Rifqi Hafizuddin commited on
Commit ·
23cc207
1
Parent(s): 1367f41
[KM-691] Develop traceability
Browse files- API_CONTRACT_BE_PYTHON.md +61 -46
- DEV_PLAN.md +7 -8
- REPO_STATUS.md +5 -5
- main.py +2 -0
- src/agents/chat_handler.py +83 -4
- src/api/v1/help.py +1 -0
- src/api/v1/traceability.py +43 -0
- src/api/v2/chat.py +24 -1
- src/db/postgres/models.py +28 -0
- src/query/executor/base.py +3 -0
- src/query/executor/db.py +1 -0
- src/query/executor/tabular.py +3 -1
- src/tools/data_access.py +2 -0
- src/traceability/__init__.py +32 -0
- src/traceability/schemas.py +76 -0
- src/traceability/scratchpad.py +213 -0
- src/traceability/store.py +110 -0
API_CONTRACT_BE_PYTHON.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Backend Agentic Service API Contract
|
| 2 |
|
| 3 |
-
This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and
|
| 4 |
|
| 5 |
Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
|
| 6 |
|
|
@@ -11,14 +11,14 @@ The Python backend owns the generative AI interaction surface:
|
|
| 11 |
1. Stream chat answers from the AI agent.
|
| 12 |
2. Execute tool-style actions for help and report generation.
|
| 13 |
3. Return report versions and report details.
|
| 14 |
-
4. Return
|
| 15 |
|
| 16 |
The frontend uses this service during the analysis conversation flow:
|
| 17 |
|
| 18 |
1. User sends a chat message.
|
| 19 |
2. Frontend calls `POST /api/v2/chat/stream` and renders the streamed answer.
|
| 20 |
3. When the stream emits `done`, frontend stores or reads the returned `message_id`.
|
| 21 |
-
4. Frontend calls `GET /api/v1/
|
| 22 |
5. Frontend calls `/api/v1/tools/help` for guided help and `/api/v1/tools/report` for report generation.
|
| 23 |
|
| 24 |
## Endpoint Summary
|
|
@@ -31,7 +31,7 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 31 |
| `POST` | `/api/v1/tools/report` | Generate and persist a new report version. |
|
| 32 |
| `GET` | `/api/v1/tools/report/{analysis_id}` | List report versions for an analysis. |
|
| 33 |
| `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
|
| 34 |
-
| `GET` | `/api/v1/
|
| 35 |
|
| 36 |
## Common Concepts
|
| 37 |
|
|
@@ -39,7 +39,7 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 39 |
|
| 40 |
- `user_id`: user identifier passed by the frontend.
|
| 41 |
- `analysis_id`: analysis conversation identifier.
|
| 42 |
-
- `message_id`: assistant answer identifier used to correlate chat streaming with
|
| 43 |
|
| 44 |
### Server-Sent Events
|
| 45 |
|
|
@@ -57,7 +57,7 @@ Common event types:
|
|
| 57 |
| `done` | JSON object | Terminal success event. Includes `message_id`. |
|
| 58 |
| `error` | text | Terminal error event. Stream stops after this. |
|
| 59 |
|
| 60 |
-
The stream carries answer text only. Planning, tool call details, and full provenance are fetched from `GET /api/v1/
|
| 61 |
|
| 62 |
## Chat
|
| 63 |
|
|
@@ -82,7 +82,7 @@ Fields:
|
|
| 82 |
| --- | --- | --- |
|
| 83 |
| `user_id` | Yes | User identifier. |
|
| 84 |
| `analysis_id` | Yes | Analysis conversation identifier. |
|
| 85 |
-
| `message_id` | No | Assistant answer id for
|
| 86 |
| `message` | Yes | User message text. |
|
| 87 |
|
| 88 |
Response: `text/event-stream`.
|
|
@@ -357,13 +357,15 @@ Response `404`:
|
|
| 357 |
}
|
| 358 |
```
|
| 359 |
|
| 360 |
-
##
|
| 361 |
|
| 362 |
-
|
| 363 |
|
| 364 |
-
|
| 365 |
|
| 366 |
-
|
|
|
|
|
|
|
| 367 |
|
| 368 |
Query params:
|
| 369 |
|
|
@@ -375,15 +377,19 @@ Query params:
|
|
| 375 |
Example:
|
| 376 |
|
| 377 |
```text
|
| 378 |
-
GET /api/v1/
|
| 379 |
```
|
| 380 |
|
|
|
|
|
|
|
| 381 |
Field rules:
|
| 382 |
|
| 383 |
-
- `planning`: present only when the planner ran; otherwise `null`.
|
| 384 |
-
- `thinking`:
|
| 385 |
-
- `tool_calls`: every invoked tool with input, output, and
|
| 386 |
-
- `sources`: required for retrieval flows; empty for chat/help
|
|
|
|
|
|
|
| 387 |
|
| 388 |
Response `200` for `structured_flow`:
|
| 389 |
|
|
@@ -391,49 +397,58 @@ Response `200` for `structured_flow`:
|
|
| 391 |
{
|
| 392 |
"analysis_id": "an_42",
|
| 393 |
"message_id": "msg_88f1",
|
|
|
|
| 394 |
"intent": "structured_flow",
|
| 395 |
-
"generated_at": "2026-
|
| 396 |
"planning": {
|
| 397 |
"goal_restated": "Find which regions drive revenue and why Q1 dipped.",
|
| 398 |
-
"assumptions": [
|
| 399 |
"steps": [
|
| 400 |
{
|
| 401 |
"step": 1,
|
| 402 |
"stage": "data_understanding",
|
| 403 |
-
"objective": "Inventory the sales source"
|
|
|
|
|
|
|
| 404 |
},
|
| 405 |
{
|
| 406 |
"step": 2,
|
| 407 |
"stage": "modeling",
|
| 408 |
-
"objective": "Aggregate revenue by region"
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
]
|
| 411 |
},
|
| 412 |
-
"thinking":
|
| 413 |
"tool_calls": [
|
| 414 |
{
|
| 415 |
"order": 1,
|
|
|
|
| 416 |
"name": "check_data",
|
| 417 |
"input": { "source_hint": "structured" },
|
| 418 |
-
"output": {
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
},
|
| 421 |
{
|
| 422 |
"order": 2,
|
|
|
|
| 423 |
"name": "retrieve_data",
|
| 424 |
-
"input": {
|
| 425 |
-
"source_id": "src_sales_db",
|
| 426 |
-
"table_id": "orders",
|
| 427 |
-
"select": ["region", "amount"],
|
| 428 |
-
"group_by": ["region"]
|
| 429 |
-
},
|
| 430 |
"output": {
|
| 431 |
"kind": "table",
|
| 432 |
"columns": ["region", "total"],
|
| 433 |
"row_count": 4,
|
| 434 |
"preview": [["Central", 1210000], ["East", 740000]]
|
| 435 |
},
|
| 436 |
-
"status": "success"
|
|
|
|
| 437 |
}
|
| 438 |
],
|
| 439 |
"sources": [
|
|
@@ -443,37 +458,36 @@ Response `200` for `structured_flow`:
|
|
| 443 |
"name": "orders",
|
| 444 |
"query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
|
| 445 |
"detail": {
|
| 446 |
-
"
|
| 447 |
-
"row_count":
|
| 448 |
}
|
| 449 |
}
|
| 450 |
]
|
| 451 |
}
|
| 452 |
```
|
| 453 |
|
|
|
|
|
|
|
| 454 |
Response `200` for `unstructured_flow`:
|
| 455 |
|
| 456 |
```json
|
| 457 |
{
|
| 458 |
"analysis_id": "an_42",
|
| 459 |
"message_id": "msg_55",
|
|
|
|
| 460 |
"intent": "unstructured_flow",
|
| 461 |
-
"generated_at": "2026-
|
| 462 |
"planning": null,
|
| 463 |
"thinking": null,
|
| 464 |
"tool_calls": [
|
| 465 |
{
|
| 466 |
"order": 1,
|
|
|
|
| 467 |
"name": "retrieve_knowledge",
|
| 468 |
-
"input": {
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
"output": {
|
| 473 |
-
"kind": "documents",
|
| 474 |
-
"row_count": 4
|
| 475 |
-
},
|
| 476 |
-
"status": "success"
|
| 477 |
}
|
| 478 |
],
|
| 479 |
"sources": [
|
|
@@ -490,14 +504,15 @@ Response `200` for `unstructured_flow`:
|
|
| 490 |
}
|
| 491 |
```
|
| 492 |
|
| 493 |
-
Response `200` for
|
| 494 |
|
| 495 |
```json
|
| 496 |
{
|
| 497 |
"analysis_id": "an_42",
|
| 498 |
"message_id": "msg_12",
|
|
|
|
| 499 |
"intent": "chat",
|
| 500 |
-
"generated_at": "2026-
|
| 501 |
"planning": null,
|
| 502 |
"thinking": null,
|
| 503 |
"tool_calls": [],
|
|
@@ -509,13 +524,13 @@ Response `404`:
|
|
| 509 |
|
| 510 |
```json
|
| 511 |
{
|
| 512 |
-
"detail": "No
|
| 513 |
}
|
| 514 |
```
|
| 515 |
|
| 516 |
Frontend rendering guidance:
|
| 517 |
|
| 518 |
-
- Render
|
| 519 |
- Default state can be collapsed.
|
| 520 |
- Show planning, tool calls, and sources as separate sections.
|
| 521 |
- Treat `planning: null`, `tool_calls: []`, and `sources: []` as valid states.
|
|
|
|
| 1 |
# Backend Agentic Service API Contract
|
| 2 |
|
| 3 |
+
This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and traceability data shown alongside chat answers.
|
| 4 |
|
| 5 |
Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
|
| 6 |
|
|
|
|
| 11 |
1. Stream chat answers from the AI agent.
|
| 12 |
2. Execute tool-style actions for help and report generation.
|
| 13 |
3. Return report versions and report details.
|
| 14 |
+
4. Return traceability/provenance for a completed assistant answer.
|
| 15 |
|
| 16 |
The frontend uses this service during the analysis conversation flow:
|
| 17 |
|
| 18 |
1. User sends a chat message.
|
| 19 |
2. Frontend calls `POST /api/v2/chat/stream` and renders the streamed answer.
|
| 20 |
3. When the stream emits `done`, frontend stores or reads the returned `message_id`.
|
| 21 |
+
4. Frontend calls `GET /api/v1/traceability` for planning, tool calls, and source provenance.
|
| 22 |
5. Frontend calls `/api/v1/tools/help` for guided help and `/api/v1/tools/report` for report generation.
|
| 23 |
|
| 24 |
## Endpoint Summary
|
|
|
|
| 31 |
| `POST` | `/api/v1/tools/report` | Generate and persist a new report version. |
|
| 32 |
| `GET` | `/api/v1/tools/report/{analysis_id}` | List report versions for an analysis. |
|
| 33 |
| `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
|
| 34 |
+
| `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
|
| 35 |
|
| 36 |
## Common Concepts
|
| 37 |
|
|
|
|
| 39 |
|
| 40 |
- `user_id`: user identifier passed by the frontend.
|
| 41 |
- `analysis_id`: analysis conversation identifier.
|
| 42 |
+
- `message_id`: assistant answer identifier used to correlate chat streaming with traceability.
|
| 43 |
|
| 44 |
### Server-Sent Events
|
| 45 |
|
|
|
|
| 57 |
| `done` | JSON object | Terminal success event. Includes `message_id`. |
|
| 58 |
| `error` | text | Terminal error event. Stream stops after this. |
|
| 59 |
|
| 60 |
+
The stream carries answer text only. Planning, tool call details, and full provenance are fetched from `GET /api/v1/traceability` after the stream is done.
|
| 61 |
|
| 62 |
## Chat
|
| 63 |
|
|
|
|
| 82 |
| --- | --- | --- |
|
| 83 |
| `user_id` | Yes | User identifier. |
|
| 84 |
| `analysis_id` | Yes | Analysis conversation identifier. |
|
| 85 |
+
| `message_id` | No | Assistant answer id for traceability correlation. If omitted, Python returns one in `done`. |
|
| 86 |
| `message` | Yes | User message text. |
|
| 87 |
|
| 88 |
Response: `text/event-stream`.
|
|
|
|
| 357 |
}
|
| 358 |
```
|
| 359 |
|
| 360 |
+
## Traceability
|
| 361 |
|
| 362 |
+
> Renamed from `observability` (team decision 2026-07-06) so it is never confused with the internal Langfuse *observability* stack (engineering telemetry, PII-masked). Traceability is **user-facing** provenance — real tool args, output previews, and the executed query — shown alongside the answer.
|
| 363 |
|
| 364 |
+
### `GET /api/v1/traceability`
|
| 365 |
|
| 366 |
+
Returns user-facing provenance for one assistant answer.
|
| 367 |
+
|
| 368 |
+
The frontend should call this after the chat/help stream emits `done`, using the `message_id` from the `done` event. The row is written **before** `done`, so an immediate GET returns `200` (no polling race). A `404` means the id is unknown or the turn errored before completing (error turns never produce a row).
|
| 369 |
|
| 370 |
Query params:
|
| 371 |
|
|
|
|
| 377 |
Example:
|
| 378 |
|
| 379 |
```text
|
| 380 |
+
GET /api/v1/traceability?analysis_id=an_42&message_id=msg_88f1
|
| 381 |
```
|
| 382 |
|
| 383 |
+
`intent` values the frontend may see: `chat` · `help` · `check` · `unstructured_flow` · `structured_flow` · `out_of_scope` · `blocked` (`blocked` = input-guard or Azure content-filter refusal; `chat` also covers the greeting fast-path and cache replays).
|
| 384 |
+
|
| 385 |
Field rules:
|
| 386 |
|
| 387 |
+
- `planning`: present only when the planner ran (`structured_flow`); otherwise `null`.
|
| 388 |
+
- `thinking`: **always `null` in v1** — our agents are plain chat completions with no native reasoning output, and synthesizing it post-hoc would be unfaithful. The field stays in the payload so it can be populated later without a contract change.
|
| 389 |
+
- `tool_calls`: every invoked tool with `input`, `output`, `status`, `task_id` (nullable), and `error` (nullable); empty for chat / help / greeting / refusal paths.
|
| 390 |
+
- `sources`: required for retrieval flows; empty for chat / help / refusal paths and for `check`.
|
| 391 |
+
- The payload also carries an internal `user_id` (ownership); the frontend may ignore it.
|
| 392 |
+
- Truncation: `preview` ≤ 5 rows; any string inside `input`/`output`/`preview`/`snippet` ≤ 300 chars; rows beyond the preview are dropped (`row_count` is preserved).
|
| 393 |
|
| 394 |
Response `200` for `structured_flow`:
|
| 395 |
|
|
|
|
| 397 |
{
|
| 398 |
"analysis_id": "an_42",
|
| 399 |
"message_id": "msg_88f1",
|
| 400 |
+
"user_id": "user_7",
|
| 401 |
"intent": "structured_flow",
|
| 402 |
+
"generated_at": "2026-07-06T03:21:09.114Z",
|
| 403 |
"planning": {
|
| 404 |
"goal_restated": "Find which regions drive revenue and why Q1 dipped.",
|
| 405 |
+
"assumptions": [],
|
| 406 |
"steps": [
|
| 407 |
{
|
| 408 |
"step": 1,
|
| 409 |
"stage": "data_understanding",
|
| 410 |
+
"objective": "Inventory the sales source",
|
| 411 |
+
"status": "success",
|
| 412 |
+
"tools_used": ["check_data"]
|
| 413 |
},
|
| 414 |
{
|
| 415 |
"step": 2,
|
| 416 |
"stage": "modeling",
|
| 417 |
+
"objective": "Aggregate revenue by region",
|
| 418 |
+
"status": "success",
|
| 419 |
+
"tools_used": ["retrieve_data", "analyze_aggregate"]
|
| 420 |
}
|
| 421 |
]
|
| 422 |
},
|
| 423 |
+
"thinking": null,
|
| 424 |
"tool_calls": [
|
| 425 |
{
|
| 426 |
"order": 1,
|
| 427 |
+
"task_id": null,
|
| 428 |
"name": "check_data",
|
| 429 |
"input": { "source_hint": "structured" },
|
| 430 |
+
"output": {
|
| 431 |
+
"kind": "table",
|
| 432 |
+
"columns": ["source_id", "name", "source_type", "table_count"],
|
| 433 |
+
"row_count": 1,
|
| 434 |
+
"preview": [["src_sales_db", "orders", "schema", 1]]
|
| 435 |
+
},
|
| 436 |
+
"status": "success",
|
| 437 |
+
"error": null
|
| 438 |
},
|
| 439 |
{
|
| 440 |
"order": 2,
|
| 441 |
+
"task_id": null,
|
| 442 |
"name": "retrieve_data",
|
| 443 |
+
"input": { "ir": { "source_id": "src_sales_db", "table_id": "orders", "select": ["region", "amount"], "group_by": ["region"] } },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
"output": {
|
| 445 |
"kind": "table",
|
| 446 |
"columns": ["region", "total"],
|
| 447 |
"row_count": 4,
|
| 448 |
"preview": [["Central", 1210000], ["East", 740000]]
|
| 449 |
},
|
| 450 |
+
"status": "success",
|
| 451 |
+
"error": null
|
| 452 |
}
|
| 453 |
],
|
| 454 |
"sources": [
|
|
|
|
| 458 |
"name": "orders",
|
| 459 |
"query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
|
| 460 |
"detail": {
|
| 461 |
+
"table": "orders",
|
| 462 |
+
"row_count": 4
|
| 463 |
}
|
| 464 |
}
|
| 465 |
]
|
| 466 |
}
|
| 467 |
```
|
| 468 |
|
| 469 |
+
> Note: `retrieve_data`'s real `input` is the compiled query IR under an `ir` key (the planner builds an IR, never raw SQL). The executed SQL/rendered query appears on the corresponding `sources[].query`.
|
| 470 |
+
|
| 471 |
Response `200` for `unstructured_flow`:
|
| 472 |
|
| 473 |
```json
|
| 474 |
{
|
| 475 |
"analysis_id": "an_42",
|
| 476 |
"message_id": "msg_55",
|
| 477 |
+
"user_id": "user_7",
|
| 478 |
"intent": "unstructured_flow",
|
| 479 |
+
"generated_at": "2026-07-06T03:40:02.001Z",
|
| 480 |
"planning": null,
|
| 481 |
"thinking": null,
|
| 482 |
"tool_calls": [
|
| 483 |
{
|
| 484 |
"order": 1,
|
| 485 |
+
"task_id": null,
|
| 486 |
"name": "retrieve_knowledge",
|
| 487 |
+
"input": { "query": "technology stack used in this project" },
|
| 488 |
+
"output": { "kind": "documents", "row_count": 4 },
|
| 489 |
+
"status": "success",
|
| 490 |
+
"error": null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
}
|
| 492 |
],
|
| 493 |
"sources": [
|
|
|
|
| 504 |
}
|
| 505 |
```
|
| 506 |
|
| 507 |
+
Response `200` for chat / greeting / help / refusals (`out_of_scope`, `blocked`):
|
| 508 |
|
| 509 |
```json
|
| 510 |
{
|
| 511 |
"analysis_id": "an_42",
|
| 512 |
"message_id": "msg_12",
|
| 513 |
+
"user_id": "user_7",
|
| 514 |
"intent": "chat",
|
| 515 |
+
"generated_at": "2026-07-06T03:05:00.000Z",
|
| 516 |
"planning": null,
|
| 517 |
"thinking": null,
|
| 518 |
"tool_calls": [],
|
|
|
|
| 524 |
|
| 525 |
```json
|
| 526 |
{
|
| 527 |
+
"detail": "No traceability for message 'msg_88f1' yet."
|
| 528 |
}
|
| 529 |
```
|
| 530 |
|
| 531 |
Frontend rendering guidance:
|
| 532 |
|
| 533 |
+
- Render traceability separately from the streamed answer.
|
| 534 |
- Default state can be collapsed.
|
| 535 |
- Show planning, tool calls, and sources as separate sections.
|
| 536 |
- Treat `planning: null`, `tool_calls: []`, and `sources: []` as valid states.
|
DEV_PLAN.md
CHANGED
|
@@ -30,20 +30,19 @@ the endpoint contract *before* coding the tools. Status legend: ⬜ not started
|
|
| 30 |
| **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia | ✅ | Renamed route `GET /api/v1/tools` → `GET /api/v1/tools/list` ([tools.py:133](src/api/v1/tools.py:133)). |
|
| 31 |
| **2 — v2 + regroup** | FE: slash menu = `/help` only; report = right-side button | Mentor (FE) | ⬜ | Coordination note, not Python work. |
|
| 32 |
| **3 — tools + obs** | Finish `help` so it actually **calls** (not just lists) + test | Sofhia | ⬜ | Mentor: help currently only lists tools. Core #2 after chat. |
|
| 33 |
-
| **3 — tools + obs** |
|
| 34 |
-
| **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi |
|
| 35 |
-
| **3 — tools + obs** | Build `GET /api/v1/
|
| 36 |
-
| **3 — tools + obs** | Keep stream **text-only**;
|
| 37 |
-
| **3 — tools + obs** | Resolve `message_id` correlation (stream ↔
|
| 38 |
| **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
|
| 39 |
| **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
|
| 40 |
| **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
|
| 41 |
|
| 42 |
**Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
|
| 43 |
under `/api/v1/tools/`). The `message_id` correlation contract is now settled in **pr/6** (Python sole
|
| 44 |
-
minter, stream-only). The
|
| 45 |
-
(
|
| 46 |
-
questions, Go-blocked).
|
| 47 |
|
| 48 |
---
|
| 49 |
|
|
|
|
| 30 |
| **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia | ✅ | Renamed route `GET /api/v1/tools` → `GET /api/v1/tools/list` ([tools.py:133](src/api/v1/tools.py:133)). |
|
| 31 |
| **2 — v2 + regroup** | FE: slash menu = `/help` only; report = right-side button | Mentor (FE) | ⬜ | Coordination note, not Python work. |
|
| 32 |
| **3 — tools + obs** | Finish `help` so it actually **calls** (not just lists) + test | Sofhia | ⬜ | Mentor: help currently only lists tools. Core #2 after chat. |
|
| 33 |
+
| **3 — tools + obs** | Traceability **scratchpad** accumulating in the chat agent | Rifqi + Sofhia | ✅ | **KM-691.** `TraceabilityScratchpad` + `TraceabilityToolInvoker` (`src/traceability/`) capture planning / tool I/O / sources during the run; flushed one row before every `done` (all 8 sites; error turns = no row). Renamed observability→traceability (vs. Langfuse). |
|
| 34 |
+
| **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi | ✅ | **KM-691.** Chose a dedicated store: `message_traceability` = 1 JSONB row per message (Python-owned, like `report_inputs`; DDL run manually against dedorch, handed to Harry). Langfuse kept for engineering. |
|
| 35 |
+
| **3 — tools + obs** | Build `GET /api/v1/traceability` (one merged response) | Rifqi | ✅ | **KM-691.** `src/api/v1/traceability.py` → store.get → payload/404. Intent-based source rules (greeting/help/refusals = none; retrieve = required); full planning only on slow path. Contract §7 updated. |
|
| 36 |
+
| **3 — tools + obs** | Keep stream **text-only**; traceability is a separate parallel call | Rifqi | ✅ | **KM-691.** No trace data in the SSE stream; the FE fetches `/traceability` on `done`. |
|
| 37 |
+
| **3 — tools + obs** | Resolve `message_id` correlation (stream ↔ traceability) with Harry | Rifqi ↔ Harry | ✅ | **RESOLVED (pr/6):** Python is the **sole minter** — `message_id` dropped from the `/api/v2/chat/stream` + `/api/v1/tools/help` request bodies; always minted server-side (server-authoritative, FE-security) and returned on `done`. Any caller-sent `message_id` is ignored. Contract open-Q #1 closed. |
|
| 38 |
| **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
|
| 39 |
| **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
|
| 40 |
| **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
|
| 41 |
|
| 42 |
**Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
|
| 43 |
under `/api/v1/tools/`). The `message_id` correlation contract is now settled in **pr/6** (Python sole
|
| 44 |
+
minter, stream-only). The **Phase 3 traceability build** — scratchpad + `GET /api/v1/traceability`
|
| 45 |
+
(contract §7) — is **done (KM-691)**. Next is **Phase 4** (business questions, Go-blocked).
|
|
|
|
| 46 |
|
| 47 |
---
|
| 48 |
|
REPO_STATUS.md
CHANGED
|
@@ -18,7 +18,7 @@ are placeholders (see §12).
|
|
| 18 |
> [DEV_PLAN §0](DEV_PLAN.md)). **Python is becoming a generation/AI-only service** — Go owns the full
|
| 19 |
> analysis lifecycle *and* the data-plane endpoints. Scope:
|
| 20 |
> - **Unwired from `main` + Swagger** (router files kept, *not* deleted): `analysis` CRUD, `room`, `db_client`, `document`, `data_catalog`, `users`/login. **✅ DONE — KM-686, commit `0b2d678`** (so the §7 rows for these are now commented out of `main.py`).
|
| 21 |
-
> - **AI surface that stays live:** `chat` → **`POST /api/v2/chat/stream`** (explicit **`analysis_id`**, not `room_id`); the skills regroup under **`/api/v1/tools/`** (`list` · `help` · `report`); plus a **new `GET /api/v1/
|
| 22 |
> - **Only `chat/stream` moves to `/api/v2`;** everything else stays `/api/v1`.
|
| 23 |
>
|
| 24 |
> §2/§4/§7 below still describe the **pre-restructure wiring** except the unwire above, which has landed.
|
|
@@ -50,7 +50,7 @@ streaming.
|
|
| 50 |
|
| 51 |
> **» pr/5 (decided, not yet in code):** Python's non-AI endpoints (analysis CRUD, `room`, `document`,
|
| 52 |
> `db_client`, `data_catalog`, `users`/login) are being **unwired** — Python keeps only the
|
| 53 |
-
> generation/AI surface (chat, tools: `help`/`report`/`list`,
|
| 54 |
|
| 55 |
Shared infra: **Postgres** (app tables + `data_catalog` jsonb + PGVector `langchain_pg_embedding`), **Azure Blob**, and (Python-only) **Redis**.
|
| 56 |
|
|
@@ -78,8 +78,8 @@ Entry: `POST /api/v1/chat/stream` (`src/api/v1/chat.py`) → `ChatHandler.handle
|
|
| 78 |
(`src/agents/chat_handler.py`). One shared `ChatHandler` per process keeps the Azure clients warm.
|
| 79 |
|
| 80 |
> **» pr/5:** this endpoint moves to **`POST /api/v2/chat/stream`** with an explicit **`analysis_id`**
|
| 81 |
-
> field (replacing `room_id`), and the
|
| 82 |
-
> the stream to a separate `GET /api/v1/
|
| 83 |
|
| 84 |
```
|
| 85 |
POST /chat/stream { user_id, room_id, message }
|
|
@@ -156,7 +156,7 @@ Two facts to internalise:
|
|
| 156 |
## 7. API surface (this repo, all under `/api/v1`)
|
| 157 |
|
| 158 |
> **» pr/5 (decided, not yet in code):** chat → `/api/v2/chat/stream` (`analysis_id`); `/tools` splits
|
| 159 |
-
> into `/tools/list` + `/tools/help` + `/tools/report`; new `/api/v1/
|
| 160 |
> analysis-CRUD / `room` / `users` / `document` / `db_client` / `data_catalog` rows are unwired from
|
| 161 |
> `main` + Swagger. See the Direction-update banner.
|
| 162 |
|
|
|
|
| 18 |
> [DEV_PLAN §0](DEV_PLAN.md)). **Python is becoming a generation/AI-only service** — Go owns the full
|
| 19 |
> analysis lifecycle *and* the data-plane endpoints. Scope:
|
| 20 |
> - **Unwired from `main` + Swagger** (router files kept, *not* deleted): `analysis` CRUD, `room`, `db_client`, `document`, `data_catalog`, `users`/login. **✅ DONE — KM-686, commit `0b2d678`** (so the §7 rows for these are now commented out of `main.py`).
|
| 21 |
+
> - **AI surface that stays live:** `chat` → **`POST /api/v2/chat/stream`** (explicit **`analysis_id`**, not `room_id`); the skills regroup under **`/api/v1/tools/`** (`list` · `help` · `report`); plus a **new `GET /api/v1/traceability`** (user-facing provenance per answer, backed by a Python-owned `message_traceability` store — renamed from `observability`, KM-691). **✅ built.**
|
| 22 |
> - **Only `chat/stream` moves to `/api/v2`;** everything else stays `/api/v1`.
|
| 23 |
>
|
| 24 |
> §2/§4/§7 below still describe the **pre-restructure wiring** except the unwire above, which has landed.
|
|
|
|
| 50 |
|
| 51 |
> **» pr/5 (decided, not yet in code):** Python's non-AI endpoints (analysis CRUD, `room`, `document`,
|
| 52 |
> `db_client`, `data_catalog`, `users`/login) are being **unwired** — Python keeps only the
|
| 53 |
+
> generation/AI surface (chat, tools: `help`/`report`/`list`, traceability). See the Direction-update banner.
|
| 54 |
|
| 55 |
Shared infra: **Postgres** (app tables + `data_catalog` jsonb + PGVector `langchain_pg_embedding`), **Azure Blob**, and (Python-only) **Redis**.
|
| 56 |
|
|
|
|
| 78 |
(`src/agents/chat_handler.py`). One shared `ChatHandler` per process keeps the Azure clients warm.
|
| 79 |
|
| 80 |
> **» pr/5:** this endpoint moves to **`POST /api/v2/chat/stream`** with an explicit **`analysis_id`**
|
| 81 |
+
> field (replacing `room_id`), and the traceability detail (planning / tool I/O / sources) moves out of
|
| 82 |
+
> the stream to a separate `GET /api/v1/traceability` call. See the Direction-update banner.
|
| 83 |
|
| 84 |
```
|
| 85 |
POST /chat/stream { user_id, room_id, message }
|
|
|
|
| 156 |
## 7. API surface (this repo, all under `/api/v1`)
|
| 157 |
|
| 158 |
> **» pr/5 (decided, not yet in code):** chat → `/api/v2/chat/stream` (`analysis_id`); `/tools` splits
|
| 159 |
+
> into `/tools/list` + `/tools/help` + `/tools/report`; new `/api/v1/traceability`; and the
|
| 160 |
> analysis-CRUD / `room` / `users` / `document` / `db_client` / `data_catalog` rows are unwired from
|
| 161 |
> `main` + Swagger. See the Direction-update banner.
|
| 162 |
|
main.py
CHANGED
|
@@ -21,6 +21,7 @@ from slowapi.errors import RateLimitExceeded
|
|
| 21 |
from src.api.v1.report import router as report_router
|
| 22 |
from src.api.v1.tools import router as tools_router
|
| 23 |
from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
|
|
|
|
| 24 |
from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
|
| 25 |
from src.db.postgres.init_db import init_db
|
| 26 |
from src.config.settings import settings
|
|
@@ -67,6 +68,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
| 67 |
app.include_router(report_router)
|
| 68 |
app.include_router(tools_router)
|
| 69 |
app.include_router(help_router)
|
|
|
|
| 70 |
app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
|
| 71 |
|
| 72 |
|
|
|
|
| 21 |
from src.api.v1.report import router as report_router
|
| 22 |
from src.api.v1.tools import router as tools_router
|
| 23 |
from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
|
| 24 |
+
from src.api.v1.traceability import router as traceability_router # KM-691
|
| 25 |
from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
|
| 26 |
from src.db.postgres.init_db import init_db
|
| 27 |
from src.config.settings import settings
|
|
|
|
| 68 |
app.include_router(report_router)
|
| 69 |
app.include_router(tools_router)
|
| 70 |
app.include_router(help_router)
|
| 71 |
+
app.include_router(traceability_router) # KM-691: GET /api/v1/traceability
|
| 72 |
app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
|
| 73 |
|
| 74 |
|
src/agents/chat_handler.py
CHANGED
|
@@ -38,6 +38,7 @@ from langchain_core.messages import BaseMessage
|
|
| 38 |
|
| 39 |
from src.middlewares.logging import get_logger
|
| 40 |
from src.retrieval.base import RetrievalResult
|
|
|
|
| 41 |
|
| 42 |
from .chatbot import ChatbotAgent, DocumentChunk
|
| 43 |
from .guard import InputGuard
|
|
@@ -54,6 +55,7 @@ from .refusals import blocked_message, out_of_scope_message
|
|
| 54 |
if TYPE_CHECKING:
|
| 55 |
from ..catalog.reader import CatalogReader
|
| 56 |
from ..retrieval.router import RetrievalRouter
|
|
|
|
| 57 |
from .gate import AnalysisState
|
| 58 |
from .slow_path.coordinator import SlowPathCoordinator
|
| 59 |
from .slow_path.store import ReportInputStore
|
|
@@ -102,6 +104,7 @@ class ChatHandler:
|
|
| 102 |
Callable[[str], SlowPathCoordinator] | None
|
| 103 |
) = None,
|
| 104 |
analysis_store: ReportInputStore | None = None,
|
|
|
|
| 105 |
check_invoker_factory: Callable[[str], Any] | None = None,
|
| 106 |
ps_agent: ProblemStatementAgent | None = None,
|
| 107 |
help_agent: HelpAgent | None = None,
|
|
@@ -123,6 +126,9 @@ class ChatHandler:
|
|
| 123 |
# factory + store are injectable for tests.
|
| 124 |
self._slow_path_factory = slow_path_coordinator_factory
|
| 125 |
self._analysis_store = analysis_store
|
|
|
|
|
|
|
|
|
|
| 126 |
# `check` skill: builds the data-access invoker (check_data/check_knowledge)
|
| 127 |
# per request with the authenticated user_id. Injectable for tests.
|
| 128 |
self._check_invoker_factory = check_invoker_factory
|
|
@@ -251,6 +257,7 @@ class ChatHandler:
|
|
| 251 |
analysis_id: str | None,
|
| 252 |
history: list[BaseMessage] | None = None,
|
| 253 |
message: str | None = None,
|
|
|
|
| 254 |
) -> AsyncIterator[dict[str, Any]]:
|
| 255 |
"""Deterministic `help` dispatch for the dedicated `/api/v1/tools/help` endpoint.
|
| 256 |
|
|
@@ -262,6 +269,12 @@ class ChatHandler:
|
|
| 262 |
documents), `chunk`*, then `done` (data left empty; the endpoint stamps the
|
| 263 |
`message_id`). On failure, yields a terminal `error` event.
|
| 264 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
# Load (or lazily create) the analysis state; fail closed to a not-validated
|
| 266 |
# stub so help degrades gracefully on a missing row / read error / legacy id.
|
| 267 |
state: AnalysisState | None = None
|
|
@@ -292,6 +305,7 @@ class ChatHandler:
|
|
| 292 |
logger.error("help streaming failed", user_id=user_id, error=str(e))
|
| 293 |
yield {"event": "error", "data": f"Help generation failed: {e}"}
|
| 294 |
return
|
|
|
|
| 295 |
yield {"event": "done", "data": ""}
|
| 296 |
|
| 297 |
async def handle(
|
|
@@ -300,8 +314,14 @@ class ChatHandler:
|
|
| 300 |
user_id: str,
|
| 301 |
history: list[BaseMessage] | None = None,
|
| 302 |
analysis_id: str | None = None,
|
|
|
|
| 303 |
) -> AsyncIterator[dict[str, Any]]:
|
| 304 |
tracer = self._make_tracer(user_id, message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
# ---- 0. Input guard ------------------------------------------
|
| 307 |
# Deliberate input-filtering layer BEFORE the router: screen for prompt-
|
|
@@ -319,6 +339,8 @@ class ChatHandler:
|
|
| 319 |
yield {"event": "sources", "data": json.dumps([])}
|
| 320 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 321 |
tracer.end()
|
|
|
|
|
|
|
| 322 |
yield {"event": "done", "data": ""}
|
| 323 |
return
|
| 324 |
|
|
@@ -335,6 +357,8 @@ class ChatHandler:
|
|
| 335 |
yield {"event": "sources", "data": json.dumps([])}
|
| 336 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 337 |
tracer.end()
|
|
|
|
|
|
|
| 338 |
yield {"event": "done", "data": ""}
|
| 339 |
return
|
| 340 |
logger.error("intent classification failed", error=repr(e))
|
|
@@ -345,6 +369,7 @@ class ChatHandler:
|
|
| 345 |
return
|
| 346 |
|
| 347 |
intent = decision.intent
|
|
|
|
| 348 |
# ---- 1a. Ensure session state row (T-A) ----------------------
|
| 349 |
# Rooms created via /room/create have no `analysis` row. Without one, Help and
|
| 350 |
# the report_id write-back silently no-op. Lazily get-or-create it (idempotent).
|
|
@@ -392,6 +417,7 @@ class ChatHandler:
|
|
| 392 |
yield {"event": "sources", "data": json.dumps([])}
|
| 393 |
yield {"event": "chunk", "data": out_of_scope_message(message)}
|
| 394 |
tracer.end()
|
|
|
|
| 395 |
yield {"event": "done", "data": ""}
|
| 396 |
return
|
| 397 |
if intent == "structured_flow":
|
|
@@ -416,7 +442,8 @@ class ChatHandler:
|
|
| 416 |
# make the assembled answer English for an Indonesian question).
|
| 417 |
reply_language = detect_reply_language(history, message=message)
|
| 418 |
async for event in self._run_slow_path(
|
| 419 |
-
user_id, rewritten, catalog, tracer, reader, analysis_id,
|
|
|
|
| 420 |
):
|
| 421 |
yield event
|
| 422 |
return
|
|
@@ -434,6 +461,14 @@ class ChatHandler:
|
|
| 434 |
rewritten, user_id
|
| 435 |
)
|
| 436 |
chunks = _normalize_chunks(raw_chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
except Exception as e:
|
| 438 |
logger.error(
|
| 439 |
"unstructured route failed", user_id=user_id, error=str(e)
|
|
@@ -442,7 +477,8 @@ class ChatHandler:
|
|
| 442 |
return
|
| 443 |
elif intent == "check":
|
| 444 |
try:
|
| 445 |
-
invoker
|
|
|
|
| 446 |
# Detect from the ORIGINAL message (not `rewritten`, which the
|
| 447 |
# router normalizes to English) so the deterministic check reply
|
| 448 |
# matches the user's language like the other paths.
|
|
@@ -453,6 +489,7 @@ class ChatHandler:
|
|
| 453 |
yield {"event": "error", "data": f"Lookup failed: {e}"}
|
| 454 |
return
|
| 455 |
yield {"event": "chunk", "data": text}
|
|
|
|
| 456 |
yield {"event": "done", "data": ""}
|
| 457 |
return
|
| 458 |
# problem_statement dispatch removed 2026-06-24 (skill unwired; intent no longer
|
|
@@ -504,6 +541,7 @@ class ChatHandler:
|
|
| 504 |
yield {"event": "error", "data": f"Help generation failed: {e}"}
|
| 505 |
return
|
| 506 |
tracer.end()
|
|
|
|
| 507 |
yield {"event": "done", "data": ""}
|
| 508 |
return
|
| 509 |
# else: chat path — no context
|
|
@@ -537,6 +575,8 @@ class ChatHandler:
|
|
| 537 |
return
|
| 538 |
|
| 539 |
tracer.end()
|
|
|
|
|
|
|
| 540 |
yield {"event": "done", "data": ""}
|
| 541 |
|
| 542 |
# ------------------------------------------------------------------
|
|
@@ -554,7 +594,11 @@ class ChatHandler:
|
|
| 554 |
return RequestTracer.start(user_id=user_id, question=question)
|
| 555 |
|
| 556 |
def _get_slow_path_coordinator(
|
| 557 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
) -> SlowPathCoordinator:
|
| 559 |
"""Build the per-request slow-path coordinator (composition root).
|
| 560 |
|
|
@@ -583,6 +627,10 @@ class ChatHandler:
|
|
| 583 |
from ..observability.langfuse.tracing import TracingToolInvoker
|
| 584 |
|
| 585 |
invoker = TracingToolInvoker(invoker, tracer)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
registry = default_registry()
|
| 587 |
return SlowPathCoordinator(
|
| 588 |
PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
|
|
@@ -595,6 +643,30 @@ class ChatHandler:
|
|
| 595 |
self._analysis_store = PostgresReportInputStore()
|
| 596 |
return self._analysis_store
|
| 597 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
async def _run_slow_path(
|
| 599 |
self,
|
| 600 |
user_id: str,
|
|
@@ -604,6 +676,7 @@ class ChatHandler:
|
|
| 604 |
catalog_reader: CatalogReader | None = None,
|
| 605 |
analysis_id: str | None = None,
|
| 606 |
reply_language: str | None = None,
|
|
|
|
| 607 |
) -> AsyncIterator[dict[str, Any]]:
|
| 608 |
"""Run the slow path and stream its assembled answer as SSE events.
|
| 609 |
|
|
@@ -621,7 +694,7 @@ class ChatHandler:
|
|
| 621 |
|
| 622 |
tracer = NullTracer()
|
| 623 |
|
| 624 |
-
coordinator = self._get_slow_path_coordinator(user_id, tracer, catalog_reader)
|
| 625 |
context = await get_business_context(user_id)
|
| 626 |
|
| 627 |
# DB3: warm the user's DB connection in parallel with planning so the
|
|
@@ -688,9 +761,15 @@ class ChatHandler:
|
|
| 688 |
update={"user_id": user_id, "analysis_id": analysis_id}
|
| 689 |
)
|
| 690 |
await self._get_analysis_store().save(record)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
except Exception as e: # persistence must never break the user's answer
|
| 692 |
logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
|
| 693 |
tracer.end() # output omitted (chat_answer may contain PII on Cloud)
|
|
|
|
|
|
|
| 694 |
yield {"event": "done", "data": ""}
|
| 695 |
|
| 696 |
|
|
|
|
| 38 |
|
| 39 |
from src.middlewares.logging import get_logger
|
| 40 |
from src.retrieval.base import RetrievalResult
|
| 41 |
+
from src.traceability import TraceabilityScratchpad, TraceabilityToolInvoker
|
| 42 |
|
| 43 |
from .chatbot import ChatbotAgent, DocumentChunk
|
| 44 |
from .guard import InputGuard
|
|
|
|
| 55 |
if TYPE_CHECKING:
|
| 56 |
from ..catalog.reader import CatalogReader
|
| 57 |
from ..retrieval.router import RetrievalRouter
|
| 58 |
+
from ..traceability.store import TraceabilityStore
|
| 59 |
from .gate import AnalysisState
|
| 60 |
from .slow_path.coordinator import SlowPathCoordinator
|
| 61 |
from .slow_path.store import ReportInputStore
|
|
|
|
| 104 |
Callable[[str], SlowPathCoordinator] | None
|
| 105 |
) = None,
|
| 106 |
analysis_store: ReportInputStore | None = None,
|
| 107 |
+
traceability_store: TraceabilityStore | None = None,
|
| 108 |
check_invoker_factory: Callable[[str], Any] | None = None,
|
| 109 |
ps_agent: ProblemStatementAgent | None = None,
|
| 110 |
help_agent: HelpAgent | None = None,
|
|
|
|
| 126 |
# factory + store are injectable for tests.
|
| 127 |
self._slow_path_factory = slow_path_coordinator_factory
|
| 128 |
self._analysis_store = analysis_store
|
| 129 |
+
# Traceability (KM-691): user-facing per-turn provenance store. Injectable for
|
| 130 |
+
# tests; lazily built (Postgres) in production. Distinct from Langfuse tracing.
|
| 131 |
+
self._traceability_store = traceability_store
|
| 132 |
# `check` skill: builds the data-access invoker (check_data/check_knowledge)
|
| 133 |
# per request with the authenticated user_id. Injectable for tests.
|
| 134 |
self._check_invoker_factory = check_invoker_factory
|
|
|
|
| 257 |
analysis_id: str | None,
|
| 258 |
history: list[BaseMessage] | None = None,
|
| 259 |
message: str | None = None,
|
| 260 |
+
message_id: str | None = None,
|
| 261 |
) -> AsyncIterator[dict[str, Any]]:
|
| 262 |
"""Deterministic `help` dispatch for the dedicated `/api/v1/tools/help` endpoint.
|
| 263 |
|
|
|
|
| 269 |
documents), `chunk`*, then `done` (data left empty; the endpoint stamps the
|
| 270 |
`message_id`). On failure, yields a terminal `error` event.
|
| 271 |
"""
|
| 272 |
+
# Traceability (KM-691): a help turn has no planning/tools/sources — an
|
| 273 |
+
# empty payload stamped `help`, flushed before `done`.
|
| 274 |
+
pad = TraceabilityScratchpad()
|
| 275 |
+
pad.message_id = message_id
|
| 276 |
+
pad.set_intent("help")
|
| 277 |
+
|
| 278 |
# Load (or lazily create) the analysis state; fail closed to a not-validated
|
| 279 |
# stub so help degrades gracefully on a missing row / read error / legacy id.
|
| 280 |
state: AnalysisState | None = None
|
|
|
|
| 305 |
logger.error("help streaming failed", user_id=user_id, error=str(e))
|
| 306 |
yield {"event": "error", "data": f"Help generation failed: {e}"}
|
| 307 |
return
|
| 308 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 309 |
yield {"event": "done", "data": ""}
|
| 310 |
|
| 311 |
async def handle(
|
|
|
|
| 314 |
user_id: str,
|
| 315 |
history: list[BaseMessage] | None = None,
|
| 316 |
analysis_id: str | None = None,
|
| 317 |
+
message_id: str | None = None,
|
| 318 |
) -> AsyncIterator[dict[str, Any]]:
|
| 319 |
tracer = self._make_tracer(user_id, message)
|
| 320 |
+
# Traceability (KM-691): per-request accumulator, flushed before EVERY `done`
|
| 321 |
+
# (§5 matrix). Default intent `chat` until the router classifies; the two
|
| 322 |
+
# refusal branches below stamp `blocked` explicitly.
|
| 323 |
+
pad = TraceabilityScratchpad()
|
| 324 |
+
pad.message_id = message_id
|
| 325 |
|
| 326 |
# ---- 0. Input guard ------------------------------------------
|
| 327 |
# Deliberate input-filtering layer BEFORE the router: screen for prompt-
|
|
|
|
| 339 |
yield {"event": "sources", "data": json.dumps([])}
|
| 340 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 341 |
tracer.end()
|
| 342 |
+
pad.set_intent("blocked")
|
| 343 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 344 |
yield {"event": "done", "data": ""}
|
| 345 |
return
|
| 346 |
|
|
|
|
| 357 |
yield {"event": "sources", "data": json.dumps([])}
|
| 358 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 359 |
tracer.end()
|
| 360 |
+
pad.set_intent("blocked")
|
| 361 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 362 |
yield {"event": "done", "data": ""}
|
| 363 |
return
|
| 364 |
logger.error("intent classification failed", error=repr(e))
|
|
|
|
| 369 |
return
|
| 370 |
|
| 371 |
intent = decision.intent
|
| 372 |
+
pad.set_intent(intent) # traceability: chat | check | *_flow | out_of_scope | help
|
| 373 |
# ---- 1a. Ensure session state row (T-A) ----------------------
|
| 374 |
# Rooms created via /room/create have no `analysis` row. Without one, Help and
|
| 375 |
# the report_id write-back silently no-op. Lazily get-or-create it (idempotent).
|
|
|
|
| 417 |
yield {"event": "sources", "data": json.dumps([])}
|
| 418 |
yield {"event": "chunk", "data": out_of_scope_message(message)}
|
| 419 |
tracer.end()
|
| 420 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 421 |
yield {"event": "done", "data": ""}
|
| 422 |
return
|
| 423 |
if intent == "structured_flow":
|
|
|
|
| 442 |
# make the assembled answer English for an Indonesian question).
|
| 443 |
reply_language = detect_reply_language(history, message=message)
|
| 444 |
async for event in self._run_slow_path(
|
| 445 |
+
user_id, rewritten, catalog, tracer, reader, analysis_id,
|
| 446 |
+
reply_language, pad,
|
| 447 |
):
|
| 448 |
yield event
|
| 449 |
return
|
|
|
|
| 461 |
rewritten, user_id
|
| 462 |
)
|
| 463 |
chunks = _normalize_chunks(raw_chunks)
|
| 464 |
+
# Traceability (KM-691): retrieval bypasses the tool invoker, so synth
|
| 465 |
+
# the retrieve_knowledge call (input = rewritten query) + document sources.
|
| 466 |
+
pad.record_tool_call(
|
| 467 |
+
"retrieve_knowledge",
|
| 468 |
+
{"query": rewritten},
|
| 469 |
+
{"kind": "documents", "row_count": len(raw_chunks or [])},
|
| 470 |
+
)
|
| 471 |
+
pad.add_document_sources(raw_chunks, rewritten)
|
| 472 |
except Exception as e:
|
| 473 |
logger.error(
|
| 474 |
"unstructured route failed", user_id=user_id, error=str(e)
|
|
|
|
| 477 |
return
|
| 478 |
elif intent == "check":
|
| 479 |
try:
|
| 480 |
+
# Wrap the check invoker so its check_* tool calls land in the trace.
|
| 481 |
+
invoker = TraceabilityToolInvoker(self._get_check_invoker(user_id), pad)
|
| 482 |
# Detect from the ORIGINAL message (not `rewritten`, which the
|
| 483 |
# router normalizes to English) so the deterministic check reply
|
| 484 |
# matches the user's language like the other paths.
|
|
|
|
| 489 |
yield {"event": "error", "data": f"Lookup failed: {e}"}
|
| 490 |
return
|
| 491 |
yield {"event": "chunk", "data": text}
|
| 492 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 493 |
yield {"event": "done", "data": ""}
|
| 494 |
return
|
| 495 |
# problem_statement dispatch removed 2026-06-24 (skill unwired; intent no longer
|
|
|
|
| 541 |
yield {"event": "error", "data": f"Help generation failed: {e}"}
|
| 542 |
return
|
| 543 |
tracer.end()
|
| 544 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 545 |
yield {"event": "done", "data": ""}
|
| 546 |
return
|
| 547 |
# else: chat path — no context
|
|
|
|
| 575 |
return
|
| 576 |
|
| 577 |
tracer.end()
|
| 578 |
+
# chat: empty payload; unstructured_flow: synth retrieve_knowledge + doc sources.
|
| 579 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 580 |
yield {"event": "done", "data": ""}
|
| 581 |
|
| 582 |
# ------------------------------------------------------------------
|
|
|
|
| 594 |
return RequestTracer.start(user_id=user_id, question=question)
|
| 595 |
|
| 596 |
def _get_slow_path_coordinator(
|
| 597 |
+
self,
|
| 598 |
+
user_id: str,
|
| 599 |
+
tracer: Any = None,
|
| 600 |
+
catalog_reader: CatalogReader | None = None,
|
| 601 |
+
pad: TraceabilityScratchpad | None = None,
|
| 602 |
) -> SlowPathCoordinator:
|
| 603 |
"""Build the per-request slow-path coordinator (composition root).
|
| 604 |
|
|
|
|
| 627 |
from ..observability.langfuse.tracing import TracingToolInvoker
|
| 628 |
|
| 629 |
invoker = TracingToolInvoker(invoker, tracer)
|
| 630 |
+
# Traceability outermost: records the SAME real I/O the tools return (both
|
| 631 |
+
# wrappers see it; order is immaterial). KM-691.
|
| 632 |
+
if pad is not None:
|
| 633 |
+
invoker = TraceabilityToolInvoker(invoker, pad)
|
| 634 |
registry = default_registry()
|
| 635 |
return SlowPathCoordinator(
|
| 636 |
PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
|
|
|
|
| 643 |
self._analysis_store = PostgresReportInputStore()
|
| 644 |
return self._analysis_store
|
| 645 |
|
| 646 |
+
def _get_traceability_store(self) -> TraceabilityStore:
|
| 647 |
+
if self._traceability_store is None:
|
| 648 |
+
from ..traceability import PostgresTraceabilityStore
|
| 649 |
+
|
| 650 |
+
self._traceability_store = PostgresTraceabilityStore()
|
| 651 |
+
return self._traceability_store
|
| 652 |
+
|
| 653 |
+
async def _flush_trace(
|
| 654 |
+
self, pad: TraceabilityScratchpad, analysis_id: str | None, user_id: str
|
| 655 |
+
) -> None:
|
| 656 |
+
"""Persist the turn's traceability row right before `done` (KM-691).
|
| 657 |
+
|
| 658 |
+
No-op without a `message_id` (tests / unwired callers never flush). The
|
| 659 |
+
store save is itself never-throw, but we also guard here: a trace failure
|
| 660 |
+
must never break — or delay past `done` — the user's answer.
|
| 661 |
+
"""
|
| 662 |
+
if pad.message_id is None:
|
| 663 |
+
return
|
| 664 |
+
try:
|
| 665 |
+
payload = pad.build(analysis_id or "", user_id, pad.message_id)
|
| 666 |
+
await self._get_traceability_store().save(payload)
|
| 667 |
+
except Exception as e: # noqa: BLE001 — never break the answer on a trace slip
|
| 668 |
+
logger.warning("traceability flush failed", error=str(e))
|
| 669 |
+
|
| 670 |
async def _run_slow_path(
|
| 671 |
self,
|
| 672 |
user_id: str,
|
|
|
|
| 676 |
catalog_reader: CatalogReader | None = None,
|
| 677 |
analysis_id: str | None = None,
|
| 678 |
reply_language: str | None = None,
|
| 679 |
+
pad: TraceabilityScratchpad | None = None,
|
| 680 |
) -> AsyncIterator[dict[str, Any]]:
|
| 681 |
"""Run the slow path and stream its assembled answer as SSE events.
|
| 682 |
|
|
|
|
| 694 |
|
| 695 |
tracer = NullTracer()
|
| 696 |
|
| 697 |
+
coordinator = self._get_slow_path_coordinator(user_id, tracer, catalog_reader, pad)
|
| 698 |
context = await get_business_context(user_id)
|
| 699 |
|
| 700 |
# DB3: warm the user's DB connection in parallel with planning so the
|
|
|
|
| 761 |
update={"user_id": user_id, "analysis_id": analysis_id}
|
| 762 |
)
|
| 763 |
await self._get_analysis_store().save(record)
|
| 764 |
+
if pad is not None:
|
| 765 |
+
# Traceability planning = goal_restated + tasks_run from the record;
|
| 766 |
+
# tool_calls were already recorded by the wrapped invoker.
|
| 767 |
+
pad.set_planning_from_record(record)
|
| 768 |
except Exception as e: # persistence must never break the user's answer
|
| 769 |
logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
|
| 770 |
tracer.end() # output omitted (chat_answer may contain PII on Cloud)
|
| 771 |
+
if pad is not None:
|
| 772 |
+
await self._flush_trace(pad, analysis_id, user_id)
|
| 773 |
yield {"event": "done", "data": ""}
|
| 774 |
|
| 775 |
|
src/api/v1/help.py
CHANGED
|
@@ -62,6 +62,7 @@ async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)):
|
|
| 62 |
request.analysis_id,
|
| 63 |
history=history,
|
| 64 |
message=None,
|
|
|
|
| 65 |
):
|
| 66 |
if event["event"] == "done":
|
| 67 |
# Stamp the turn id so the FE can fetch /observability for it.
|
|
|
|
| 62 |
request.analysis_id,
|
| 63 |
history=history,
|
| 64 |
message=None,
|
| 65 |
+
message_id=message_id,
|
| 66 |
):
|
| 67 |
if event["event"] == "done":
|
| 68 |
# Stamp the turn id so the FE can fetch /observability for it.
|
src/api/v1/traceability.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Traceability endpoint — user-facing per-turn provenance (KM-691).
|
| 2 |
+
|
| 3 |
+
`GET /api/v1/traceability?analysis_id=&message_id=` returns the provenance record for
|
| 4 |
+
one assistant turn: planning / tool calls (with real inputs + outputs) / data sources
|
| 5 |
+
(with the executed query). One JSONB row per assistant `message_id`, written by the
|
| 6 |
+
chat pipeline right before the `done` SSE event; the FE fires this GET on `done`.
|
| 7 |
+
|
| 8 |
+
Renamed from the contracted `/api/v1/observability` (team decision 2026-07-06) so it is
|
| 9 |
+
never confused with the Langfuse *observability* stack (engineering-only, PII-masked).
|
| 10 |
+
No auth — Go fronts Python.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 14 |
+
|
| 15 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 16 |
+
from src.traceability import PostgresTraceabilityStore, TraceabilityPayload
|
| 17 |
+
|
| 18 |
+
logger = get_logger("traceability_api")
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v1", tags=["Traceability"])
|
| 21 |
+
|
| 22 |
+
# Warm, process-shared store (mirrors the chat endpoints' module-level instances).
|
| 23 |
+
_store = PostgresTraceabilityStore()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/traceability", response_model=TraceabilityPayload)
|
| 27 |
+
@log_execution(logger)
|
| 28 |
+
async def get_traceability(
|
| 29 |
+
analysis_id: str = Query(..., description="Analysis/session id"),
|
| 30 |
+
message_id: str = Query(
|
| 31 |
+
..., description="Assistant turn id, taken from the `done` SSE event"
|
| 32 |
+
),
|
| 33 |
+
) -> TraceabilityPayload:
|
| 34 |
+
"""Fetch one turn's provenance record. 404 while the turn is still running or if
|
| 35 |
+
the id is unknown (the FE never gets a `message_id` for error turns, so 404 is
|
| 36 |
+
the correct answer there)."""
|
| 37 |
+
payload = await _store.get(analysis_id, message_id)
|
| 38 |
+
if payload is None:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=404,
|
| 41 |
+
detail=f"No traceability for message '{message_id}' yet.",
|
| 42 |
+
)
|
| 43 |
+
return payload
|
src/api/v2/chat.py
CHANGED
|
@@ -44,11 +44,29 @@ from src.db.postgres.connection import get_db
|
|
| 44 |
from src.db.redis.connection import get_redis
|
| 45 |
from src.middlewares.logging import get_logger, log_execution
|
| 46 |
from src.middlewares.rate_limit import limiter
|
|
|
|
| 47 |
|
| 48 |
logger = get_logger("chat_api_v2")
|
| 49 |
|
| 50 |
router = APIRouter(prefix="/api/v2", tags=["Chat"])
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def _mint_message_id() -> str:
|
| 54 |
"""Mint the assistant turn id. Server-authoritative — never accepted from the caller
|
|
@@ -103,6 +121,8 @@ async def chat_stream(
|
|
| 103 |
yield {"event": "chunk", "data": cached_text[i:i + 50]}
|
| 104 |
yield done_event
|
| 105 |
|
|
|
|
|
|
|
| 106 |
return EventSourceResponse(stream_cached())
|
| 107 |
|
| 108 |
try:
|
|
@@ -116,6 +136,8 @@ async def chat_stream(
|
|
| 116 |
yield {"event": "chunk", "data": direct}
|
| 117 |
yield done_event
|
| 118 |
|
|
|
|
|
|
|
| 119 |
return EventSourceResponse(stream_direct())
|
| 120 |
|
| 121 |
history = await load_history(db, analysis_id, limit=10)
|
|
@@ -127,7 +149,8 @@ async def chat_stream(
|
|
| 127 |
sources: list[dict[str, Any]] = []
|
| 128 |
effective_intent: str | None = None
|
| 129 |
async for event in handler.handle(
|
| 130 |
-
body.message, body.user_id, history,
|
|
|
|
| 131 |
):
|
| 132 |
if event["event"] == "intent":
|
| 133 |
# consumed internally (not forwarded); gates caching below.
|
|
|
|
| 44 |
from src.db.redis.connection import get_redis
|
| 45 |
from src.middlewares.logging import get_logger, log_execution
|
| 46 |
from src.middlewares.rate_limit import limiter
|
| 47 |
+
from src.traceability import PostgresTraceabilityStore, TraceabilityScratchpad
|
| 48 |
|
| 49 |
logger = get_logger("chat_api_v2")
|
| 50 |
|
| 51 |
router = APIRouter(prefix="/api/v2", tags=["Chat"])
|
| 52 |
|
| 53 |
+
# Module-level store (mirrors the warm, process-shared `_chat_handler`): the greeting
|
| 54 |
+
# fast-path and cache-replay branches never enter `ChatHandler.handle`, so they write
|
| 55 |
+
# their (empty `chat`) traceability row directly here. KM-691.
|
| 56 |
+
_traceability_store = PostgresTraceabilityStore()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def _save_empty_chat_trace(analysis_id: str, user_id: str, message_id: str) -> None:
|
| 60 |
+
"""Persist an empty `chat` traceability row for a turn that bypassed the handler
|
| 61 |
+
(greeting / cache replay), so the FE's GET on `done` returns a payload, not a 404."""
|
| 62 |
+
try:
|
| 63 |
+
pad = TraceabilityScratchpad()
|
| 64 |
+
pad.message_id = message_id
|
| 65 |
+
pad.set_intent("chat")
|
| 66 |
+
await _traceability_store.save(pad.build(analysis_id, user_id, message_id))
|
| 67 |
+
except Exception as e: # noqa: BLE001 — never break the reply on a trace slip
|
| 68 |
+
logger.warning("traceability direct save failed", message_id=message_id, error=str(e))
|
| 69 |
+
|
| 70 |
|
| 71 |
def _mint_message_id() -> str:
|
| 72 |
"""Mint the assistant turn id. Server-authoritative — never accepted from the caller
|
|
|
|
| 121 |
yield {"event": "chunk", "data": cached_text[i:i + 50]}
|
| 122 |
yield done_event
|
| 123 |
|
| 124 |
+
# Write the row BEFORE the stream so the FE's GET on `done` can't race a 404.
|
| 125 |
+
await _save_empty_chat_trace(analysis_id, body.user_id, message_id)
|
| 126 |
return EventSourceResponse(stream_cached())
|
| 127 |
|
| 128 |
try:
|
|
|
|
| 136 |
yield {"event": "chunk", "data": direct}
|
| 137 |
yield done_event
|
| 138 |
|
| 139 |
+
# Write the row BEFORE the stream so the FE's GET on `done` can't race a 404.
|
| 140 |
+
await _save_empty_chat_trace(analysis_id, body.user_id, message_id)
|
| 141 |
return EventSourceResponse(stream_direct())
|
| 142 |
|
| 143 |
history = await load_history(db, analysis_id, limit=10)
|
|
|
|
| 149 |
sources: list[dict[str, Any]] = []
|
| 150 |
effective_intent: str | None = None
|
| 151 |
async for event in handler.handle(
|
| 152 |
+
body.message, body.user_id, history,
|
| 153 |
+
analysis_id=analysis_id, message_id=message_id,
|
| 154 |
):
|
| 155 |
if event["event"] == "intent":
|
| 156 |
# consumed internally (not forwarded); gates caching below.
|
src/db/postgres/models.py
CHANGED
|
@@ -278,3 +278,31 @@ class AnalysesMessageRow(Base):
|
|
| 278 |
role = Column(String, nullable=False) # user | ai
|
| 279 |
content = Column(Text, nullable=False)
|
| 280 |
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
role = Column(String, nullable=False) # user | ai
|
| 279 |
content = Column(Text, nullable=False)
|
| 280 |
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class MessageTraceabilityRow(Base):
|
| 284 |
+
"""One row per assistant turn — user-facing provenance (KM-691).
|
| 285 |
+
|
| 286 |
+
`data` holds the full Pydantic `TraceabilityPayload`
|
| 287 |
+
(src\\traceability\\schemas.py:TraceabilityPayload) serialized via
|
| 288 |
+
`model_dump(mode="json", by_alias=True)`; the read path rehydrates with
|
| 289 |
+
`TraceabilityPayload.model_validate(...)`. One row per assistant `message_id`
|
| 290 |
+
(the Python-minted turn id "msg_<12hex>"), written before the `done` SSE event
|
| 291 |
+
and served by `GET /api/v1/traceability`.
|
| 292 |
+
|
| 293 |
+
OWNERSHIP / HANDOFF (KM-691): **Python-owned for now**, the same pattern as
|
| 294 |
+
`report_inputs` (ReportInputRow). Post-cutover `init_db` no longer runs
|
| 295 |
+
`create_all`, so the table is created by a one-time manual DDL run against
|
| 296 |
+
dedorch (Rifqi, 2026-07-06); the finalized schema goes to Harry so the dedorch
|
| 297 |
+
migration creates it later (`message_id`/`analysis_id` gain the FK to
|
| 298 |
+
`analyses(id)` there). Distinct from Langfuse observability — this is unmasked,
|
| 299 |
+
user-facing provenance, not engineering telemetry.
|
| 300 |
+
"""
|
| 301 |
+
__tablename__ = "message_traceability"
|
| 302 |
+
|
| 303 |
+
message_id = Column(String, primary_key=True) # Python-minted turn id ("msg_<12hex>")
|
| 304 |
+
analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True) # analysis session id
|
| 305 |
+
user_id = Column(String, nullable=False)
|
| 306 |
+
intent = Column(String, nullable=False)
|
| 307 |
+
data = Column(JSONB, nullable=False) # full TraceabilityPayload (source of truth)
|
| 308 |
+
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
src/query/executor/base.py
CHANGED
|
@@ -20,6 +20,9 @@ class QueryResult:
|
|
| 20 |
table_id: str = ""
|
| 21 |
table_name: str = ""
|
| 22 |
source_name: str = ""
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
class BaseExecutor(ABC):
|
|
|
|
| 20 |
table_id: str = ""
|
| 21 |
table_name: str = ""
|
| 22 |
source_name: str = ""
|
| 23 |
+
# The executed query string (compiled SQL for db, rendered pandas chain for
|
| 24 |
+
# tabular), surfaced to traceability. None on error/unset paths. KM-691.
|
| 25 |
+
query: str | None = None
|
| 26 |
|
| 27 |
|
| 28 |
class BaseExecutor(ABC):
|
src/query/executor/db.py
CHANGED
|
@@ -114,6 +114,7 @@ class DbExecutor(BaseExecutor):
|
|
| 114 |
table_id=ir.table_id,
|
| 115 |
table_name=table_name,
|
| 116 |
source_name=source_name,
|
|
|
|
| 117 |
)
|
| 118 |
|
| 119 |
except Exception as e:
|
|
|
|
| 114 |
table_id=ir.table_id,
|
| 115 |
table_name=table_name,
|
| 116 |
source_name=source_name,
|
| 117 |
+
query=compiled.sql, # executed SQL, for traceability (KM-691)
|
| 118 |
)
|
| 119 |
|
| 120 |
except Exception as e:
|
src/query/executor/tabular.py
CHANGED
|
@@ -83,7 +83,8 @@ class TabularExecutor(BaseExecutor):
|
|
| 83 |
)
|
| 84 |
|
| 85 |
compiled = self._compiler.compile(ir)
|
| 86 |
-
|
|
|
|
| 87 |
blob_name = _resolve_blob_name(source, table)
|
| 88 |
blob_bytes = await self._fetch_blob(blob_name)
|
| 89 |
|
|
@@ -113,6 +114,7 @@ class TabularExecutor(BaseExecutor):
|
|
| 113 |
table_id=ir.table_id,
|
| 114 |
table_name=table_name,
|
| 115 |
source_name=source_name,
|
|
|
|
| 116 |
)
|
| 117 |
|
| 118 |
except Exception as e:
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
compiled = self._compiler.compile(ir)
|
| 86 |
+
rendered_query = _render_query(ir, {c.column_id: c for c in table.columns})
|
| 87 |
+
logger.info("pandas query", query=rendered_query)
|
| 88 |
blob_name = _resolve_blob_name(source, table)
|
| 89 |
blob_bytes = await self._fetch_blob(blob_name)
|
| 90 |
|
|
|
|
| 114 |
table_id=ir.table_id,
|
| 115 |
table_name=table_name,
|
| 116 |
source_name=source_name,
|
| 117 |
+
query=rendered_query, # rendered pandas chain, for traceability (KM-691)
|
| 118 |
)
|
| 119 |
|
| 120 |
except Exception as e:
|
src/tools/data_access.py
CHANGED
|
@@ -277,6 +277,8 @@ class DataAccessToolInvoker:
|
|
| 277 |
"row_count": result.row_count,
|
| 278 |
"truncated": result.truncated,
|
| 279 |
"elapsed_ms": result.elapsed_ms,
|
|
|
|
|
|
|
| 280 |
},
|
| 281 |
)
|
| 282 |
|
|
|
|
| 277 |
"row_count": result.row_count,
|
| 278 |
"truncated": result.truncated,
|
| 279 |
"elapsed_ms": result.elapsed_ms,
|
| 280 |
+
# Executed query for traceability (KM-691); None if unavailable.
|
| 281 |
+
"query": result.query,
|
| 282 |
},
|
| 283 |
)
|
| 284 |
|
src/traceability/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Traceability (KM-691): user-facing per-turn provenance.
|
| 2 |
+
|
| 3 |
+
Captured by a per-request `TraceabilityScratchpad` inside `ChatHandler`, persisted
|
| 4 |
+
through a `TraceabilityStore` before the `done` SSE event, and served by
|
| 5 |
+
`GET /api/v1/traceability`. Kept separate from Langfuse observability
|
| 6 |
+
(`src/observability/langfuse/`), which is engineering-only and PII-masked.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .schemas import (
|
| 10 |
+
PlanningInfo,
|
| 11 |
+
PlanStep,
|
| 12 |
+
ToolCallInfo,
|
| 13 |
+
TraceabilityPayload,
|
| 14 |
+
)
|
| 15 |
+
from .scratchpad import TraceabilityScratchpad, TraceabilityToolInvoker
|
| 16 |
+
from .store import (
|
| 17 |
+
NullTraceabilityStore,
|
| 18 |
+
PostgresTraceabilityStore,
|
| 19 |
+
TraceabilityStore,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"NullTraceabilityStore",
|
| 24 |
+
"PlanStep",
|
| 25 |
+
"PlanningInfo",
|
| 26 |
+
"PostgresTraceabilityStore",
|
| 27 |
+
"ToolCallInfo",
|
| 28 |
+
"TraceabilityPayload",
|
| 29 |
+
"TraceabilityScratchpad",
|
| 30 |
+
"TraceabilityStore",
|
| 31 |
+
"TraceabilityToolInvoker",
|
| 32 |
+
]
|
src/traceability/schemas.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Traceability payload schemas (KM-691).
|
| 2 |
+
|
| 3 |
+
User-facing provenance for one assistant turn: what the AI planned, which tools it
|
| 4 |
+
called (with real inputs + outputs), and which data sources it read (with the
|
| 5 |
+
executed query). One `TraceabilityPayload` is built per assistant `message_id`,
|
| 6 |
+
stored as a JSONB row, and served by `GET /api/v1/traceability`.
|
| 7 |
+
|
| 8 |
+
Distinct from Langfuse *observability* (`src/observability/langfuse/`): that is
|
| 9 |
+
engineering-only, PII-masked (arg keys + row counts). Traceability shows the user
|
| 10 |
+
their own data's provenance — real args, output previews, executed SQL — so it is
|
| 11 |
+
NOT masked. Truncation caps (in `scratchpad.py`) bound the payload size instead.
|
| 12 |
+
|
| 13 |
+
`thinking` is always `null` in v1 (our agents are plain chat completions with no
|
| 14 |
+
native reasoning output; synthesizing it post-hoc would be unfaithful). The field
|
| 15 |
+
stays in the payload so adding it later is contract-compatible.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from typing import Any, Literal
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PlanStep(BaseModel):
|
| 27 |
+
"""One CRISP-DM step the Planner ran (derived from an AnalysisRecord task)."""
|
| 28 |
+
|
| 29 |
+
step: int
|
| 30 |
+
stage: str
|
| 31 |
+
objective: str
|
| 32 |
+
status: str
|
| 33 |
+
tools_used: list[str] = Field(default_factory=list)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PlanningInfo(BaseModel):
|
| 37 |
+
"""Planner output for a slow-path turn; `null` on every other turn type."""
|
| 38 |
+
|
| 39 |
+
goal_restated: str
|
| 40 |
+
assumptions: list[str] = Field(default_factory=list)
|
| 41 |
+
steps: list[PlanStep] = Field(default_factory=list)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ToolCallInfo(BaseModel):
|
| 45 |
+
"""One tool invocation with its real input + output (both truncation-capped).
|
| 46 |
+
|
| 47 |
+
The field is spelled `input` in the wire contract (the FE reads it), which
|
| 48 |
+
shadows the `input` builtin — hence the alias + `populate_by_name` so callers
|
| 49 |
+
can pass `input=` on construction and `model_dump(by_alias=True)` emits `input`.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 53 |
+
|
| 54 |
+
order: int
|
| 55 |
+
task_id: str | None = None
|
| 56 |
+
name: str
|
| 57 |
+
input_: dict[str, Any] = Field(default_factory=dict, alias="input")
|
| 58 |
+
output: dict[str, Any] = Field(default_factory=dict)
|
| 59 |
+
status: Literal["success", "error"] = "success"
|
| 60 |
+
error: str | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TraceabilityPayload(BaseModel):
|
| 64 |
+
"""The full provenance record for one assistant `message_id`."""
|
| 65 |
+
|
| 66 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 67 |
+
|
| 68 |
+
analysis_id: str
|
| 69 |
+
message_id: str
|
| 70 |
+
user_id: str # ownership column on the row; the FE may ignore it
|
| 71 |
+
intent: str
|
| 72 |
+
generated_at: datetime
|
| 73 |
+
planning: PlanningInfo | None = None
|
| 74 |
+
thinking: str | None = None
|
| 75 |
+
tool_calls: list[ToolCallInfo] = Field(default_factory=list)
|
| 76 |
+
sources: list[dict[str, Any]] = Field(default_factory=list)
|
src/traceability/scratchpad.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-request traceability accumulator + tool-invoker wrapper (KM-691).
|
| 2 |
+
|
| 3 |
+
`TraceabilityScratchpad` is a mutable, per-request blackboard that `ChatHandler`
|
| 4 |
+
fills while answering a turn, then `build()`s into a `TraceabilityPayload` right
|
| 5 |
+
before the `done` SSE event. `TraceabilityToolInvoker` wraps the real tool invoker
|
| 6 |
+
so every tool call on the slow path / check branch records its full I/O into the
|
| 7 |
+
scratchpad (mirrors `TracingToolInvoker` in `src/observability/langfuse/tracing.py`,
|
| 8 |
+
whose name is taken — this one records real I/O, not masked metadata).
|
| 9 |
+
|
| 10 |
+
Everything here is best-effort: recording must never break the user's answer.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from src.middlewares.logging import get_logger
|
| 18 |
+
|
| 19 |
+
from .schemas import PlanningInfo, PlanStep, ToolCallInfo, TraceabilityPayload
|
| 20 |
+
|
| 21 |
+
logger = get_logger("traceability")
|
| 22 |
+
|
| 23 |
+
# Truncation caps (bound the JSONB payload) — see plan §3.
|
| 24 |
+
CAP_PREVIEW_ROWS = 5
|
| 25 |
+
CAP_STR = 300
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _truncate(obj: Any) -> Any:
|
| 29 |
+
"""Recursively cap any string to CAP_STR; leave numbers/None untouched."""
|
| 30 |
+
if isinstance(obj, str):
|
| 31 |
+
return obj[:CAP_STR]
|
| 32 |
+
if isinstance(obj, dict):
|
| 33 |
+
return {k: _truncate(v) for k, v in obj.items()}
|
| 34 |
+
if isinstance(obj, list):
|
| 35 |
+
return [_truncate(v) for v in obj]
|
| 36 |
+
return obj
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _output_to_dict(output: Any) -> dict[str, Any]:
|
| 40 |
+
"""Normalize a tool result (`ToolOutput` or a synth dict) to the wire shape:
|
| 41 |
+
kind/columns/row_count/preview/value/error, all truncation-capped."""
|
| 42 |
+
if isinstance(output, dict):
|
| 43 |
+
return _truncate(output)
|
| 44 |
+
|
| 45 |
+
kind = getattr(output, "kind", None)
|
| 46 |
+
result: dict[str, Any] = {"kind": kind}
|
| 47 |
+
rows = getattr(output, "rows", None)
|
| 48 |
+
if rows is not None:
|
| 49 |
+
result["row_count"] = len(rows)
|
| 50 |
+
columns = getattr(output, "columns", None)
|
| 51 |
+
if columns is not None:
|
| 52 |
+
result["columns"] = list(columns)
|
| 53 |
+
result["preview"] = [
|
| 54 |
+
[_truncate(cell) for cell in row] for row in rows[:CAP_PREVIEW_ROWS]
|
| 55 |
+
]
|
| 56 |
+
value = getattr(output, "value", None)
|
| 57 |
+
if value is not None:
|
| 58 |
+
result["value"] = _truncate(value)
|
| 59 |
+
error = getattr(output, "error", None)
|
| 60 |
+
if error is not None:
|
| 61 |
+
result["error"] = _truncate(error)
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _meta_of(output: Any) -> dict[str, Any]:
|
| 66 |
+
"""Best-effort read of a tool result's `meta` dict (ToolOutput or plain dict)."""
|
| 67 |
+
if isinstance(output, dict):
|
| 68 |
+
meta = output.get("meta")
|
| 69 |
+
else:
|
| 70 |
+
meta = getattr(output, "meta", None)
|
| 71 |
+
return meta if isinstance(meta, dict) else {}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TraceabilityScratchpad:
|
| 75 |
+
"""Mutable per-request accumulator; `build()` freezes it into a payload."""
|
| 76 |
+
|
| 77 |
+
def __init__(self) -> None:
|
| 78 |
+
self.message_id: str | None = None # set at handler entry; None => no flush
|
| 79 |
+
self.intent: str = "chat" # default until the router classifies
|
| 80 |
+
self._planning: PlanningInfo | None = None
|
| 81 |
+
self._tool_calls: list[ToolCallInfo] = []
|
| 82 |
+
self._db_sources: list[dict[str, Any]] = []
|
| 83 |
+
self._doc_sources: list[dict[str, Any]] = []
|
| 84 |
+
self._doc_seen: set[tuple[Any, Any]] = set()
|
| 85 |
+
|
| 86 |
+
def set_intent(self, intent: str) -> None:
|
| 87 |
+
self.intent = intent
|
| 88 |
+
|
| 89 |
+
def record_tool_call(
|
| 90 |
+
self,
|
| 91 |
+
name: str,
|
| 92 |
+
args: dict[str, Any],
|
| 93 |
+
output: Any,
|
| 94 |
+
task_id: str | None = None,
|
| 95 |
+
) -> None:
|
| 96 |
+
"""Append one tool call (input + normalized output). For `retrieve_data`,
|
| 97 |
+
also derive a database source from the args + executed query in `meta`."""
|
| 98 |
+
out_dict = _output_to_dict(output)
|
| 99 |
+
status = "error" if out_dict.get("kind") == "error" else "success"
|
| 100 |
+
self._tool_calls.append(
|
| 101 |
+
ToolCallInfo(
|
| 102 |
+
order=len(self._tool_calls) + 1,
|
| 103 |
+
task_id=task_id,
|
| 104 |
+
name=name,
|
| 105 |
+
input=_truncate(dict(args)),
|
| 106 |
+
output=out_dict,
|
| 107 |
+
status=status,
|
| 108 |
+
error=out_dict.get("error"),
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
if name == "retrieve_data":
|
| 112 |
+
self._record_db_source(output)
|
| 113 |
+
|
| 114 |
+
def _record_db_source(self, output: Any) -> None:
|
| 115 |
+
# retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
|
| 116 |
+
# live on the tool OUTPUT meta (see tools/data_access.py::_retrieve_data).
|
| 117 |
+
meta = _meta_of(output)
|
| 118 |
+
query = meta.get("query")
|
| 119 |
+
table = meta.get("table_name") or meta.get("table_id")
|
| 120 |
+
self._db_sources.append({
|
| 121 |
+
"type": "database",
|
| 122 |
+
"source_id": meta.get("source_id"),
|
| 123 |
+
"name": table,
|
| 124 |
+
"query": _truncate(query) if isinstance(query, str) else None,
|
| 125 |
+
"detail": {"table": table, "row_count": meta.get("row_count")},
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
def set_planning_from_record(self, record: Any) -> None:
|
| 129 |
+
"""Map an `AnalysisRecord` (goal_restated + tasks_run) to `PlanningInfo`."""
|
| 130 |
+
try:
|
| 131 |
+
steps = [
|
| 132 |
+
PlanStep(
|
| 133 |
+
step=i + 1,
|
| 134 |
+
stage=str(getattr(task, "stage", "")),
|
| 135 |
+
objective=getattr(task, "objective", ""),
|
| 136 |
+
status=str(getattr(task, "status", "")),
|
| 137 |
+
tools_used=list(getattr(task, "tools_used", []) or []),
|
| 138 |
+
)
|
| 139 |
+
for i, task in enumerate(getattr(record, "tasks_run", []) or [])
|
| 140 |
+
]
|
| 141 |
+
self._planning = PlanningInfo(
|
| 142 |
+
goal_restated=getattr(record, "goal_restated", "") or "",
|
| 143 |
+
assumptions=[], # AnalysisRecord carries no assumptions field (honest: empty)
|
| 144 |
+
steps=steps,
|
| 145 |
+
)
|
| 146 |
+
except Exception as exc: # never break the answer on a mapping slip
|
| 147 |
+
logger.warning("traceability planning mapping failed", error=str(exc))
|
| 148 |
+
|
| 149 |
+
def add_document_sources(self, raw_chunks: Any, query: str) -> None:
|
| 150 |
+
"""Dedupe retrieved chunks by (document_id, page_label) into document
|
| 151 |
+
sources (mirrors `chat_handler._build_sources`), stamped with the query."""
|
| 152 |
+
for item in raw_chunks or []:
|
| 153 |
+
if hasattr(item, "metadata"):
|
| 154 |
+
data = item.metadata.get("data", {})
|
| 155 |
+
elif isinstance(item, dict):
|
| 156 |
+
data = item
|
| 157 |
+
else:
|
| 158 |
+
continue
|
| 159 |
+
key = (data.get("document_id"), data.get("page_label"))
|
| 160 |
+
if key == (None, None) or key in self._doc_seen:
|
| 161 |
+
continue
|
| 162 |
+
self._doc_seen.add(key)
|
| 163 |
+
source: dict[str, Any] = {
|
| 164 |
+
"type": "document",
|
| 165 |
+
"document_id": data.get("document_id"),
|
| 166 |
+
"filename": data.get("filename", "Unknown"),
|
| 167 |
+
"page_label": data.get("page_label"),
|
| 168 |
+
"query": _truncate(query),
|
| 169 |
+
}
|
| 170 |
+
snippet = data.get("snippet") or data.get("content") or data.get("text")
|
| 171 |
+
if isinstance(snippet, str):
|
| 172 |
+
source["snippet"] = snippet[:CAP_STR]
|
| 173 |
+
score = data.get("score")
|
| 174 |
+
if score is not None:
|
| 175 |
+
source["score"] = score
|
| 176 |
+
self._doc_sources.append(source)
|
| 177 |
+
|
| 178 |
+
def build(self, analysis_id: str, user_id: str, message_id: str) -> TraceabilityPayload:
|
| 179 |
+
"""Freeze the accumulated state into a `TraceabilityPayload`."""
|
| 180 |
+
from datetime import UTC, datetime
|
| 181 |
+
|
| 182 |
+
return TraceabilityPayload(
|
| 183 |
+
analysis_id=analysis_id,
|
| 184 |
+
message_id=message_id,
|
| 185 |
+
user_id=user_id,
|
| 186 |
+
intent=self.intent,
|
| 187 |
+
generated_at=datetime.now(UTC),
|
| 188 |
+
planning=self._planning,
|
| 189 |
+
thinking=None,
|
| 190 |
+
tool_calls=list(self._tool_calls),
|
| 191 |
+
sources=self._doc_sources + self._db_sources,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class TraceabilityToolInvoker:
|
| 196 |
+
"""Wraps a ToolInvoker to record each call's full I/O into a scratchpad.
|
| 197 |
+
|
| 198 |
+
Implements the ToolInvoker protocol (`async invoke(tool_name, args)`). Recording
|
| 199 |
+
is never-throw so a trace slip can't break the tool run. Distinct from
|
| 200 |
+
`TracingToolInvoker` (Langfuse, masked-metadata-only) — that name is taken.
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
def __init__(self, inner: Any, pad: TraceabilityScratchpad) -> None:
|
| 204 |
+
self._inner = inner
|
| 205 |
+
self._pad = pad
|
| 206 |
+
|
| 207 |
+
async def invoke(self, tool_name: str, args: dict[str, Any]) -> Any:
|
| 208 |
+
out = await self._inner.invoke(tool_name, args)
|
| 209 |
+
try:
|
| 210 |
+
self._pad.record_tool_call(tool_name, args, out)
|
| 211 |
+
except Exception as exc: # never break the tool run
|
| 212 |
+
logger.warning("traceability tool record failed", tool=tool_name, error=str(exc))
|
| 213 |
+
return out
|
src/traceability/store.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TraceabilityStore — the seam the chat pipeline persists provenance through (KM-691).
|
| 2 |
+
|
| 3 |
+
`ChatHandler` (and the v2 chat endpoint's greeting/cache branches) flush one
|
| 4 |
+
`TraceabilityPayload` per assistant turn through this seam, right before the `done`
|
| 5 |
+
SSE event; `GET /api/v1/traceability` reads it back by (analysis_id, message_id).
|
| 6 |
+
|
| 7 |
+
- `NullTraceabilityStore` logs and stores nothing (tests / disabled persistence).
|
| 8 |
+
- `PostgresTraceabilityStore` writes one `message_traceability` row per turn
|
| 9 |
+
(dedorch, `AsyncSessionLocal`), mirroring `PostgresReportInputStore`.
|
| 10 |
+
|
| 11 |
+
`save` must NEVER raise on the caller's path — a persistence failure must not break
|
| 12 |
+
the user's answer. `get` is the endpoint read and returns `None` on a miss (→ 404).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Protocol, runtime_checkable
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import select
|
| 20 |
+
from sqlalchemy.dialects.postgresql import insert
|
| 21 |
+
|
| 22 |
+
from src.db.postgres.connection import AsyncSessionLocal
|
| 23 |
+
from src.db.postgres.models import MessageTraceabilityRow
|
| 24 |
+
from src.middlewares.logging import get_logger
|
| 25 |
+
|
| 26 |
+
from .schemas import TraceabilityPayload
|
| 27 |
+
|
| 28 |
+
logger = get_logger("traceability_store")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@runtime_checkable
|
| 32 |
+
class TraceabilityStore(Protocol):
|
| 33 |
+
"""Persist + read one provenance record per assistant `message_id`.
|
| 34 |
+
|
| 35 |
+
`save` must never raise on the caller's path. `get` returns the payload for one
|
| 36 |
+
turn, or `None` if none exists yet (the turn is still running or the id is unknown).
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
async def save(self, payload: TraceabilityPayload) -> None: ...
|
| 40 |
+
|
| 41 |
+
async def get(
|
| 42 |
+
self, analysis_id: str, message_id: str
|
| 43 |
+
) -> TraceabilityPayload | None: ...
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class NullTraceabilityStore:
|
| 47 |
+
"""No-op store: logs the payload, persists nothing. Reads return `None`."""
|
| 48 |
+
|
| 49 |
+
async def save(self, payload: TraceabilityPayload) -> None:
|
| 50 |
+
logger.info(
|
| 51 |
+
"traceability produced (not persisted — NullTraceabilityStore)",
|
| 52 |
+
message_id=payload.message_id,
|
| 53 |
+
intent=payload.intent,
|
| 54 |
+
n_tool_calls=len(payload.tool_calls),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
async def get(
|
| 58 |
+
self, analysis_id: str, message_id: str
|
| 59 |
+
) -> TraceabilityPayload | None:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class PostgresTraceabilityStore:
|
| 64 |
+
"""Writes/reads `message_traceability` jsonb rows. Upsert on `message_id`."""
|
| 65 |
+
|
| 66 |
+
async def save(self, payload: TraceabilityPayload) -> None:
|
| 67 |
+
try:
|
| 68 |
+
data = payload.model_dump(mode="json", by_alias=True)
|
| 69 |
+
async with AsyncSessionLocal() as session:
|
| 70 |
+
stmt = insert(MessageTraceabilityRow).values(
|
| 71 |
+
message_id=payload.message_id,
|
| 72 |
+
analysis_id=payload.analysis_id,
|
| 73 |
+
user_id=payload.user_id,
|
| 74 |
+
intent=payload.intent,
|
| 75 |
+
data=data,
|
| 76 |
+
)
|
| 77 |
+
# Idempotent: a re-flushed turn overwrites its own row.
|
| 78 |
+
stmt = stmt.on_conflict_do_update(
|
| 79 |
+
index_elements=[MessageTraceabilityRow.message_id],
|
| 80 |
+
set_={"data": stmt.excluded.data, "intent": stmt.excluded.intent},
|
| 81 |
+
)
|
| 82 |
+
await session.execute(stmt)
|
| 83 |
+
await session.commit()
|
| 84 |
+
logger.info(
|
| 85 |
+
"traceability persisted",
|
| 86 |
+
message_id=payload.message_id,
|
| 87 |
+
analysis_id=payload.analysis_id,
|
| 88 |
+
intent=payload.intent,
|
| 89 |
+
)
|
| 90 |
+
except Exception as exc: # never break the user's answer
|
| 91 |
+
logger.error(
|
| 92 |
+
"traceability persist failed",
|
| 93 |
+
message_id=payload.message_id,
|
| 94 |
+
error=str(exc),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
async def get(
|
| 98 |
+
self, analysis_id: str, message_id: str
|
| 99 |
+
) -> TraceabilityPayload | None:
|
| 100 |
+
async with AsyncSessionLocal() as session:
|
| 101 |
+
result = await session.execute(
|
| 102 |
+
select(MessageTraceabilityRow.data).where(
|
| 103 |
+
MessageTraceabilityRow.message_id == message_id,
|
| 104 |
+
MessageTraceabilityRow.analysis_id == analysis_id,
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
row = result.scalar_one_or_none()
|
| 108 |
+
if row is None:
|
| 109 |
+
return None
|
| 110 |
+
return TraceabilityPayload.model_validate(row)
|