diff --git a/Makefile b/Makefile index 374f23c..4351bf2 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ db-up db-down docker-up docker-down docker-demo docker-logs \ init migrate demo ingest explain clusters ask \ api web web-serve worker test test-unit test-int test-cov lint format \ - openapi client-go client-python clean + openapi jsonschema client-go client-python clean PYTHON := python PIP := pip @@ -41,6 +41,7 @@ help: @echo " make lint Ruff lint" @echo " make format Ruff format" @echo " make openapi Export OpenAPI schema to clients/openapi.json" + @echo " make jsonschema Export /v1/query JSON Schemas to clients/jsonschema/" @echo " make client-go Generate Go client (oapi-codegen; no-op if missing)" @echo " make client-python Optional OpenAPI Python generator, or use src/clients/v1.py" @echo " make clean Remove build artifacts" @@ -156,6 +157,9 @@ format: openapi: PYTHONPATH=. $(PYTHON) scripts/export_openapi.py +jsonschema: + PYTHONPATH=. $(PYTHON) scripts/export_jsonschema.py + # Generates clients/go/client.go when oapi-codegen is installed. # Missing binary: print install hint and exit 0 so CI without Go still passes. client-go: openapi diff --git a/README.md b/README.md index db19757..4677017 100644 --- a/README.md +++ b/README.md @@ -1052,7 +1052,7 @@ Overlapping tail poll windows can **double-count** the same physical lines until Unversioned `/ingestions`, `/query/*`, and `/config` remain as **deprecated aliases** for one release. They behave the same as the `/v1` paths and send `Deprecation: true` plus a `Link: ; rel="successor-version"` header. `/health`, the web UI (`/`), and `/static` stay unversioned. -**Compatibility policy.** Additive changes stay in `v1`. Breaking path or method removals require `v2`. JSON response bodies are unchanged in this release (a stable evidence schema is tracked separately as G7). +**Compatibility policy.** Additive changes stay in `v1`. Breaking path or method removals require `v2`. `/v1/query/*` JSON bodies are **schema_version 1.0**: structured fields plus an `llm` provenance block (`used`, `provider`, `model`, `fell_back`). Prose is `rendered_text` (and `format: text` still adds a `text` alias; `format: markdown` still adds `markdown`). Additive fields may appear in 1.x; a breaking body change requires `schema_version` 2.0 / `v2`. **OpenAPI and clients.** Export the spec with `make openapi` (`clients/openapi.json`). CI uploads that file as a workflow artifact and attaches it to GitHub Releases on tags. A thin typed Python client ships as `src.clients.v1.RaglogsClient` (targets `/v1`). `make client-go` runs [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) into `clients/go/` when the binary is installed; otherwise it prints the install command and exits 0. See `clients/README.md`. @@ -1066,25 +1066,34 @@ curl -X POST http://localhost:8000/v1/query/explain \ ```json { - "window": {"start": "2026-03-12T22:00:00Z", "end": "2026-03-12T22:30:00Z"}, - "summary": "Incident summary\n\nWindow: ...", - "confidence": "medium-high", - "mode": "rules", - "total_logs": 464, - "services_affected": ["api", "billing-worker"], + "schema_version": "1.0", + "scope": "default", + "window": {"from": "2026-03-12T22:00:00+00:00", "to": "2026-03-12T22:30:00+00:00"}, + "confidence": {"label": "medium-high", "score": 0.72}, + "summary": "Stripe signature verification failed for endpoint /webhooks/stripe", + "trigger": {"detected": false, "type": null, "service": null, "at": null, "correlation": null}, "primary_cluster": { - "message": "Stripe signature verification failed for endpoint /webhooks/stripe", + "fingerprint": "a1b2c3d4", + "template": "Stripe signature verification failed for endpoint /webhooks/stripe", "count": 184, "baseline_count": 0, - "change_ratio": 185.0 + "change_ratio": 185.0, + "services": ["billing-worker"], + "levels": ["error"] }, - "evidence": ["184 similar errors in billing-worker", "..."] + "evidence": [ + {"kind": "log", "detail": "184 similar errors in billing-worker"} + ], + "llm": {"used": false, "provider": "disabled", "model": "gpt-4.1-mini", "fell_back": false}, + "rendered_text": "Incident summary\n\nWindow: ...", + "cached": false, + "total_logs": 464 } ``` **Explain** — `POST /v1/query/explain` accepts the same window filters as the CLI. Optional `"format": "markdown"` adds a paste-ready `markdown` incident report field alongside the JSON payload (same shape as `raglogs explain --format markdown`). -**Timeline** — `POST /v1/query/timeline` accepts the same window filters as the CLI (`since` or `from_time`/`to_time`, optional `service`, `env`, `all_ingestions`, `ingestion_job_id`). Set `"format": "text"` to include a plain-text `text` field alongside `events`. +**Timeline** — `POST /v1/query/timeline` accepts the same window filters as the CLI (`since` or `from_time`/`to_time`, optional `service`, `env`, `all_ingestions`, `ingestion_job_id`). Set `"format": "text"` to include plain-text `rendered_text` (and a `text` alias) alongside `events`. Timeline is rules-only (`llm.used` is always false). ```bash curl -X POST http://localhost:8000/v1/query/timeline \ @@ -1092,7 +1101,9 @@ curl -X POST http://localhost:8000/v1/query/timeline \ -d '{"since": "2h", "format": "json"}' ``` -**Compare** — `POST /v1/query/compare` matches `raglogs compare`: either `"since"` + `"baseline"` (durations, window A ends at request time) or explicit `window_a_from` / `window_a_to` / `window_b_from` / `window_b_to`. Optional `"format": "text"` adds a rendered `text` field. +**Compare** — `POST /v1/query/compare` matches `raglogs compare`: either `"since"` + `"baseline"` (durations, window A ends at request time) or explicit `window_a_from` / `window_a_to` / `window_b_from` / `window_b_to`. Optional `"format": "text"` adds `rendered_text` (and a `text` alias). Cluster diffs include a `marker` (`+` / `-` / `↑` / `↓`; triggers use `+⚡` / `-⚡`). Compare is rules-only. + +Published JSON Schema files live in `clients/jsonschema/` (`explain.v1.json`, `timeline.v1.json`, `compare.v1.json`, `ask.v1.json`). Export with `make jsonschema`. ```bash curl -X POST http://localhost:8000/v1/query/compare \ diff --git a/clients/README.md b/clients/README.md index ca55276..76a9a18 100644 --- a/clients/README.md +++ b/clients/README.md @@ -10,6 +10,15 @@ make openapi `clients/openapi.json` is the published contract. CI uploads it as a workflow artifact on every push/PR and attaches it to GitHub Releases on tags. +## JSON Schema (`/v1/query/*`) + +Versioned response bodies (`schema_version` 1.0) are published as JSON Schema: + +```bash +make jsonschema +# writes clients/jsonschema/explain.v1.json (and timeline/compare/ask/clusters) +``` + ## Python A thin typed httpx client ships in-tree: diff --git a/clients/jsonschema/ask.v1.json b/clients/jsonschema/ask.v1.json new file mode 100644 index 0000000..01ef797 --- /dev/null +++ b/clients/jsonschema/ask.v1.json @@ -0,0 +1,106 @@ +{ + "$defs": { + "LlmProvenance": { + "properties": { + "used": { + "title": "Used", + "type": "boolean" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "fell_back": { + "title": "Fell Back", + "type": "boolean" + } + }, + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance", + "type": "object" + } + }, + "properties": { + "schema_version": { + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "question": { + "title": "Question", + "type": "string" + }, + "answer": { + "title": "Answer", + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "title": "Evidence", + "type": "array" + }, + "clusters": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Clusters", + "type": "array" + }, + "total_matches": { + "default": 0, + "title": "Total Matches", + "type": "integer" + }, + "retrieval_mode": { + "default": "keyword", + "title": "Retrieval Mode", + "type": "string" + }, + "llm": { + "$ref": "#/$defs/LlmProvenance" + }, + "rendered_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered Text" + }, + "mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mode" + } + }, + "required": [ + "question", + "answer", + "llm" + ], + "title": "AskResponse", + "type": "object" +} diff --git a/clients/jsonschema/clusters.v1.json b/clients/jsonschema/clusters.v1.json new file mode 100644 index 0000000..a80a357 --- /dev/null +++ b/clients/jsonschema/clusters.v1.json @@ -0,0 +1,95 @@ +{ + "$defs": { + "LlmProvenance": { + "properties": { + "used": { + "title": "Used", + "type": "boolean" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "fell_back": { + "title": "Fell Back", + "type": "boolean" + } + }, + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance", + "type": "object" + }, + "TimeWindow": { + "description": "Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601).", + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "to": { + "title": "To", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "TimeWindow", + "type": "object" + } + }, + "properties": { + "schema_version": { + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "default": "default", + "title": "Scope", + "type": "string" + }, + "window": { + "$ref": "#/$defs/TimeWindow" + }, + "clusters": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Clusters", + "type": "array" + }, + "llm": { + "$ref": "#/$defs/LlmProvenance" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ingestion Job Id" + } + }, + "required": [ + "window", + "llm" + ], + "title": "ClustersResponse", + "type": "object" +} diff --git a/clients/jsonschema/compare.v1.json b/clients/jsonschema/compare.v1.json new file mode 100644 index 0000000..4a51210 --- /dev/null +++ b/clients/jsonschema/compare.v1.json @@ -0,0 +1,239 @@ +{ + "$defs": { + "ClusterDiffModel": { + "properties": { + "fingerprint": { + "title": "Fingerprint", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "services": { + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "count_a": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count A" + }, + "count_b": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count B" + }, + "marker": { + "title": "Marker", + "type": "string" + } + }, + "required": [ + "fingerprint", + "message", + "marker" + ], + "title": "ClusterDiffModel", + "type": "object" + }, + "LlmProvenance": { + "properties": { + "used": { + "title": "Used", + "type": "boolean" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "fell_back": { + "title": "Fell Back", + "type": "boolean" + } + }, + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance", + "type": "object" + }, + "TimeWindow": { + "description": "Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601).", + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "to": { + "title": "To", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "TimeWindow", + "type": "object" + }, + "TriggerDiffModel": { + "properties": { + "message": { + "title": "Message", + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Service" + }, + "only_in": { + "title": "Only In", + "type": "string" + }, + "marker": { + "title": "Marker", + "type": "string" + } + }, + "required": [ + "message", + "only_in", + "marker" + ], + "title": "TriggerDiffModel", + "type": "object" + } + }, + "properties": { + "schema_version": { + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "default": "default", + "title": "Scope", + "type": "string" + }, + "window_a": { + "$ref": "#/$defs/TimeWindow" + }, + "window_b": { + "$ref": "#/$defs/TimeWindow" + }, + "has_changes": { + "title": "Has Changes", + "type": "boolean" + }, + "new_clusters": { + "items": { + "$ref": "#/$defs/ClusterDiffModel" + }, + "title": "New Clusters", + "type": "array" + }, + "disappeared_clusters": { + "items": { + "$ref": "#/$defs/ClusterDiffModel" + }, + "title": "Disappeared Clusters", + "type": "array" + }, + "increased_clusters": { + "items": { + "$ref": "#/$defs/ClusterDiffModel" + }, + "title": "Increased Clusters", + "type": "array" + }, + "decreased_clusters": { + "items": { + "$ref": "#/$defs/ClusterDiffModel" + }, + "title": "Decreased Clusters", + "type": "array" + }, + "new_triggers": { + "items": { + "$ref": "#/$defs/TriggerDiffModel" + }, + "title": "New Triggers", + "type": "array" + }, + "dropped_triggers": { + "items": { + "$ref": "#/$defs/TriggerDiffModel" + }, + "title": "Dropped Triggers", + "type": "array" + }, + "llm": { + "$ref": "#/$defs/LlmProvenance" + }, + "rendered_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered Text" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "window_a", + "window_b", + "has_changes", + "llm" + ], + "title": "CompareResponse", + "type": "object" +} diff --git a/clients/jsonschema/explain.v1.json b/clients/jsonschema/explain.v1.json new file mode 100644 index 0000000..7a0c0e8 --- /dev/null +++ b/clients/jsonschema/explain.v1.json @@ -0,0 +1,391 @@ +{ + "$defs": { + "ClusterEvidence": { + "properties": { + "fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fingerprint" + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Template" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "baseline_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline Count" + }, + "change_ratio": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Change Ratio" + }, + "services": { + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "levels": { + "items": { + "type": "string" + }, + "title": "Levels", + "type": "array" + }, + "first_seen": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Seen" + }, + "last_seen": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Seen" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "title": "ClusterEvidence", + "type": "object" + }, + "Confidence": { + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "score": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Score", + "type": "number" + } + }, + "required": [ + "label", + "score" + ], + "title": "Confidence", + "type": "object" + }, + "EvidenceItem": { + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "source_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Ref" + } + }, + "required": [ + "kind", + "detail" + ], + "title": "EvidenceItem", + "type": "object" + }, + "LlmProvenance": { + "properties": { + "used": { + "title": "Used", + "type": "boolean" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "fell_back": { + "title": "Fell Back", + "type": "boolean" + } + }, + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance", + "type": "object" + }, + "TimeWindow": { + "description": "Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601).", + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "to": { + "title": "To", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "TimeWindow", + "type": "object" + }, + "TriggerInfo": { + "properties": { + "detected": { + "title": "Detected", + "type": "boolean" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Service" + }, + "at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "At" + }, + "correlation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation" + } + }, + "required": [ + "detected" + ], + "title": "TriggerInfo", + "type": "object" + } + }, + "properties": { + "schema_version": { + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "default": "default", + "title": "Scope", + "type": "string" + }, + "window": { + "$ref": "#/$defs/TimeWindow" + }, + "confidence": { + "$ref": "#/$defs/Confidence" + }, + "summary": { + "title": "Summary", + "type": "string" + }, + "trigger": { + "$ref": "#/$defs/TriggerInfo" + }, + "primary_cluster": { + "anyOf": [ + { + "$ref": "#/$defs/ClusterEvidence" + }, + { + "type": "null" + } + ], + "default": null + }, + "secondary_clusters": { + "items": { + "$ref": "#/$defs/ClusterEvidence" + }, + "title": "Secondary Clusters", + "type": "array" + }, + "evidence": { + "items": { + "$ref": "#/$defs/EvidenceItem" + }, + "title": "Evidence", + "type": "array" + }, + "llm": { + "$ref": "#/$defs/LlmProvenance" + }, + "rendered_text": { + "default": "", + "title": "Rendered Text", + "type": "string" + }, + "markdown": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Markdown" + }, + "cached": { + "default": false, + "title": "Cached", + "type": "boolean" + }, + "total_logs": { + "default": 0, + "title": "Total Logs", + "type": "integer" + }, + "mode": { + "default": "rules", + "title": "Mode", + "type": "string" + }, + "services_affected": { + "items": { + "type": "string" + }, + "title": "Services Affected", + "type": "array" + } + }, + "required": [ + "window", + "confidence", + "summary", + "trigger", + "llm" + ], + "title": "ExplainResponse", + "type": "object" +} diff --git a/clients/jsonschema/timeline.v1.json b/clients/jsonschema/timeline.v1.json new file mode 100644 index 0000000..f978b4d --- /dev/null +++ b/clients/jsonschema/timeline.v1.json @@ -0,0 +1,182 @@ +{ + "$defs": { + "LlmProvenance": { + "properties": { + "used": { + "title": "Used", + "type": "boolean" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "fell_back": { + "title": "Fell Back", + "type": "boolean" + } + }, + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance", + "type": "object" + }, + "TimeWindow": { + "description": "Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601).", + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "to": { + "title": "To", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "TimeWindow", + "type": "object" + }, + "TimelineEventModel": { + "properties": { + "timestamp": { + "title": "Timestamp", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "services": { + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "duration_minutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Minutes" + } + }, + "required": [ + "timestamp", + "category", + "label", + "description" + ], + "title": "TimelineEventModel", + "type": "object" + } + }, + "properties": { + "schema_version": { + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "default": "default", + "title": "Scope", + "type": "string" + }, + "window": { + "$ref": "#/$defs/TimeWindow" + }, + "events": { + "items": { + "$ref": "#/$defs/TimelineEventModel" + }, + "title": "Events", + "type": "array" + }, + "llm": { + "$ref": "#/$defs/LlmProvenance" + }, + "rendered_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered Text" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ingestion Job Id" + }, + "all_ingestions": { + "default": false, + "title": "All Ingestions", + "type": "boolean" + } + }, + "required": [ + "window", + "llm" + ], + "title": "TimelineResponse", + "type": "object" +} diff --git a/clients/openapi.json b/clients/openapi.json index 3ab2c83..4e326fd 100644 --- a/clients/openapi.json +++ b/clients/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "raglogs", - "description": "Incident explanation API \u2014 ask your logs what happened.\n\nCanonical query, ingest, and config routes live under `/v1/`. Unversioned\n`/ingestions`, `/query`, and `/config` paths are deprecated aliases for one\nrelease and include a `Deprecation: true` header.\n\nCompatibility: additive changes stay in `v1`; breaking changes require `v2`.\nJSON response bodies are unchanged in this release.\n", + "description": "Incident explanation API \u2014 ask your logs what happened.\n\nCanonical query, ingest, and config routes live under `/v1/`. Unversioned\n`/ingestions`, `/query`, and `/config` paths are deprecated aliases for one\nrelease and include a `Deprecation: true` header.\n\nCompatibility: additive changes stay in `v1`; breaking changes require `v2`.\n`/v1/query/*` JSON bodies use `schema_version` `1.0` with an `llm` provenance\nblock (`used`, `provider`, `model`, `fell_back`). Prose is `rendered_text`.\n", "version": "0.1.0" }, "paths": { @@ -774,7 +774,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ExplainResponse" + } } } }, @@ -813,7 +815,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ExplainResponse" + } } } }, @@ -853,7 +857,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/AskResponse" + } } } }, @@ -892,7 +898,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/AskResponse" + } } } }, @@ -932,7 +940,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ClustersResponse" + } } } }, @@ -971,7 +981,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ClustersResponse" + } } } }, @@ -1011,7 +1023,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/TimelineResponse" + } } } }, @@ -1050,7 +1064,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/TimelineResponse" + } } } }, @@ -1090,7 +1106,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/CompareResponse" + } } } }, @@ -1129,7 +1147,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/CompareResponse" + } } } }, @@ -1259,9 +1279,50 @@ ], "title": "AskRequest" }, - "ClustersRequest": { + "AskResponse": { "properties": { - "since": { + "schema_version": { + "type": "string", + "title": "Schema Version", + "default": "1.0" + }, + "question": { + "type": "string", + "title": "Question" + }, + "answer": { + "type": "string", + "title": "Answer" + }, + "evidence": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Evidence" + }, + "clusters": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Clusters" + }, + "total_matches": { + "type": "integer", + "title": "Total Matches", + "default": 0 + }, + "retrieval_mode": { + "type": "string", + "title": "Retrieval Mode", + "default": "keyword" + }, + "llm": { + "$ref": "#/components/schemas/LlmProvenance" + }, + "rendered_text": { "anyOf": [ { "type": "string" @@ -1270,33 +1331,83 @@ "type": "null" } ], - "title": "Since" + "title": "Rendered Text" }, - "from_time": { + "mode": { "anyOf": [ { - "type": "string", - "format": "date-time" + "type": "string" }, { "type": "null" } ], - "title": "From Time" + "title": "Mode" + } + }, + "type": "object", + "required": [ + "question", + "answer", + "llm" + ], + "title": "AskResponse" + }, + "ClusterDiffModel": { + "properties": { + "fingerprint": { + "type": "string", + "title": "Fingerprint" }, - "to_time": { + "message": { + "type": "string", + "title": "Message" + }, + "services": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Services" + }, + "count_a": { "anyOf": [ { - "type": "string", - "format": "date-time" + "type": "integer" }, { "type": "null" } ], - "title": "To Time" + "title": "Count A" }, - "service": { + "count_b": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Count B" + }, + "marker": { + "type": "string", + "title": "Marker" + } + }, + "type": "object", + "required": [ + "fingerprint", + "message", + "marker" + ], + "title": "ClusterDiffModel" + }, + "ClusterEvidence": { + "properties": { + "fingerprint": { "anyOf": [ { "type": "string" @@ -1305,9 +1416,9 @@ "type": "null" } ], - "title": "Service" + "title": "Fingerprint" }, - "env": { + "template": { "anyOf": [ { "type": "string" @@ -1316,42 +1427,56 @@ "type": "null" } ], - "title": "Env" + "title": "Template" }, - "top": { - "type": "integer", - "title": "Top", - "default": 15 + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Count" }, - "ingestion_job_id": { + "baseline_count": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Ingestion Job Id" - } - }, - "type": "object", - "title": "ClustersRequest" - }, - "CompareRequest": { - "properties": { - "since": { + "title": "Baseline Count" + }, + "change_ratio": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Since" + "title": "Change Ratio" }, - "baseline": { + "services": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Services" + }, + "levels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Levels" + }, + "first_seen": { "anyOf": [ { "type": "string" @@ -1360,33 +1485,48 @@ "type": "null" } ], - "title": "Baseline" + "title": "First Seen" }, - "window_a_from": { + "last_seen": { "anyOf": [ { - "type": "string", - "format": "date-time" + "type": "string" }, { "type": "null" } ], - "title": "Window A From" + "title": "Last Seen" }, - "window_a_to": { + "message": { "anyOf": [ { - "type": "string", - "format": "date-time" + "type": "string" }, { "type": "null" } ], - "title": "Window A To" + "title": "Message" + } + }, + "type": "object", + "title": "ClusterEvidence" + }, + "ClustersRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" }, - "window_b_from": { + "from_time": { "anyOf": [ { "type": "string", @@ -1396,9 +1536,9 @@ "type": "null" } ], - "title": "Window B From" + "title": "From Time" }, - "window_b_to": { + "to_time": { "anyOf": [ { "type": "string", @@ -1408,7 +1548,7 @@ "type": "null" } ], - "title": "Window B To" + "title": "To Time" }, "service": { "anyOf": [ @@ -1432,10 +1572,10 @@ ], "title": "Env" }, - "all_ingestions": { - "type": "boolean", - "title": "All Ingestions", - "default": false + "top": { + "type": "integer", + "title": "Top", + "default": 15 }, "ingestion_job_id": { "anyOf": [ @@ -1447,36 +1587,36 @@ } ], "title": "Ingestion Job Id" - }, - "format": { - "type": "string", - "enum": [ - "json", - "text" - ], - "title": "Format", - "default": "json" } }, "type": "object", - "title": "CompareRequest" + "title": "ClustersRequest" }, - "EnqueuedResponse": { + "ClustersResponse": { "properties": { - "worker_job_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Worker Job Id" + "schema_version": { + "type": "string", + "title": "Schema Version", + "default": "1.0" }, - "status": { + "scope": { "type": "string", - "title": "Status" + "title": "Scope", + "default": "default" + }, + "window": { + "$ref": "#/components/schemas/TimeWindow" + }, + "clusters": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Clusters" + }, + "llm": { + "$ref": "#/components/schemas/LlmProvenance" }, "ingestion_job_id": { "anyOf": [ @@ -1488,20 +1628,16 @@ } ], "title": "Ingestion Job Id" - }, - "mode": { - "type": "string", - "title": "Mode", - "default": "batch" } }, "type": "object", "required": [ - "status" + "window", + "llm" ], - "title": "EnqueuedResponse" + "title": "ClustersResponse" }, - "ExplainRequest": { + "CompareRequest": { "properties": { "since": { "anyOf": [ @@ -1514,19 +1650,18 @@ ], "title": "Since" }, - "from_time": { + "baseline": { "anyOf": [ { - "type": "string", - "format": "date-time" + "type": "string" }, { "type": "null" } ], - "title": "From Time" + "title": "Baseline" }, - "to_time": { + "window_a_from": { "anyOf": [ { "type": "string", @@ -1536,34 +1671,357 @@ "type": "null" } ], - "title": "To Time" + "title": "Window A From" }, - "service": { + "window_a_to": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Service" + "title": "Window A To" }, - "env": { + "window_b_from": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" } ], - "title": "Env" + "title": "Window B From" }, - "no_llm": { - "type": "boolean", - "title": "No Llm", - "default": false + "window_b_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Window B To" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "all_ingestions": { + "type": "boolean", + "title": "All Ingestions", + "default": false + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "format": { + "type": "string", + "enum": [ + "json", + "text" + ], + "title": "Format", + "default": "json" + } + }, + "type": "object", + "title": "CompareRequest" + }, + "CompareResponse": { + "properties": { + "schema_version": { + "type": "string", + "title": "Schema Version", + "default": "1.0" + }, + "scope": { + "type": "string", + "title": "Scope", + "default": "default" + }, + "window_a": { + "$ref": "#/components/schemas/TimeWindow" + }, + "window_b": { + "$ref": "#/components/schemas/TimeWindow" + }, + "has_changes": { + "type": "boolean", + "title": "Has Changes" + }, + "new_clusters": { + "items": { + "$ref": "#/components/schemas/ClusterDiffModel" + }, + "type": "array", + "title": "New Clusters" + }, + "disappeared_clusters": { + "items": { + "$ref": "#/components/schemas/ClusterDiffModel" + }, + "type": "array", + "title": "Disappeared Clusters" + }, + "increased_clusters": { + "items": { + "$ref": "#/components/schemas/ClusterDiffModel" + }, + "type": "array", + "title": "Increased Clusters" + }, + "decreased_clusters": { + "items": { + "$ref": "#/components/schemas/ClusterDiffModel" + }, + "type": "array", + "title": "Decreased Clusters" + }, + "new_triggers": { + "items": { + "$ref": "#/components/schemas/TriggerDiffModel" + }, + "type": "array", + "title": "New Triggers" + }, + "dropped_triggers": { + "items": { + "$ref": "#/components/schemas/TriggerDiffModel" + }, + "type": "array", + "title": "Dropped Triggers" + }, + "llm": { + "$ref": "#/components/schemas/LlmProvenance" + }, + "rendered_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rendered Text" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text" + } + }, + "type": "object", + "required": [ + "window_a", + "window_b", + "has_changes", + "llm" + ], + "title": "CompareResponse" + }, + "Confidence": { + "properties": { + "label": { + "type": "string", + "title": "Label" + }, + "score": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Score" + } + }, + "type": "object", + "required": [ + "label", + "score" + ], + "title": "Confidence" + }, + "EnqueuedResponse": { + "properties": { + "worker_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Worker Job Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "mode": { + "type": "string", + "title": "Mode", + "default": "batch" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "EnqueuedResponse" + }, + "EvidenceItem": { + "properties": { + "kind": { + "type": "string", + "title": "Kind" + }, + "detail": { + "type": "string", + "title": "Detail" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ref" + }, + "source_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Ref" + } + }, + "type": "object", + "required": [ + "kind", + "detail" + ], + "title": "EvidenceItem" + }, + "ExplainRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "no_llm": { + "type": "boolean", + "title": "No Llm", + "default": false }, "max_clusters": { "type": "integer", @@ -1610,6 +2068,107 @@ "type": "object", "title": "ExplainRequest" }, + "ExplainResponse": { + "properties": { + "schema_version": { + "type": "string", + "title": "Schema Version", + "default": "1.0" + }, + "scope": { + "type": "string", + "title": "Scope", + "default": "default" + }, + "window": { + "$ref": "#/components/schemas/TimeWindow" + }, + "confidence": { + "$ref": "#/components/schemas/Confidence" + }, + "summary": { + "type": "string", + "title": "Summary" + }, + "trigger": { + "$ref": "#/components/schemas/TriggerInfo" + }, + "primary_cluster": { + "anyOf": [ + { + "$ref": "#/components/schemas/ClusterEvidence" + }, + { + "type": "null" + } + ] + }, + "secondary_clusters": { + "items": { + "$ref": "#/components/schemas/ClusterEvidence" + }, + "type": "array", + "title": "Secondary Clusters" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/EvidenceItem" + }, + "type": "array", + "title": "Evidence" + }, + "llm": { + "$ref": "#/components/schemas/LlmProvenance" + }, + "rendered_text": { + "type": "string", + "title": "Rendered Text", + "default": "" + }, + "markdown": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Markdown" + }, + "cached": { + "type": "boolean", + "title": "Cached", + "default": false + }, + "total_logs": { + "type": "integer", + "title": "Total Logs", + "default": 0 + }, + "mode": { + "type": "string", + "title": "Mode", + "default": "rules" + }, + "services_affected": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Services Affected" + } + }, + "type": "object", + "required": [ + "window", + "confidence", + "summary", + "trigger", + "llm" + ], + "title": "ExplainResponse" + }, "HTTPValidationError": { "properties": { "detail": { @@ -2002,6 +2561,34 @@ ], "title": "LatestIngestionResponse" }, + "LlmProvenance": { + "properties": { + "used": { + "type": "boolean", + "title": "Used" + }, + "provider": { + "type": "string", + "title": "Provider" + }, + "model": { + "type": "string", + "title": "Model" + }, + "fell_back": { + "type": "boolean", + "title": "Fell Back" + } + }, + "type": "object", + "required": [ + "used", + "provider", + "model", + "fell_back" + ], + "title": "LlmProvenance" + }, "PushLinesResponse": { "properties": { "ingestion_job_id": { @@ -2063,6 +2650,82 @@ ], "title": "TailLifecycleResponse" }, + "TimeWindow": { + "properties": { + "from": { + "type": "string", + "title": "From" + }, + "to": { + "type": "string", + "title": "To" + } + }, + "type": "object", + "required": [ + "from", + "to" + ], + "title": "TimeWindow", + "description": "Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601)." + }, + "TimelineEventModel": { + "properties": { + "timestamp": { + "type": "string", + "title": "Timestamp" + }, + "category": { + "type": "string", + "title": "Category" + }, + "label": { + "type": "string", + "title": "Label" + }, + "description": { + "type": "string", + "title": "Description" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Count" + }, + "services": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Services" + }, + "duration_minutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Duration Minutes" + } + }, + "type": "object", + "required": [ + "timestamp", + "category", + "label", + "description" + ], + "title": "TimelineEventModel" + }, "TimelineRequest": { "properties": { "since": { @@ -2151,6 +2814,168 @@ "type": "object", "title": "TimelineRequest" }, + "TimelineResponse": { + "properties": { + "schema_version": { + "type": "string", + "title": "Schema Version", + "default": "1.0" + }, + "scope": { + "type": "string", + "title": "Scope", + "default": "default" + }, + "window": { + "$ref": "#/components/schemas/TimeWindow" + }, + "events": { + "items": { + "$ref": "#/components/schemas/TimelineEventModel" + }, + "type": "array", + "title": "Events" + }, + "llm": { + "$ref": "#/components/schemas/LlmProvenance" + }, + "rendered_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rendered Text" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "all_ingestions": { + "type": "boolean", + "title": "All Ingestions", + "default": false + } + }, + "type": "object", + "required": [ + "window", + "llm" + ], + "title": "TimelineResponse" + }, + "TriggerDiffModel": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "only_in": { + "type": "string", + "title": "Only In" + }, + "marker": { + "type": "string", + "title": "Marker" + } + }, + "type": "object", + "required": [ + "message", + "only_in", + "marker" + ], + "title": "TriggerDiffModel" + }, + "TriggerInfo": { + "properties": { + "detected": { + "type": "boolean", + "title": "Detected" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "At" + }, + "correlation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Correlation" + } + }, + "type": "object", + "required": [ + "detected" + ], + "title": "TriggerInfo" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/export_jsonschema.py b/scripts/export_jsonschema.py new file mode 100644 index 0000000..d423ed4 --- /dev/null +++ b/scripts/export_jsonschema.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Export Pydantic v1 query models to clients/jsonschema/*.v1.json. + +Works the same way CI tests do: ``PYTHONPATH=. python scripts/export_jsonschema.py``. +Does not start a server or connect to the database. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.api.schemas.v1 import ( # noqa: E402 + AskResponse, + ClustersResponse, + CompareResponse, + ExplainResponse, + TimelineResponse, +) + +MODELS: tuple[tuple[str, type], ...] = ( + ("explain.v1.json", ExplainResponse), + ("timeline.v1.json", TimelineResponse), + ("compare.v1.json", CompareResponse), + ("ask.v1.json", AskResponse), + ("clusters.v1.json", ClustersResponse), +) + + +def main() -> None: + out_dir = ROOT / "clients" / "jsonschema" + out_dir.mkdir(parents=True, exist_ok=True) + for filename, model in MODELS: + path = out_dir / filename + schema = model.model_json_schema() + path.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/src/api/app.py b/src/api/app.py index 1e1af07..fc14cb7 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -19,7 +19,8 @@ release and include a `Deprecation: true` header. Compatibility: additive changes stay in `v1`; breaking changes require `v2`. -JSON response bodies are unchanged in this release. +`/v1/query/*` JSON bodies use `schema_version` `1.0` with an `llm` provenance +block (`used`, `provider`, `model`, `fell_back`). Prose is `rendered_text`. """ diff --git a/src/api/routes/ask.py b/src/api/routes/ask.py index 7cf639e..6aac79e 100644 --- a/src/api/routes/ask.py +++ b/src/api/routes/ask.py @@ -5,6 +5,13 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from src.api.schemas.v1 import ( + SCHEMA_VERSION, + AskResponse, + llm_from_mode, + llm_requested, +) + router = APIRouter() @@ -17,8 +24,12 @@ class AskRequest(BaseModel): ingestion_job_id: Optional[str] = None -@router.post("/ask") -def ask_endpoint(request: AskRequest): +@router.post( + "/ask", + response_model=AskResponse, + response_model_exclude_unset=True, +) +def ask_endpoint(request: AskRequest) -> AskResponse: from src.core.retrieval.question_router import answer_question from src.db.session import get_db from src.utils.time import resolve_window @@ -54,11 +65,15 @@ def ask_endpoint(request: AskRequest): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - return { - "question": result.question, - "answer": result.answer_text, - "evidence": result.evidence_items, - "clusters": result.clusters_used, - "total_matches": result.total_matches, - "retrieval_mode": result.retrieval_mode, - } + return AskResponse( + schema_version=SCHEMA_VERSION, + question=result.question, + answer=result.answer_text, + evidence=result.evidence_items, + clusters=result.clusters_used, + total_matches=result.total_matches, + retrieval_mode=result.retrieval_mode, + llm=llm_from_mode(mode=result.mode, requested=llm_requested()), + rendered_text=result.answer_text, + mode=result.mode, + ) diff --git a/src/api/routes/clusters.py b/src/api/routes/clusters.py index bfd23cc..09ed55b 100644 --- a/src/api/routes/clusters.py +++ b/src/api/routes/clusters.py @@ -3,9 +3,17 @@ from datetime import datetime from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from src.api.schemas.v1 import ( + SCHEMA_VERSION, + ClustersResponse, + llm_rules_only, + scope_from_request, + window_from_bounds, +) + router = APIRouter() @@ -19,8 +27,13 @@ class ClustersRequest(BaseModel): ingestion_job_id: Optional[str] = None -@router.post("/clusters") -def clusters_endpoint(request: ClustersRequest): +@router.post( + "/clusters", + response_model=ClustersResponse, + response_model_exclude_unset=True, + response_model_by_alias=True, +) +def clusters_endpoint(request: ClustersRequest, http_request: Request) -> ClustersResponse: from src.core.clustering.clusterer import run_clustering from src.db.session import get_db from src.utils.time import resolve_window @@ -56,10 +69,13 @@ def clusters_endpoint(request: ClustersRequest): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - return { - "window": {"start": window_start.isoformat(), "end": window_end.isoformat()}, - "ingestion_job_id": request.ingestion_job_id, - "clusters": [ + return ClustersResponse( + schema_version=SCHEMA_VERSION, + scope=scope_from_request(http_request), + window=window_from_bounds(window_start, window_end), + ingestion_job_id=request.ingestion_job_id, + llm=llm_rules_only(), + clusters=[ { "fingerprint": c.fingerprint, "message": c.representative_message, @@ -76,4 +92,4 @@ def clusters_endpoint(request: ClustersRequest): } for c in clusters ], - } + ) diff --git a/src/api/routes/compare_windows.py b/src/api/routes/compare_windows.py index 671b5e9..70a4959 100644 --- a/src/api/routes/compare_windows.py +++ b/src/api/routes/compare_windows.py @@ -5,9 +5,10 @@ from datetime import datetime, timezone from typing import Literal, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from src.api.schemas.v1 import CompareResponse from src.core.compare.differ import CompareResult router = APIRouter() @@ -65,40 +66,29 @@ def _resolve_compare_windows(request: CompareRequest) -> tuple[datetime, datetim ) -def _diff_to_dict(d) -> dict: - return { - "fingerprint": d.fingerprint, - "message": d.message, - "services": d.services, - "count_a": d.count_a, - "count_b": d.count_b, - } - - -def _compare_result_to_payload(result: CompareResult) -> dict: - return { - "window_a": { - "start": result.window_a_start.isoformat(), - "end": result.window_a_end.isoformat(), - }, - "window_b": { - "start": result.window_b_start.isoformat(), - "end": result.window_b_end.isoformat(), - }, - "has_changes": result.has_changes, - "new_clusters": [_diff_to_dict(d) for d in result.new_clusters], - "disappeared_clusters": [_diff_to_dict(d) for d in result.disappeared_clusters], - "increased_clusters": [_diff_to_dict(d) for d in result.increased_clusters], - "decreased_clusters": [_diff_to_dict(d) for d in result.decreased_clusters], - "new_triggers": [ - {"message": t.message, "service": t.service, "only_in": t.only_in} - for t in result.new_triggers - ], - "dropped_triggers": [ - {"message": t.message, "service": t.service, "only_in": t.only_in} - for t in result.dropped_triggers - ], - } +def _compare_result_to_response(result: CompareResult, *, scope: str) -> CompareResponse: + from src.api.schemas.v1 import ( + SCHEMA_VERSION, + cluster_diff_model, + llm_rules_only, + trigger_diff_model, + window_from_bounds, + ) + + return CompareResponse( + schema_version=SCHEMA_VERSION, + scope=scope, + window_a=window_from_bounds(result.window_a_start, result.window_a_end), + window_b=window_from_bounds(result.window_b_start, result.window_b_end), + has_changes=result.has_changes, + new_clusters=[cluster_diff_model(d, "new") for d in result.new_clusters], + disappeared_clusters=[cluster_diff_model(d, "disappeared") for d in result.disappeared_clusters], + increased_clusters=[cluster_diff_model(d, "increased") for d in result.increased_clusters], + decreased_clusters=[cluster_diff_model(d, "decreased") for d in result.decreased_clusters], + new_triggers=[trigger_diff_model(t) for t in result.new_triggers], + dropped_triggers=[trigger_diff_model(t) for t in result.dropped_triggers], + llm=llm_rules_only(), + ) def _trunc(text: str, n: int = 72) -> str: @@ -181,8 +171,14 @@ def row(sigil: str, msg: str, count_a: Optional[int], count_b: Optional[int]) -> return "\n".join(lines) -@router.post("/compare") -def compare_endpoint(request: CompareRequest): +@router.post( + "/compare", + response_model=CompareResponse, + response_model_exclude_unset=True, + response_model_by_alias=True, +) +def compare_endpoint(request: CompareRequest, http_request: Request) -> CompareResponse: + from src.api.schemas.v1 import scope_from_request from src.core.clustering.clusterer import run_clustering from src.core.compare.differ import compare_windows as run_compare_windows from src.core.explain.evidence import assemble_evidence @@ -263,7 +259,8 @@ def compare_endpoint(request: CompareRequest): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - payload = _compare_result_to_payload(result) + body = _compare_result_to_response(result, scope=scope_from_request(http_request)) if request.format == "text": - payload["text"] = format_compare_plain(result) - return payload + rendered = format_compare_plain(result) + body = body.model_copy(update={"rendered_text": rendered, "text": rendered}) + return body diff --git a/src/api/routes/explain.py b/src/api/routes/explain.py index b8a095e..51f66d3 100644 --- a/src/api/routes/explain.py +++ b/src/api/routes/explain.py @@ -11,12 +11,19 @@ import hashlib import json import uuid -from datetime import datetime, timezone +from datetime import datetime from typing import TYPE_CHECKING, Literal, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from src.api.schemas.v1 import ( + ExplainResponse, + explain_from_cached, + explain_from_result, + scope_from_request, +) + if TYPE_CHECKING: from src.core.explain.summarizer import ExplainResult @@ -68,6 +75,49 @@ def _load_from_cache(db, cache_hash: str) -> Optional[dict]: return None +def _confidence_label(payload: dict) -> str: + conf = payload.get("confidence") + if isinstance(conf, dict): + return str(conf.get("label") or "low") + return str(conf or "low") + + +def _evidence_strings(payload: dict) -> list[str]: + items = payload.get("evidence") or [] + out: list[str] = [] + for item in items: + if isinstance(item, str): + out.append(item) + elif isinstance(item, dict): + out.append(str(item.get("detail") or item)) + else: + out.append(str(item)) + return out + + +def _primary_for_markdown(payload: dict) -> Optional[dict]: + pc = payload.get("primary_cluster") + if not isinstance(pc, dict): + return pc + if pc.get("message") or not pc.get("template"): + return pc + return {**pc, "message": pc.get("template")} + + +def _trigger_candidates_for_markdown(payload: dict) -> list[dict]: + candidates = payload.get("trigger_candidates") + if isinstance(candidates, list) and candidates: + return list(candidates) + trigger = payload.get("trigger") or {} + if isinstance(trigger, dict) and trigger.get("detected"): + return [{ + "message": trigger.get("type") or trigger.get("detail") or "", + "timestamp": trigger.get("at"), + "service": trigger.get("service"), + }] + return [] + + def _explain_result_from_payload( payload: dict, window_start: datetime, @@ -76,29 +126,34 @@ def _explain_result_from_payload( """Rebuild an ExplainResult from a cached/API payload for markdown rendering.""" from src.core.explain.summarizer import ExplainResult as ExplainResultCls + prose = payload.get("rendered_text") or payload.get("summary") or "" return ExplainResultCls( window_start=window_start, window_end=window_end, - summary_text=payload.get("summary") or "", - confidence=payload.get("confidence") or "low", - evidence_items=list(payload.get("evidence") or []), + summary_text=prose, + confidence=_confidence_label(payload), + evidence_items=_evidence_strings(payload), services_affected=list(payload.get("services_affected") or []), - primary_cluster=payload.get("primary_cluster"), + primary_cluster=_primary_for_markdown(payload), secondary_clusters=list(payload.get("secondary_clusters") or []), - trigger_candidates=list(payload.get("trigger_candidates") or []), + trigger_candidates=_trigger_candidates_for_markdown(payload), total_logs=int(payload.get("total_logs") or 0), mode=payload.get("mode") or "rules", ) -def _maybe_add_markdown(payload: dict, result: ExplainResult, request: ExplainRequest) -> dict: +def _maybe_add_markdown( + body: ExplainResponse, + result: ExplainResult, + request: ExplainRequest, +) -> ExplainResponse: if request.format != "markdown": - return payload + return body from src.core.explain.markdown_report import render_incident_report - out = dict(payload) - out["markdown"] = render_incident_report(result, environment=request.env) - return out + return body.model_copy( + update={"markdown": render_incident_report(result, environment=request.env)} + ) def _save_to_cache(db, cache_hash: str, window_start: datetime, window_end: datetime, @@ -121,8 +176,13 @@ def _save_to_cache(db, cache_hash: str, window_start: datetime, window_end: date db.flush() -@router.post("/explain") -def explain_endpoint(request: ExplainRequest): +@router.post( + "/explain", + response_model=ExplainResponse, + response_model_exclude_unset=True, + response_model_by_alias=True, +) +def explain_endpoint(request: ExplainRequest, http_request: Request) -> ExplainResponse: from src.core.explain.summarizer import explain_window from src.db.session import get_db from src.utils.time import resolve_window @@ -144,6 +204,7 @@ def explain_endpoint(request: ExplainRequest): raise HTTPException(status_code=400, detail="Invalid ingestion_job_id") cache_hash = _cache_key(window_start, window_end, request.service, request.env, request.ingestion_job_id) + scope = scope_from_request(http_request) try: with get_db() as db: @@ -151,11 +212,17 @@ def explain_endpoint(request: ExplainRequest): if not request.force_refresh and not request.no_llm: cached = _load_from_cache(db, cache_hash) if cached: - payload = {**cached, "cached": True} + body = explain_from_cached( + cached, + window_start=window_start, + window_end=window_end, + no_llm=request.no_llm, + scope=scope, + ) cached_result = _explain_result_from_payload( - payload, window_start, window_end + body.model_dump(by_alias=True), window_start, window_end ) - return _maybe_add_markdown(payload, cached_result, request) + return _maybe_add_markdown(body, cached_result, request) result = explain_window( db=db, @@ -169,32 +236,25 @@ def explain_endpoint(request: ExplainRequest): ingestion_job_id=ingestion_job_id, ) - payload = { - "window": { - "start": result.window_start.isoformat(), - "end": result.window_end.isoformat(), - }, - "summary": result.summary_text, - "confidence": result.confidence, - "mode": result.mode, - "total_logs": result.total_logs, - "services_affected": result.services_affected, - "primary_cluster": result.primary_cluster, - "secondary_clusters": result.secondary_clusters, - "trigger_candidates": result.trigger_candidates, - "evidence": result.evidence_items, - "cached": False, - } + body = explain_from_result( + result, + no_llm=request.no_llm, + cached=False, + scope=scope, + ) # Persist to cache (only rules mode — LLM results are expensive and should # be explicitly refreshed; rules results are deterministic for the same window) if result.mode == "rules": + cache_payload = body.model_dump(by_alias=True, exclude_unset=True) + cache_payload.pop("cached", None) + cache_payload.pop("markdown", None) _save_to_cache( db, cache_hash, window_start, window_end, - request.service, request.env, payload, result.confidence, result.mode, + request.service, request.env, cache_payload, result.confidence, result.mode, ) - return _maybe_add_markdown(payload, result, request) + return _maybe_add_markdown(body, result, request) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/api/routes/timeline.py b/src/api/routes/timeline.py index 5dd0a5c..db2c0b8 100644 --- a/src/api/routes/timeline.py +++ b/src/api/routes/timeline.py @@ -5,9 +5,17 @@ from datetime import datetime from typing import Literal, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from src.api.schemas.v1 import ( + SCHEMA_VERSION, + TimelineEventModel, + TimelineResponse, + llm_rules_only, + scope_from_request, + window_from_bounds, +) from src.core.timeline.plain_text import format_timeline_plain router = APIRouter() @@ -24,23 +32,28 @@ class TimelineRequest(BaseModel): format: Literal["json", "text"] = "json" -def _events_to_json(events) -> list[dict]: +def _events_to_models(events) -> list[TimelineEventModel]: return [ - { - "timestamp": e.timestamp.isoformat(), - "category": e.category, - "label": e.label, - "description": e.description, - "count": e.count, - "services": e.services, - "duration_minutes": e.duration_minutes, - } + TimelineEventModel( + timestamp=e.timestamp.isoformat(), + category=e.category, + label=e.label, + description=e.description, + count=e.count, + services=e.services, + duration_minutes=e.duration_minutes, + ) for e in events ] -@router.post("/timeline") -def timeline_endpoint(request: TimelineRequest): +@router.post( + "/timeline", + response_model=TimelineResponse, + response_model_exclude_unset=True, + response_model_by_alias=True, +) +def timeline_endpoint(request: TimelineRequest, http_request: Request) -> TimelineResponse: from src.core.clustering.clusterer import run_clustering from src.core.explain.evidence import assemble_evidence from src.core.explain.summarizer import get_latest_ingestion_job_id @@ -97,17 +110,16 @@ def timeline_endpoint(request: TimelineRequest): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - payload: dict = { - "window": { - "start": window_start.isoformat(), - "end": window_end.isoformat(), - }, - "ingestion_job_id": request.ingestion_job_id, - "all_ingestions": request.all_ingestions, - "events": _events_to_json(events), - } - + body = TimelineResponse( + schema_version=SCHEMA_VERSION, + scope=scope_from_request(http_request), + window=window_from_bounds(window_start, window_end), + events=_events_to_models(events), + llm=llm_rules_only(), + ingestion_job_id=request.ingestion_job_id, + all_ingestions=request.all_ingestions, + ) if request.format == "text": - payload["text"] = format_timeline_plain(events, window_start, window_end) - - return payload + rendered = format_timeline_plain(events, window_start, window_end) + body = body.model_copy(update={"rendered_text": rendered, "text": rendered}) + return body diff --git a/src/api/schemas/__init__.py b/src/api/schemas/__init__.py new file mode 100644 index 0000000..68bd759 --- /dev/null +++ b/src/api/schemas/__init__.py @@ -0,0 +1 @@ +"""HTTP API Pydantic models.""" diff --git a/src/api/schemas/v1.py b/src/api/schemas/v1.py new file mode 100644 index 0000000..647672c --- /dev/null +++ b/src/api/schemas/v1.py @@ -0,0 +1,485 @@ +"""Versioned JSON evidence schema for ``/v1/query/*`` (G7, schema_version 1.0). + +Pydantic models here are the HTTP contract. CLI still uses ``ExplainResult`` +string confidence and ``summary_text``; this module only shapes JSON bodies. +""" +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Optional + +from fastapi import Request +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator + +from src.core.explain.confidence import score_from_label +from src.core.normalization.patterns import infer_trigger_type + +if TYPE_CHECKING: + from src.core.explain.summarizer import ExplainResult + +SCHEMA_VERSION = "1.0" + +COMPARE_MARKERS: dict[str, str] = { + "new": "+", + "disappeared": "-", + "increased": "↑", + "decreased": "↓", +} +TRIGGER_MARKERS: dict[str, str] = { + "a": "+⚡", + "b": "-⚡", +} + + +class TimeWindow(BaseModel): + """Inclusive analysis window. Serialized as ``from`` / ``to`` (ISO-8601).""" + + model_config = ConfigDict(populate_by_name=True) + + from_: str = Field(validation_alias=AliasChoices("from", "from_", "start"), serialization_alias="from") + to: str = Field(validation_alias=AliasChoices("to", "end"), serialization_alias="to") + + @model_validator(mode="before") + @classmethod + def _accept_start_end(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + out = dict(data) + if "from" not in out and "from_" not in out and "start" in out: + out["from"] = out["start"] + if "to" not in out and "end" in out: + out["to"] = out["end"] + return out + + +class Confidence(BaseModel): + label: str + score: float = Field(ge=0.0, le=1.0) + + +class TriggerInfo(BaseModel): + detected: bool + type: Optional[str] = None + service: Optional[str] = None + at: Optional[str] = None + correlation: Optional[str] = None + + +class ClusterEvidence(BaseModel): + fingerprint: Optional[str] = None + template: Optional[str] = None + count: Optional[int] = None + baseline_count: Optional[int] = None + change_ratio: Optional[float] = None + services: list[str] = Field(default_factory=list) + levels: list[str] = Field(default_factory=list) + first_seen: Optional[str] = None + last_seen: Optional[str] = None + # Additive alias so existing UIs that read ``message`` keep working. + message: Optional[str] = None + + +class EvidenceItem(BaseModel): + kind: str + detail: str + ref: Optional[str] = None + source_ref: Optional[str] = None + + +class LlmProvenance(BaseModel): + used: bool + provider: str + model: str + fell_back: bool + + +class ExplainResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: str = SCHEMA_VERSION + scope: str = "default" + window: TimeWindow + confidence: Confidence + summary: str + trigger: TriggerInfo + primary_cluster: Optional[ClusterEvidence] = None + secondary_clusters: list[ClusterEvidence] = Field(default_factory=list) + evidence: list[EvidenceItem] = Field(default_factory=list) + llm: LlmProvenance + rendered_text: str = "" + markdown: Optional[str] = None + cached: bool = False + total_logs: int = 0 + mode: str = "rules" + services_affected: list[str] = Field(default_factory=list) + + +class TimelineEventModel(BaseModel): + timestamp: str + category: str + label: str + description: str + count: Optional[int] = None + services: list[str] = Field(default_factory=list) + duration_minutes: Optional[float] = None + + +class TimelineResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: str = SCHEMA_VERSION + scope: str = "default" + window: TimeWindow + events: list[TimelineEventModel] = Field(default_factory=list) + llm: LlmProvenance + rendered_text: Optional[str] = None + text: Optional[str] = None + ingestion_job_id: Optional[str] = None + all_ingestions: bool = False + + +class ClusterDiffModel(BaseModel): + fingerprint: str + message: str + services: list[str] = Field(default_factory=list) + count_a: Optional[int] = None + count_b: Optional[int] = None + marker: str + + +class TriggerDiffModel(BaseModel): + message: str + service: Optional[str] = None + only_in: str + marker: str + + +class CompareResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: str = SCHEMA_VERSION + scope: str = "default" + window_a: TimeWindow + window_b: TimeWindow + has_changes: bool + new_clusters: list[ClusterDiffModel] = Field(default_factory=list) + disappeared_clusters: list[ClusterDiffModel] = Field(default_factory=list) + increased_clusters: list[ClusterDiffModel] = Field(default_factory=list) + decreased_clusters: list[ClusterDiffModel] = Field(default_factory=list) + new_triggers: list[TriggerDiffModel] = Field(default_factory=list) + dropped_triggers: list[TriggerDiffModel] = Field(default_factory=list) + llm: LlmProvenance + rendered_text: Optional[str] = None + text: Optional[str] = None + + +class AskResponse(BaseModel): + schema_version: str = SCHEMA_VERSION + question: str + answer: str + evidence: list[str] = Field(default_factory=list) + clusters: list[dict[str, Any]] = Field(default_factory=list) + total_matches: int = 0 + retrieval_mode: str = "keyword" + llm: LlmProvenance + rendered_text: Optional[str] = None + mode: Optional[str] = None + + +class ClustersResponse(BaseModel): + schema_version: str = SCHEMA_VERSION + scope: str = "default" + window: TimeWindow + clusters: list[dict[str, Any]] = Field(default_factory=list) + llm: LlmProvenance + ingestion_job_id: Optional[str] = None + + +def scope_from_request(http_request: Optional[Request] = None) -> str: + """Principal scope when auth attached a principal; otherwise ``default``. + + Does not filter logs (G8). Exposed so consumers can pin the response. + """ + if http_request is None: + return "default" + principal = getattr(http_request.state, "auth_principal", None) + if principal is None: + return "default" + scope = getattr(principal, "scope", None) + return str(scope) if scope else "default" + + +def build_llm_provenance(*, used: bool, fell_back: bool) -> LlmProvenance: + from src.config import get_settings + + settings = get_settings() + return LlmProvenance( + used=used, + provider=settings.llm_provider, + model=settings.llm_model, + fell_back=fell_back, + ) + + +def llm_from_mode(*, mode: str, requested: bool) -> LlmProvenance: + used = mode == "llm" + fell_back = requested and not used + return build_llm_provenance(used=used, fell_back=fell_back) + + +def llm_rules_only() -> LlmProvenance: + return build_llm_provenance(used=False, fell_back=False) + + +def llm_requested(*, no_llm: bool = False) -> bool: + from src.config import get_settings + + settings = get_settings() + return (not no_llm) and settings.llm_provider != "disabled" + + +def window_from_bounds(start: datetime, end: datetime) -> TimeWindow: + return TimeWindow(from_=start.isoformat(), to=end.isoformat()) + + +def window_from_mapping(raw: Any, fallback_start: str = "", fallback_end: str = "") -> TimeWindow: + if isinstance(raw, TimeWindow): + return raw + if not isinstance(raw, dict): + return TimeWindow(from_=fallback_start, to=fallback_end) + start = raw.get("from") or raw.get("from_") or raw.get("start") or fallback_start + end = raw.get("to") or raw.get("end") or fallback_end + return TimeWindow(from_=str(start), to=str(end)) + + +def short_summary(summary_text: str) -> str: + """First useful sentence/paragraph; full prose stays on ``rendered_text``.""" + text = (summary_text or "").strip() + if not text: + return "" + for line in text.splitlines(): + stripped = line.strip() + if stripped.lower().startswith("primary issue:"): + rest = stripped.split(":", 1)[1].strip() + if rest: + return rest + skip = {"incident summary", "evidence:"} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.lower() in skip: + continue + if stripped.lower().startswith("window:"): + continue + return stripped + return text.split("\n", 1)[0] + + +def _levels_list(raw: Any) -> list[str]: + if isinstance(raw, dict): + return [str(k) for k in raw.keys()] + if isinstance(raw, list): + return [str(x) for x in raw] + return [] + + +def _services_list(raw: Any) -> list[str]: + if isinstance(raw, dict): + return [str(k) for k in raw.keys()] + if isinstance(raw, list): + return [str(x) for x in raw] + return [] + + +def cluster_from_mapping(raw: Optional[dict[str, Any]]) -> Optional[ClusterEvidence]: + if not raw: + return None + template = raw.get("template") or raw.get("message") or raw.get("representative_message") + template_str = str(template) if template is not None else None + return ClusterEvidence( + fingerprint=raw.get("fingerprint"), + template=template_str, + message=template_str, + count=raw.get("count"), + baseline_count=raw.get("baseline_count"), + change_ratio=raw.get("change_ratio"), + services=_services_list(raw.get("services")), + levels=_levels_list(raw.get("levels")), + first_seen=raw.get("first_seen"), + last_seen=raw.get("last_seen"), + ) + + +def evidence_from_items(items: Any) -> list[EvidenceItem]: + out: list[EvidenceItem] = [] + for item in items or []: + if isinstance(item, EvidenceItem): + out.append(item) + elif isinstance(item, dict) and item.get("detail") is not None: + out.append( + EvidenceItem( + kind=str(item.get("kind") or "log"), + detail=str(item["detail"]), + ref=item.get("ref"), + source_ref=item.get("source_ref"), + ) + ) + else: + out.append(EvidenceItem(kind="log", detail=str(item))) + return out + + +def _parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def trigger_from_candidates( + candidates: list[Any], + primary: Optional[ClusterEvidence] = None, +) -> TriggerInfo: + if not candidates: + return TriggerInfo(detected=False, type=None, service=None, at=None, correlation=None) + first = candidates[0] + if isinstance(first, dict): + message = str(first.get("message") or "") + service = first.get("service") + at = first.get("timestamp") or first.get("at") + else: + message = str(getattr(first, "message", "") or "") + service = getattr(first, "service", None) + ts = getattr(first, "timestamp", None) + at = ts.isoformat() if hasattr(ts, "isoformat") else ts + at_str = str(at) if at else None + first_seen = primary.first_seen if primary is not None else None + correlation: Optional[str] = None + trigger_dt = _parse_iso(at_str) + seen_dt = _parse_iso(first_seen) + if trigger_dt is not None and seen_dt is not None and trigger_dt <= seen_dt: + correlation = "precedes_primary_spike" + return TriggerInfo( + detected=True, + type=infer_trigger_type(message), + service=str(service) if service else None, + at=at_str, + correlation=correlation, + ) + + +def trigger_from_mapping(raw: Any, candidates: list[Any], primary: Optional[ClusterEvidence]) -> TriggerInfo: + if isinstance(raw, TriggerInfo): + return raw + if isinstance(raw, dict) and "detected" in raw: + return TriggerInfo.model_validate(raw) + return trigger_from_candidates(candidates, primary) + + +def confidence_from_value(raw: Any) -> Confidence: + if isinstance(raw, Confidence): + return raw + if isinstance(raw, dict): + label = str(raw.get("label") or "low") + score = raw.get("score") + if score is None: + score = score_from_label(label) + return Confidence(label=label, score=float(score)) + label = str(raw or "low") + return Confidence(label=label, score=score_from_label(label)) + + +def explain_from_result( + result: ExplainResult, + *, + no_llm: bool, + cached: bool, + scope: str = "default", +) -> ExplainResponse: + primary = cluster_from_mapping(result.primary_cluster) + secondary = [ + c for c in (cluster_from_mapping(item) for item in result.secondary_clusters) if c is not None + ] + prose = result.summary_text or "" + return ExplainResponse( + schema_version=SCHEMA_VERSION, + scope=scope, + window=window_from_bounds(result.window_start, result.window_end), + confidence=confidence_from_value(result.confidence), + summary=short_summary(prose), + trigger=trigger_from_candidates(result.trigger_candidates, primary), + primary_cluster=primary, + secondary_clusters=secondary, + evidence=evidence_from_items(result.evidence_items), + llm=llm_from_mode(mode=result.mode, requested=llm_requested(no_llm=no_llm)), + rendered_text=prose, + cached=cached, + total_logs=result.total_logs, + mode=result.mode, + services_affected=list(result.services_affected or []), + ) + + +def explain_from_cached( + payload: dict[str, Any], + *, + window_start: datetime, + window_end: datetime, + no_llm: bool, + scope: str = "default", +) -> ExplainResponse: + """Upgrade a cached dict (old or v1) so the response always has v1 fields.""" + primary = cluster_from_mapping(payload.get("primary_cluster")) + secondary_raw = payload.get("secondary_clusters") or [] + secondary = [c for c in (cluster_from_mapping(item) for item in secondary_raw) if c is not None] + candidates = payload.get("trigger_candidates") or [] + prose = str(payload.get("rendered_text") or payload.get("summary") or "") + already_v1 = isinstance(payload.get("confidence"), dict) and bool(payload.get("schema_version")) + if already_v1: + summary_text = str(payload.get("summary") or short_summary(prose)) + else: + summary_text = short_summary(prose) + mode = str(payload.get("mode") or "rules") + return ExplainResponse( + schema_version=SCHEMA_VERSION, + scope=str(payload.get("scope") or scope), + window=window_from_mapping( + payload.get("window"), + fallback_start=window_start.isoformat(), + fallback_end=window_end.isoformat(), + ), + confidence=confidence_from_value(payload.get("confidence")), + summary=summary_text or short_summary(prose), + trigger=trigger_from_mapping(payload.get("trigger"), candidates, primary), + primary_cluster=primary, + secondary_clusters=secondary, + evidence=evidence_from_items(payload.get("evidence")), + llm=llm_from_mode(mode=mode, requested=llm_requested(no_llm=no_llm)), + rendered_text=prose, + cached=True, + total_logs=int(payload.get("total_logs") or 0), + mode=mode, + services_affected=list(payload.get("services_affected") or []), + ) + + +def cluster_diff_model(diff: Any, kind: str) -> ClusterDiffModel: + return ClusterDiffModel( + fingerprint=diff.fingerprint, + message=diff.message, + services=list(diff.services or []), + count_a=diff.count_a, + count_b=diff.count_b, + marker=COMPARE_MARKERS[kind], + ) + + +def trigger_diff_model(diff: Any) -> TriggerDiffModel: + only_in = getattr(diff, "only_in", "a") + return TriggerDiffModel( + message=diff.message, + service=diff.service or None, + only_in=only_in, + marker=TRIGGER_MARKERS.get(only_in, "+⚡"), + ) diff --git a/src/api/static/js/app.js b/src/api/static/js/app.js index ddf7d3d..afbdba5 100644 --- a/src/api/static/js/app.js +++ b/src/api/static/js/app.js @@ -18,8 +18,9 @@ function fmtTime(iso) { } function confidenceBadge(confidence) { - const cls = confidence === "high" ? "badge-high" : confidence === "medium" ? "badge-medium" : "badge-low"; - return `${escapeHtml(confidence)}`; + const label = confidence && typeof confidence === "object" ? confidence.label : confidence; + const cls = label === "high" ? "badge-high" : label === "medium" || label === "medium-high" ? "badge-medium" : "badge-low"; + return `${escapeHtml(label || "")}`; } // FastAPI error bodies are either {"detail": "message"} (HTTPException) or @@ -169,16 +170,24 @@ function initForms() { function renderClusterCard(cluster) { const services = (cluster.services || []).map(escapeHtml).join(", "); + const message = cluster.template || cluster.message || ""; return `
-
${escapeHtml(cluster.message)}
+
${escapeHtml(message)}
${escapeHtml(cluster.count)} events${services ? " · " + services : ""}
`; } +function evidenceDetail(item) { + if (item && typeof item === "object") { + return item.detail || ""; + } + return item; +} + function renderExplain(el, data) { const evidence = (data.evidence || []) - .map((item) => `
  • ${escapeHtml(item)}
  • `) + .map((item) => `
  • ${escapeHtml(evidenceDetail(item))}
  • `) .join(""); let html = ` diff --git a/src/clients/v1.py b/src/clients/v1.py index bd6cdaf..fbff3c9 100644 --- a/src/clients/v1.py +++ b/src/clients/v1.py @@ -1,7 +1,7 @@ """Thin typed httpx client targeting the canonical ``/v1`` HTTP API. -JSON response shapes are unchanged from the unversioned aliases; this client -only pins the URL prefix. Generate a fuller client with ``make client-go`` / +JSON response shapes are versioned (`schema_version` 1.0 on `/v1/query/*`). +This client pins the URL prefix. Generate a fuller client with ``make client-go`` / ``make client-python`` from ``clients/openapi.json``. """ diff --git a/src/core/explain/confidence.py b/src/core/explain/confidence.py index fee484a..9520091 100644 --- a/src/core/explain/confidence.py +++ b/src/core/explain/confidence.py @@ -1,53 +1,69 @@ -from typing import Optional - from src.core.explain.evidence import EvidencePacket +# Design §5.7 example maps medium-high → 0.72. Labels are produced by the +# same integer scoring as compute_confidence; this table turns them into a +# stable 0–1 score for the versioned JSON schema without changing the CLI. +CONFIDENCE_LABEL_SCORES: dict[str, float] = { + "low": 0.25, + "medium": 0.50, + "medium-high": 0.72, + "high": 0.90, +} -def compute_confidence(packet: EvidencePacket) -> str: - """ - Compute a confidence level based on the evidence quality. - Returns: 'low', 'medium', 'medium-high', or 'high' - Scoring rationale: - - 'high' requires a trigger candidate AND strong cluster signal - - 'medium-high' is the ceiling when no trigger is identified - - baseline_count == 0 is not scored as a signal when job-scoped - (it's always 0 in that mode, so it carries no information) - """ - score = 0 +def score_from_label(label: str) -> float: + """Map a confidence label to a 0–1 float for the v1 JSON schema.""" + return CONFIDENCE_LABEL_SCORES.get(label, 0.0) + +def compute_confidence_points(packet: EvidencePacket) -> int: + """Integer evidence score used by ``compute_confidence``. Max is 8.""" if packet.primary_cluster is None: - return "low" + return 0 pc = packet.primary_cluster - has_trigger = bool(packet.trigger_candidates) + points = 0 - # Primary cluster size if pc.count >= 50: - score += 2 + points += 2 elif pc.count >= 10: - score += 1 + points += 1 - # Baseline change ratio — only meaningful when baseline is non-empty if pc.baseline_count > 0: if pc.change_ratio > 10: - score += 2 + points += 2 elif pc.change_ratio > 3: - score += 1 + points += 1 - # Trigger candidate is the strongest corroboration signal - if has_trigger: - score += 2 + if packet.trigger_candidates: + points += 2 - # Secondary clusters corroborate the primary if packet.secondary_clusters: - score += 1 + points += 1 - # Multiple services affected → broader blast radius → more confident it's real if len(packet.services_affected) > 1: - score += 1 + points += 1 + + return points + + +def compute_confidence(packet: EvidencePacket) -> str: + """ + Compute a confidence level based on the evidence quality. + Returns: 'low', 'medium', 'medium-high', or 'high' + + Scoring rationale: + - 'high' requires a trigger candidate AND strong cluster signal + - 'medium-high' is the ceiling when no trigger is identified + - baseline_count == 0 is not scored as a signal when job-scoped + (it's always 0 in that mode, so it carries no information) + """ + if packet.primary_cluster is None: + return "low" + + score = compute_confidence_points(packet) + has_trigger = bool(packet.trigger_candidates) - # 'high' requires trigger correlation — without it, cap at medium-high if score >= 5 and has_trigger: return "high" elif score >= 4: @@ -56,3 +72,10 @@ def compute_confidence(packet: EvidencePacket) -> str: return "medium" else: return "low" + + +def compute_confidence_score(packet: EvidencePacket) -> float: + """0–1 score from the same signals as ``compute_confidence``.""" + if packet.primary_cluster is None: + return 0.0 + return score_from_label(compute_confidence(packet)) diff --git a/src/core/explain/summarizer.py b/src/core/explain/summarizer.py index a3ae295..1686049 100644 --- a/src/core/explain/summarizer.py +++ b/src/core/explain/summarizer.py @@ -131,6 +131,7 @@ def explain_window( "message": pc.representative_message, "count": pc.count, "services": list(pc.services.keys()), + "levels": list(pc.levels.keys()), "fingerprint": pc.fingerprint, "importance_score": round(pc.importance_score, 2), "first_seen": pc.first_seen.isoformat() if pc.first_seen else None, @@ -143,8 +144,13 @@ def explain_window( "message": c.representative_message, "count": c.count, "services": list(c.services.keys()), + "levels": list(c.levels.keys()), "fingerprint": c.fingerprint, "importance_score": round(c.importance_score, 2), + "first_seen": c.first_seen.isoformat() if c.first_seen else None, + "last_seen": c.last_seen.isoformat() if c.last_seen else None, + "baseline_count": c.baseline_count, + "change_ratio": round(c.change_ratio, 2), } for c in packet.secondary_clusters ], diff --git a/src/core/normalization/patterns.py b/src/core/normalization/patterns.py index 0857705..3f7ec09 100644 --- a/src/core/normalization/patterns.py +++ b/src/core/normalization/patterns.py @@ -59,3 +59,28 @@ def is_trigger_message(message: str) -> bool: if pattern.search(message): return True return False + + +# Coarse type labels for the v1 JSON trigger object (G7). Order matters: +# more specific families first so "pod restarted" is pod, not restart. +_TRIGGER_TYPE_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"deploy(?:ment)?|rollout|release\s+\S+\s+(?:deployed|started|live)", re.IGNORECASE), "deploy"), + (re.compile(r"pod\s+(?:restart|restarted|terminated|evicted)", re.IGNORECASE), "pod"), + (re.compile(r"config(?:uration)?\s+(?:reloaded|changed|updated)", re.IGNORECASE), "config"), + (re.compile(r"migration\s+(?:started|completed|running)", re.IGNORECASE), "migration"), + (re.compile(r"queue\s+(?:full|saturated|overflow)", re.IGNORECASE), "queue"), + (re.compile(r"circuit[\s_]?breaker\s+(?:open|tripped|activated)", re.IGNORECASE), "circuit_breaker"), + (re.compile(r"webhook\s+(?:secret|key|config)\s+(?:changed|invalid|expired)", re.IGNORECASE), "webhook"), + (re.compile(r"token\s+(?:expired|expiration|invalid)", re.IGNORECASE), "token"), + (re.compile(r"(?:application|service)\s+(?:started|restarted|restart)|restart", re.IGNORECASE), "restart"), +] + + +def infer_trigger_type(message: str) -> str | None: + """Best-effort trigger family from a candidate message, or None.""" + if not message: + return None + for pattern, trigger_type in _TRIGGER_TYPE_PATTERNS: + if pattern.search(message): + return trigger_type + return None diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 6b6f64a..3d32ecd 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -673,10 +673,19 @@ def test_returns_200_with_summary(self): assert resp.status_code == 200 data = resp.json() + assert data["schema_version"] == "1.0" assert "summary" in data - assert data["confidence"] == "high" + assert data["confidence"]["label"] == "high" + assert data["confidence"]["score"] == 0.9 + assert data["trigger"]["detected"] is False + assert data["llm"]["used"] is False + assert data["llm"]["fell_back"] is False + assert set(data["llm"]) >= {"used", "provider", "model", "fell_back"} assert data["cached"] is False assert "markdown" not in data + assert "from" in data["window"] + assert "to" in data["window"] + assert data["rendered_text"] def test_format_markdown_includes_rendered_field(self): mock_db = _ctx_db() @@ -716,6 +725,9 @@ def test_cache_hit_returns_cached_true(self): assert resp.status_code == 200 data = resp.json() assert data["cached"] is True + assert data["schema_version"] == "1.0" + assert data["confidence"]["label"] == "high" + assert data["llm"]["used"] is False assert "markdown" not in data def test_cache_hit_markdown_is_derived_not_stored(self): @@ -783,6 +795,69 @@ def test_missing_time_params_returns_400(self): resp = client.post("/query/explain", json={}) assert resp.status_code == 400 + def test_no_llm_sets_used_and_fell_back_false(self): + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=self._mock_result()), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post("/v1/query/explain", json={"since": "1h", "no_llm": True}) + + assert resp.status_code == 200 + llm = resp.json()["llm"] + assert llm["used"] is False + assert llm["fell_back"] is False + + def test_llm_fell_back_when_provider_configured_but_rules_used(self): + from src.config.settings import Settings + + settings = Settings(_env_file=None, llm_provider="openai", openai_api_key="sk-test") + mock_db = _ctx_db() + with patch("src.config.get_settings", return_value=settings), \ + patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=self._mock_result()), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post("/v1/query/explain", json={"since": "1h"}) + + assert resp.status_code == 200 + llm = resp.json()["llm"] + assert llm["used"] is False + assert llm["fell_back"] is True + assert llm["provider"] == "openai" + + def test_trigger_detected_from_candidate(self): + result = self._mock_result() + result.trigger_candidates = [ + { + "message": "Deploy completed for billing-worker version v2.4.1", + "timestamp": "2026-03-12T13:05:00+00:00", + "service": "deployment-controller", + } + ] + result.primary_cluster = { + "message": "connection refused", + "fingerprint": "abc", + "count": 10, + "services": ["api"], + "first_seen": "2026-03-12T13:10:00+00:00", + "last_seen": "2026-03-12T13:50:00+00:00", + } + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=result), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post("/v1/query/explain", json={"since": "1h", "no_llm": True}) + + assert resp.status_code == 200 + trigger = resp.json()["trigger"] + assert trigger["detected"] is True + assert trigger["type"] == "deploy" + assert trigger["service"] == "deployment-controller" + assert trigger["correlation"] == "precedes_primary_spike" + assert resp.json()["primary_cluster"]["template"] == "connection refused" + # ── POST /query/ask ──────────────────────────────────────────────────────────── @@ -809,6 +884,10 @@ def test_returns_200_with_answer(self): assert data["answer"] == "Stripe signature verification failed for /webhooks/stripe." assert data["total_matches"] == 184 assert data["retrieval_mode"] == "keyword" + assert data["schema_version"] == "1.0" + assert set(data["llm"]) >= {"used", "provider", "model", "fell_back"} + assert data["llm"]["used"] is False + assert data["llm"]["fell_back"] is False def test_ingestion_job_id_passed_through(self): """ @@ -856,6 +935,8 @@ def test_returns_clusters_list(self): assert resp.status_code == 200 assert "clusters" in resp.json() + assert resp.json()["schema_version"] == "1.0" + assert set(resp.json()["llm"]) >= {"used", "provider", "model", "fell_back"} def test_invalid_ingestion_job_id_returns_400(self): resp = client.post("/query/clusters", json={ @@ -895,6 +976,11 @@ def test_returns_events_list(self): assert len(data["events"]) == 1 assert data["events"][0]["category"] == "deploy" assert data["events"][0]["label"] == "deploy" + assert data["schema_version"] == "1.0" + assert data["llm"]["used"] is False + assert data["llm"]["fell_back"] is False + assert set(data["llm"]) >= {"used", "provider", "model", "fell_back"} + assert "from" in data["window"] def test_format_text_includes_rendered_field(self): from src.core.timeline.builder import TimelineEvent @@ -919,6 +1005,7 @@ def test_format_text_includes_rendered_field(self): assert resp.status_code == 200 assert "text" in resp.json() + assert resp.json()["rendered_text"] == resp.json()["text"] assert "Incident timeline" in resp.json()["text"] def test_invalid_ingestion_job_id_returns_400(self): @@ -967,11 +1054,17 @@ def test_since_baseline_returns_json(self): assert resp.status_code == 200 data = resp.json() + assert data["schema_version"] == "1.0" assert data["has_changes"] is True assert len(data["new_clusters"]) == 1 assert data["new_clusters"][0]["message"] == "New error" + assert data["new_clusters"][0]["marker"] == "+" assert data["new_triggers"][0]["service"] == "deploy" assert "only_in" in data["new_triggers"][0] + assert data["new_triggers"][0]["marker"] == "+⚡" + assert data["llm"]["used"] is False + assert data["llm"]["fell_back"] is False + assert set(data["llm"]) >= {"used", "provider", "model", "fell_back"} def test_missing_window_params_returns_400(self): resp = client.post("/query/compare", json={}) @@ -990,6 +1083,7 @@ def test_format_text_includes_rendered_field(self): assert resp.status_code == 200 assert "text" in resp.json() + assert resp.json()["rendered_text"] == resp.json()["text"] assert "Incident comparison" in resp.json()["text"] def test_invalid_ingestion_job_id_returns_400(self): diff --git a/tests/unit/test_confidence.py b/tests/unit/test_confidence.py index 2712d02..13eb4e7 100644 --- a/tests/unit/test_confidence.py +++ b/tests/unit/test_confidence.py @@ -10,7 +10,11 @@ from datetime import datetime, timezone, timedelta from src.core.clustering.clusterer import ClusterData -from src.core.explain.confidence import compute_confidence +from src.core.explain.confidence import ( + compute_confidence, + compute_confidence_score, + score_from_label, +) from src.core.explain.evidence import EvidencePacket, TriggerCandidate @@ -198,3 +202,21 @@ def test_all_valid_values(self): ] for p in cases: assert compute_confidence(p) in self.VALID + + +class TestScoreFromLabel: + def test_design_example_medium_high(self): + assert score_from_label("medium-high") == 0.72 + + def test_unknown_label_is_zero(self): + assert score_from_label("mystery") == 0.0 + + def test_no_primary_score_is_zero(self): + assert compute_confidence_score(_packet(primary=None)) == 0.0 + + def test_score_matches_label_table(self): + p = _packet(primary=_cluster(count=100), secondary=[_cluster()], + services=["api", "worker"], triggers=[_trigger()]) + assert compute_confidence(p) == "high" + assert compute_confidence_score(p) == 0.90 + diff --git a/tests/unit/test_openapi_contract.py b/tests/unit/test_openapi_contract.py index d17e96f..29cdbcd 100644 --- a/tests/unit/test_openapi_contract.py +++ b/tests/unit/test_openapi_contract.py @@ -1,7 +1,7 @@ """OpenAPI contract tests — fail CI if a canonical /v1 path is removed. -Loads the schema from ``app.openapi()`` (no live server). JSON response -shapes are not asserted here; that is G7. +Loads the schema from ``app.openapi()`` (no live server). JSON response shapes are asserted in ``tests/unit/test_query_schema.py`` against +``schema_version`` 1.0. """ from __future__ import annotations diff --git a/tests/unit/test_query_schema.py b/tests/unit/test_query_schema.py new file mode 100644 index 0000000..4322020 --- /dev/null +++ b/tests/unit/test_query_schema.py @@ -0,0 +1,150 @@ +"""Contract tests for the versioned /v1/query JSON evidence schema (G7).""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from src.api.app import app +from src.api.schemas.v1 import ( + SCHEMA_VERSION, + AskResponse, + ClustersResponse, + CompareResponse, + ExplainResponse, + TimelineResponse, + explain_from_cached, + short_summary, +) +from src.core.normalization.patterns import infer_trigger_type + +client = TestClient(app, raise_server_exceptions=False) +SCHEMA_DIR = Path(__file__).resolve().parents[2] / "clients" / "jsonschema" +WINDOW_START = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) +WINDOW_END = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) + + +def _ctx_db() -> MagicMock: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.execute.return_value.scalar_one.return_value = 0 + return mock_db + + +def _explain_result(**overrides: object) -> MagicMock: + result = MagicMock() + result.window_start = WINDOW_START + result.window_end = WINDOW_END + result.summary_text = ( + "Incident summary\n\n" + "Window: 2026-03-12 13:00 UTC → 2026-03-12 14:00 UTC\n" + "Primary issue: Stripe signature verification failed\n" + "Confidence: high" + ) + result.confidence = "high" + result.mode = "rules" + result.total_logs = 404 + result.services_affected = ["api"] + result.primary_cluster = None + result.secondary_clusters = [] + result.trigger_candidates = [] + result.evidence_items = ["184 similar failures in billing-worker"] + for key, value in overrides.items(): + setattr(result, key, value) + return result + + +def test_published_jsonschema_matches_pydantic() -> None: + mapping = { + "explain.v1.json": ExplainResponse, + "timeline.v1.json": TimelineResponse, + "compare.v1.json": CompareResponse, + "ask.v1.json": AskResponse, + "clusters.v1.json": ClustersResponse, + } + for filename, model in mapping.items(): + path = SCHEMA_DIR / filename + assert path.is_file(), f"missing {path}" + published = json.loads(path.read_text(encoding="utf-8")) + assert published == model.model_json_schema() + + +def test_short_summary_extracts_primary_issue() -> None: + prose = "Incident summary\n\nWindow: 1h\nPrimary issue: payment timeouts\n" + assert short_summary(prose) == "payment timeouts" + + +def test_infer_trigger_type() -> None: + assert infer_trigger_type("Deploy completed for api") == "deploy" + assert infer_trigger_type("circuit breaker tripped") == "circuit_breaker" + assert infer_trigger_type("hello world") is None + + +def test_cached_old_payload_upgrades_to_v1() -> None: + old = { + "window": {"start": WINDOW_START.isoformat(), "end": WINDOW_END.isoformat()}, + "summary": "Incident summary\n\nPrimary issue: old cache hit", + "confidence": "medium-high", + "mode": "rules", + "total_logs": 10, + "services_affected": ["api"], + "primary_cluster": None, + "secondary_clusters": [], + "trigger_candidates": [], + "evidence": ["a log line"], + } + body = explain_from_cached( + old, + window_start=WINDOW_START, + window_end=WINDOW_END, + no_llm=False, + scope="default", + ) + data = body.model_dump(by_alias=True) + ExplainResponse.model_validate(data) + assert data["schema_version"] == SCHEMA_VERSION + assert data["window"]["from"] == WINDOW_START.isoformat() + assert data["confidence"]["label"] == "medium-high" + assert data["confidence"]["score"] == 0.72 + assert data["trigger"]["detected"] is False + assert data["evidence"][0]["kind"] == "log" + assert data["evidence"][0]["detail"] == "a log line" + assert data["llm"]["used"] is False + assert "provider" in data["llm"] + assert "model" in data["llm"] + assert "fell_back" in data["llm"] + assert data["cached"] is True + + +def test_explain_openapi_documents_response_model() -> None: + schema = app.openapi() + explain = schema["paths"]["/v1/query/explain"]["post"] + ref = explain["responses"]["200"]["content"]["application/json"]["schema"] + assert "$ref" in ref + assert "ExplainResponse" in ref["$ref"] + components = schema["components"]["schemas"] + assert "ExplainResponse" in components + assert "LlmProvenance" in components + llm = components["LlmProvenance"]["properties"] + for key in ("used", "provider", "model", "fell_back"): + assert key in llm + + +def test_explain_and_unversioned_share_v1_body() -> None: + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=_explain_result()), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + unversioned = client.post("/query/explain", json={"since": "1h", "no_llm": True}) + versioned = client.post("/v1/query/explain", json={"since": "1h", "no_llm": True}) + + assert unversioned.status_code == 200 + assert versioned.status_code == 200 + assert unversioned.json() == versioned.json() + ExplainResponse.model_validate(versioned.json()) + assert versioned.json()["schema_version"] == "1.0" diff --git a/tests/unit/test_v1_client.py b/tests/unit/test_v1_client.py index f464149..bacde69 100644 --- a/tests/unit/test_v1_client.py +++ b/tests/unit/test_v1_client.py @@ -42,7 +42,9 @@ def test_explain_targets_v1_and_returns_body() -> None: payload = client.explain(since="1h", no_llm=True) assert payload["summary"] == "ok" - assert payload["confidence"] == "low" + assert payload["confidence"]["label"] == "low" + assert payload["schema_version"] == "1.0" + assert payload["llm"]["used"] is False def test_health_uses_unversioned_path() -> None: