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 `