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 observability 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,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 observability/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/observability` 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,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/observability` | Retrieve provenance for one assistant answer. |
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 observability.
43
 
44
  ### Server-Sent Events
45
 
@@ -51,13 +51,13 @@ Common event types:
51
 
52
  | Event | Data | Meaning |
53
  | --- | --- | --- |
54
- | `sources` | JSON array | Sources available early in the stream. May be empty. |
55
  | `status` | text | Optional progress update for slower paths. |
56
  | `chunk` | text | Answer text fragment. Concatenate chunks in order. |
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/observability` after the stream is done.
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 observability correlation. If omitted, Python returns one in `done`. |
86
  | `message` | Yes | User message text. |
87
 
88
  Response: `text/event-stream`.
@@ -91,7 +91,7 @@ Example structured answer:
91
 
92
  ```text
93
  event: sources
94
- data: [{"document_id":"u_1a2b3c_orders","filename":"orders","page_label":null}]
95
 
96
  event: status
97
  data: Planning analysis...
@@ -127,7 +127,7 @@ Behavior notes:
127
  - Greeting and farewell messages may use a fast canned path.
128
  - Stateless `chat` intent may use a 1-hour Redis response cache.
129
  - The router may classify messages into intents such as `chat`, `help`, `check`, `unstructured_flow`, or `structured_flow`.
130
- - `sources` can be empty for chat/help/error paths.
131
  - `status` events are optional and should be safe for the frontend to ignore.
132
 
133
  ## Tools
@@ -357,13 +357,15 @@ Response `404`:
357
  }
358
  ```
359
 
360
- ## Observability
 
 
361
 
362
- ### `GET /api/v1/observability`
363
 
364
- Returns Responsible AI provenance for one assistant answer.
365
 
366
- The frontend should call this after the chat/help stream emits `done`, using the `message_id` from the `done` event. If the row is not ready yet, the frontend may poll until `200` or stop on `404` according to product behavior.
367
 
368
  Query params:
369
 
@@ -375,15 +377,19 @@ Query params:
375
  Example:
376
 
377
  ```text
378
- GET /api/v1/observability?analysis_id=an_42&message_id=msg_88f1
379
  ```
380
 
 
 
381
  Field rules:
382
 
383
- - `planning`: present only when the planner ran; otherwise `null`.
384
- - `thinking`: optional reasoning summary; `null` if unavailable.
385
- - `tool_calls`: every invoked tool with input, output, and status; empty for pure chat or greeting paths.
386
- - `sources`: required for retrieval flows; empty for chat/help paths that do not reference data.
 
 
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-06-30T03:21:09.114Z",
396
  "planning": {
397
  "goal_restated": "Find which regions drive revenue and why Q1 dipped.",
398
- "assumptions": ["'last quarter' = Q1 2026"],
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": "The question needs a per-region breakdown plus a cause, so I inventory the source, aggregate revenue by region, then compare quarters.",
413
  "tool_calls": [
414
  {
415
  "order": 1,
 
416
  "name": "check_data",
417
  "input": { "source_hint": "structured" },
418
- "output": { "kind": "table", "summary": "1 source, 1 table, 48,213 rows" },
419
- "status": "success"
 
 
 
 
 
 
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
- "tables": ["orders"],
447
- "row_count": 48213
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-06-30T03:40:02.001Z",
462
  "planning": null,
463
  "thinking": null,
464
  "tool_calls": [
465
  {
466
  "order": 1,
 
467
  "name": "retrieve_knowledge",
468
- "input": {
469
- "query": "technology stack used in this project",
470
- "top_k": 4
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 simple chat or greeting:
494
 
495
  ```json
496
  {
497
  "analysis_id": "an_42",
498
  "message_id": "msg_12",
 
499
  "intent": "chat",
500
- "generated_at": "2026-06-30T03:05:00.000Z",
501
  "planning": null,
502
  "thinking": null,
503
  "tool_calls": [],
@@ -509,13 +524,13 @@ Response `404`:
509
 
510
  ```json
511
  {
512
- "detail": "No observability for message 'msg_88f1' yet."
513
  }
514
  ```
515
 
516
  Frontend rendering guidance:
517
 
518
- - Render observability separately from the streamed answer.
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
 
 
51
 
52
  | Event | Data | Meaning |
53
  | --- | --- | --- |
54
+ | `sources` | JSON array | Always `[]` sources moved to `GET /api/v1/traceability` (KM-691). Event kept for backward-compat; read `sources[]` from the traceability call. |
55
  | `status` | text | Optional progress update for slower paths. |
56
  | `chunk` | text | Answer text fragment. Concatenate chunks in order. |
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`.
 
91
 
92
  ```text
93
  event: sources
94
+ data: []
95
 
96
  event: status
97
  data: Planning analysis...
 
127
  - Greeting and farewell messages may use a fast canned path.
128
  - Stateless `chat` intent may use a 1-hour Redis response cache.
129
  - The router may classify messages into intents such as `chat`, `help`, `check`, `unstructured_flow`, or `structured_flow`.
130
+ - `sources` in the stream is **always `[]`** (KM-691) — read the real `sources[]` from `GET /api/v1/traceability` after `done`.
131
  - `status` events are optional and should be safe for the frontend to ignore.
132
 
133
  ## Tools
 
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** | Observability **scratchpad** (decorator) accumulating in the chat agent | Rifqi + Sofhia | | Capture planning / tool I/O / sources during the run; flush one record on `done`. |
34
- | **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi | | **Rec:** dedicated provenance store = 1 JSONB row per message (logical 3 sections); keep Langfuse for engineering. |
35
- | **3 — tools + obs** | Build `GET /api/v1/observability` (one merged response) | Rifqi | | Intent-based source rules (greeting/help = none; retrieve = required). Richness path-dependent (full planning only on slow path). |
36
- | **3 — tools + obs** | Keep stream **text-only**; observability is a separate parallel call | Rifqi | | Per mentor don't slow the stream. |
37
- | **3 — tools + obs** | Resolve `message_id` correlation (stream ↔ observability) 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 full **Phase 3 observability build** — scratchpad + `GET /api/v1/observability`
45
- (shape sketched in the contract §7) — is slated for a **later PR**, then **Phase 4** (business
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/observability`** (Responsible-AI provenance per answer, backed by a provenance store — shape TBD). ** pending.**
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`, observability). 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,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 observability detail (planning / tool I/O / sources) moves out of
82
- > the stream to a separate `GET /api/v1/observability` call. See the Direction-update banner.
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/observability`; 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
 
 
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, reply_language
 
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,13 +477,19 @@ class ChatHandler:
442
  return
443
  elif intent == "check":
444
  try:
445
- invoker = self._get_check_invoker(user_id)
446
- text = await run_check(rewritten, invoker)
 
 
 
 
 
447
  except Exception as e:
448
  logger.error("check route failed", user_id=user_id, error=str(e))
449
  yield {"event": "error", "data": f"Lookup failed: {e}"}
450
  return
451
  yield {"event": "chunk", "data": text}
 
452
  yield {"event": "done", "data": ""}
453
  return
454
  # problem_statement dispatch removed 2026-06-24 (skill unwired; intent no longer
@@ -500,19 +541,17 @@ class ChatHandler:
500
  yield {"event": "error", "data": f"Help generation failed: {e}"}
501
  return
502
  tracer.end()
 
503
  yield {"event": "done", "data": ""}
504
  return
505
  # else: chat path — no context
506
 
507
  # ---- 2b. Emit sources ---------------------------------------
508
- sources = _build_sources(intent, user_id, query_result, raw_chunks)
509
- logger.info(
510
- "built sources",
511
- intent=intent,
512
- sources_count=len(sources),
513
- raw_chunks_count=len(raw_chunks) if raw_chunks else 0,
514
- )
515
- yield {"event": "sources", "data": json.dumps(sources)}
516
 
517
  # ---- 3. Stream answer ----------------------------------------
518
  # masked: the answer call sees real query rows / doc chunks (possible PII).
@@ -533,6 +572,8 @@ class ChatHandler:
533
  return
534
 
535
  tracer.end()
 
 
536
  yield {"event": "done", "data": ""}
537
 
538
  # ------------------------------------------------------------------
@@ -550,7 +591,11 @@ class ChatHandler:
550
  return RequestTracer.start(user_id=user_id, question=question)
551
 
552
  def _get_slow_path_coordinator(
553
- self, user_id: str, tracer: Any = None, catalog_reader: CatalogReader | None = None
 
 
 
 
554
  ) -> SlowPathCoordinator:
555
  """Build the per-request slow-path coordinator (composition root).
556
 
@@ -579,6 +624,10 @@ class ChatHandler:
579
  from ..observability.langfuse.tracing import TracingToolInvoker
580
 
581
  invoker = TracingToolInvoker(invoker, tracer)
 
 
 
 
582
  registry = default_registry()
583
  return SlowPathCoordinator(
584
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
@@ -591,6 +640,30 @@ class ChatHandler:
591
  self._analysis_store = PostgresReportInputStore()
592
  return self._analysis_store
593
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  async def _run_slow_path(
595
  self,
596
  user_id: str,
@@ -600,6 +673,7 @@ class ChatHandler:
600
  catalog_reader: CatalogReader | None = None,
601
  analysis_id: str | None = None,
602
  reply_language: str | None = None,
 
603
  ) -> AsyncIterator[dict[str, Any]]:
604
  """Run the slow path and stream its assembled answer as SSE events.
605
 
@@ -617,7 +691,7 @@ class ChatHandler:
617
 
618
  tracer = NullTracer()
619
 
620
- coordinator = self._get_slow_path_coordinator(user_id, tracer, catalog_reader)
621
  context = await get_business_context(user_id)
622
 
623
  # DB3: warm the user's DB connection in parallel with planning so the
@@ -673,7 +747,9 @@ class ChatHandler:
673
  yield {"event": "error", "data": f"Analysis failed: {e}"}
674
  return
675
 
676
- yield {"event": "sources", "data": json.dumps([])} # TODO: derive from record
 
 
677
  yield {"event": "chunk", "data": result.chat_answer}
678
  try:
679
  # Stamp identity from the request scope: owner + the shared session id
@@ -684,9 +760,15 @@ class ChatHandler:
684
  update={"user_id": user_id, "analysis_id": analysis_id}
685
  )
686
  await self._get_analysis_store().save(record)
 
 
 
 
687
  except Exception as e: # persistence must never break the user's answer
688
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
689
  tracer.end() # output omitted (chat_answer may contain PII on Cloud)
 
 
690
  yield {"event": "done", "data": ""}
691
 
692
 
@@ -715,54 +797,6 @@ class _ScopedCatalogReader:
715
  return catalog.model_copy(update={"sources": scoped or catalog.sources})
716
 
717
 
718
- def _build_sources(
719
- intent: str,
720
- user_id: str,
721
- query_result: Any,
722
- raw_chunks: Any,
723
- ) -> list[dict[str, Any]]:
724
- """Build the sources payload for the SSE `sources` event.
725
-
726
- - structured_flow: one entry per executed table (table_name only).
727
- - unstructured_flow: deduped by (document_id, page_label), Phase 1 shape.
728
- - chat or error: empty list.
729
- """
730
- if intent == "structured_flow":
731
- if query_result is None or getattr(query_result, "error", None):
732
- return []
733
- table_name = getattr(query_result, "table_name", "") or ""
734
- if not table_name:
735
- return []
736
- return [{
737
- "document_id": f"{user_id}_{table_name}",
738
- "filename": table_name,
739
- "page_label": None,
740
- }]
741
-
742
- if intent == "unstructured_flow" and raw_chunks:
743
- seen: set[tuple[Any, Any]] = set()
744
- sources: list[dict[str, Any]] = []
745
- for item in raw_chunks:
746
- if isinstance(item, RetrievalResult):
747
- data = item.metadata.get("data", {})
748
- elif isinstance(item, dict):
749
- data = item
750
- else:
751
- continue
752
- key = (data.get("document_id"), data.get("page_label"))
753
- if key in seen or key == (None, None):
754
- continue
755
- seen.add(key)
756
- sources.append({
757
- "document_id": data.get("document_id"),
758
- "filename": data.get("filename", "Unknown"),
759
- "page_label": data.get("page_label", "Unknown"),
760
- })
761
- return sources
762
-
763
- return []
764
-
765
-
766
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
767
  """Convert whatever the retriever returns into list[DocumentChunk].
768
 
 
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.
485
+ reply_language = detect_reply_language(history, message=message)
486
+ text = await run_check(rewritten, invoker, reply_language)
487
  except Exception as e:
488
  logger.error("check route failed", user_id=user_id, error=str(e))
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
548
 
549
  # ---- 2b. Emit sources ---------------------------------------
550
+ # Sources moved to traceability (KM-691): the stream stays text-only. The FE
551
+ # reads sources from GET /api/v1/traceability (richer + intent-consistent; the
552
+ # document sources for this turn are captured on the pad above). The empty
553
+ # `sources` event is kept for SSE backward-compat.
554
+ yield {"event": "sources", "data": json.dumps([])}
 
 
 
555
 
556
  # ---- 3. Stream answer ----------------------------------------
557
  # masked: the answer call sees real query rows / doc chunks (possible PII).
 
572
  return
573
 
574
  tracer.end()
575
+ # chat: empty payload; unstructured_flow: synth retrieve_knowledge + doc sources.
576
+ await self._flush_trace(pad, analysis_id, user_id)
577
  yield {"event": "done", "data": ""}
578
 
579
  # ------------------------------------------------------------------
 
591
  return RequestTracer.start(user_id=user_id, question=question)
592
 
593
  def _get_slow_path_coordinator(
594
+ self,
595
+ user_id: str,
596
+ tracer: Any = None,
597
+ catalog_reader: CatalogReader | None = None,
598
+ pad: TraceabilityScratchpad | None = None,
599
  ) -> SlowPathCoordinator:
600
  """Build the per-request slow-path coordinator (composition root).
601
 
 
624
  from ..observability.langfuse.tracing import TracingToolInvoker
625
 
626
  invoker = TracingToolInvoker(invoker, tracer)
627
+ # Traceability outermost: records the SAME real I/O the tools return (both
628
+ # wrappers see it; order is immaterial). KM-691.
629
+ if pad is not None:
630
+ invoker = TraceabilityToolInvoker(invoker, pad)
631
  registry = default_registry()
632
  return SlowPathCoordinator(
633
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
 
640
  self._analysis_store = PostgresReportInputStore()
641
  return self._analysis_store
642
 
643
+ def _get_traceability_store(self) -> TraceabilityStore:
644
+ if self._traceability_store is None:
645
+ from ..traceability import PostgresTraceabilityStore
646
+
647
+ self._traceability_store = PostgresTraceabilityStore()
648
+ return self._traceability_store
649
+
650
+ async def _flush_trace(
651
+ self, pad: TraceabilityScratchpad, analysis_id: str | None, user_id: str
652
+ ) -> None:
653
+ """Persist the turn's traceability row right before `done` (KM-691).
654
+
655
+ No-op without a `message_id` (tests / unwired callers never flush). The
656
+ store save is itself never-throw, but we also guard here: a trace failure
657
+ must never break — or delay past `done` — the user's answer.
658
+ """
659
+ if pad.message_id is None:
660
+ return
661
+ try:
662
+ payload = pad.build(analysis_id or "", user_id, pad.message_id)
663
+ await self._get_traceability_store().save(payload)
664
+ except Exception as e: # noqa: BLE001 — never break the answer on a trace slip
665
+ logger.warning("traceability flush failed", error=str(e))
666
+
667
  async def _run_slow_path(
668
  self,
669
  user_id: str,
 
673
  catalog_reader: CatalogReader | None = None,
674
  analysis_id: str | None = None,
675
  reply_language: str | None = None,
676
+ pad: TraceabilityScratchpad | None = None,
677
  ) -> AsyncIterator[dict[str, Any]]:
678
  """Run the slow path and stream its assembled answer as SSE events.
679
 
 
691
 
692
  tracer = NullTracer()
693
 
694
+ coordinator = self._get_slow_path_coordinator(user_id, tracer, catalog_reader, pad)
695
  context = await get_business_context(user_id)
696
 
697
  # DB3: warm the user's DB connection in parallel with planning so the
 
747
  yield {"event": "error", "data": f"Analysis failed: {e}"}
748
  return
749
 
750
+ # Sources live in traceability now (KM-691), derived from the run's
751
+ # retrieve_data calls; the stream stays text-only.
752
+ yield {"event": "sources", "data": json.dumps([])}
753
  yield {"event": "chunk", "data": result.chat_answer}
754
  try:
755
  # Stamp identity from the request scope: owner + the shared session id
 
760
  update={"user_id": user_id, "analysis_id": analysis_id}
761
  )
762
  await self._get_analysis_store().save(record)
763
+ if pad is not None:
764
+ # Traceability planning = goal_restated + tasks_run from the record;
765
+ # tool_calls were already recorded by the wrapped invoker.
766
+ pad.set_planning_from_record(record)
767
  except Exception as e: # persistence must never break the user's answer
768
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
769
  tracer.end() # output omitted (chat_answer may contain PII on Cloud)
770
+ if pad is not None:
771
+ await self._flush_trace(pad, analysis_id, user_id)
772
  yield {"event": "done", "data": ""}
773
 
774
 
 
797
  return catalog.model_copy(update={"sources": scoped or catalog.sources})
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
801
  """Convert whatever the retriever returns into list[DocumentChunk].
802
 
src/agents/handlers/check.py CHANGED
@@ -14,23 +14,27 @@ from __future__ import annotations
14
 
15
  import asyncio
16
  import re
17
- from typing import TYPE_CHECKING
18
 
19
  from src.tools.contracts import ToolOutput
20
 
21
  if TYPE_CHECKING:
22
  from src.agents.slow_path.invoker import ToolInvoker
23
 
24
- # Cues that point at documents rather than structured data.
 
 
 
 
 
 
25
  _KNOWLEDGE_CUES = (
26
  "document",
27
  "docs",
28
  "doc ",
29
- "file",
30
  "pdf",
31
  "docx",
32
  ".txt",
33
- "uploaded",
34
  "knowledge",
35
  "dokumen",
36
  )
@@ -50,6 +54,88 @@ _DATA_CUES = (
50
  )
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def _intent(message: str) -> str:
54
  """Return 'knowledge', 'data', or 'both' (helicopter view) from keyword cues."""
55
  lowered = message.lower()
@@ -62,22 +148,75 @@ def _intent(message: str) -> str:
62
  return "both"
63
 
64
 
65
- def render_tool_output(out: ToolOutput) -> str:
66
  """Render a `check_*` ToolOutput table into a markdown string, or '' if empty."""
67
  if out.kind == "error":
68
- return f"Sorry, I couldn't look that up: {out.error}"
69
  columns = out.columns or []
70
  rows = out.rows or []
71
  if not rows:
72
  return ""
73
- header = "| " + " | ".join(columns) + " |"
74
- separator = "| " + " | ".join("---" for _ in columns) + " |"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  body = "\n".join(
76
- "| " + " | ".join(str(cell) for cell in row) + " |" for row in rows
77
  )
78
  return f"{header}\n{separator}\n{body}"
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
82
  """All source_ids whose name appears as a whole word in the message.
83
 
@@ -106,60 +245,325 @@ def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
106
  return matched
107
 
108
 
109
- def _render_helicopter(data_out: ToolOutput, knowledge_out: ToolOutput) -> str:
 
 
110
  """Stitch structured + document inventory into one helicopter-view reply."""
 
111
  parts: list[str] = []
112
 
113
- data_table = render_tool_output(data_out)
114
- if data_table:
115
- parts.append(f"**Data terstruktur**\n{data_table}")
116
 
117
- knowledge_table = render_tool_output(knowledge_out)
118
- if knowledge_table:
119
- parts.append(f"**Dokumen**\n{knowledge_table}")
120
 
121
  if not parts:
122
- return "Nothing registered yet — I don't see any sources or documents."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  return "\n\n".join(parts)
125
 
126
 
127
- async def run_check(message: str, invoker: ToolInvoker) -> str:
128
- """Route to check_data, check_knowledge, or both (helicopter view) based on cues."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  intent = _intent(message)
130
 
131
- _no_match = "Nothing registered yet — I don't see any matching sources."
132
 
133
  if intent == "knowledge":
134
  out = await invoker.invoke("check_knowledge", {})
135
- return render_tool_output(out) or _no_match
 
 
 
136
 
137
- if intent == "data":
 
 
 
 
 
138
  inventory = await invoker.invoke("check_data", {})
139
  if inventory.kind == "error":
140
- return render_tool_output(inventory)
141
- # Drill down to the schema of each source the user named; if they named
142
- # none, return the source listing.
143
- source_ids = _matched_source_ids(message, inventory)
144
- if not source_ids:
145
- return render_tool_output(inventory) or _no_match
 
146
  schemas = await asyncio.gather(
147
- *(invoker.invoke("check_data", {"source_id": sid}) for sid in source_ids)
148
  )
149
- if len(schemas) == 1:
150
- return render_tool_output(schemas[0]) or _no_match
151
- # Multiple named sources → one labelled section per source.
152
- sections: list[str] = []
153
- for out in schemas:
154
- table = render_tool_output(out)
155
- if table:
156
- label = (out.meta or {}).get("source_name") or "source"
157
- sections.append(f"**{label}**\n{table}")
158
- return "\n\n".join(sections) or _no_match
 
159
 
160
  # broad / ambiguous → helicopter view: call both concurrently
161
  data_out, knowledge_out = await asyncio.gather(
162
  invoker.invoke("check_data", {}),
163
  invoker.invoke("check_knowledge", {}),
164
  )
165
- return _render_helicopter(data_out, knowledge_out)
 
14
 
15
  import asyncio
16
  import re
17
+ from typing import TYPE_CHECKING, Any
18
 
19
  from src.tools.contracts import ToolOutput
20
 
21
  if TYPE_CHECKING:
22
  from src.agents.slow_path.invoker import ToolInvoker
23
 
24
+ # Cues that point at documents rather than structured data. Deliberately
25
+ # document-SPECIFIC: generic words like "file" and "uploaded" are excluded
26
+ # because users say "file/data yang aku upload" about tabular datasets too, and
27
+ # forcing those to the document-only branch made "what data have I uploaded?"
28
+ # answer "nothing" for a user whose data is tabular (lives under check_data).
29
+ # A bare inventory question with no specific cue falls through to the
30
+ # helicopter view (both structured + documents) instead.
31
  _KNOWLEDGE_CUES = (
32
  "document",
33
  "docs",
34
  "doc ",
 
35
  "pdf",
36
  "docx",
37
  ".txt",
 
38
  "knowledge",
39
  "dokumen",
40
  )
 
54
  )
55
 
56
 
57
+ # Internal id columns — meaningful to the system (drill-down keys off them via
58
+ # the ToolOutput rows), but noise in a user-facing answer. Hidden from the
59
+ # rendered table; the raw ToolOutput still carries them for _matched_source_ids.
60
+ # `table_count` is kept: always 1 for a tabular file, but the real table count
61
+ # for a database, which is worth showing.
62
+ _HIDDEN_COLUMNS = frozenset({"source_id", "table_id", "column_id"})
63
+
64
+ # Friendly, localized labels for the catalog column ids the tools emit. Any
65
+ # column not listed falls back to its raw name.
66
+ _HEADER_LABELS = {
67
+ "English": {
68
+ "name": "Name", "source_type": "Type", "table_count": "Tables",
69
+ "table_name": "Table", "table_row_count": "Rows",
70
+ "column_name": "Column", "data_type": "Data type",
71
+ "nullable": "Nullable", "pii_flag": "PII",
72
+ },
73
+ "Indonesian": {
74
+ "name": "Nama", "source_type": "Jenis", "table_count": "Jumlah tabel",
75
+ "table_name": "Tabel", "table_row_count": "Jumlah baris",
76
+ "column_name": "Kolom", "data_type": "Tipe data",
77
+ "nullable": "Boleh kosong", "pii_flag": "PII",
78
+ },
79
+ }
80
+
81
+ # Friendly values for the raw `source_type` enum ("schema"/"tabular"/"unstructured").
82
+ _SOURCE_TYPE_LABELS = {
83
+ "English": {"tabular": "Tabular file", "schema": "Database", "unstructured": "Document"},
84
+ "Indonesian": {"tabular": "File tabular", "schema": "Database", "unstructured": "Dokumen"},
85
+ }
86
+
87
+
88
+ # User-facing fixed strings, localized by reply language (detect_reply_language
89
+ # returns "Indonesian"/"English"). Table headers/values are humanized via the
90
+ # maps above; only the prose the user reads is translated here.
91
+ _STRINGS = {
92
+ "English": {
93
+ "no_match": "Nothing registered yet — I don't see any matching sources.",
94
+ "none": "Nothing registered yet — I don't see any sources or documents.",
95
+ "structured": "Structured data",
96
+ "documents": "Documents",
97
+ "lookup_error": "Sorry, I couldn't look that up: {error}",
98
+ },
99
+ "Indonesian": {
100
+ "no_match": "Belum ada yang terdaftar — aku tidak menemukan sumber data yang cocok.",
101
+ "none": "Belum ada yang terdaftar — aku tidak menemukan sumber data atau dokumen.",
102
+ "structured": "Data terstruktur",
103
+ "documents": "Dokumen",
104
+ "lookup_error": "Maaf, aku tidak bisa mencarinya: {error}",
105
+ },
106
+ }
107
+
108
+
109
+ def _s(reply_language: str) -> dict[str, str]:
110
+ """Localized string bundle; defaults to English for an unknown language."""
111
+ return _STRINGS.get(reply_language, _STRINGS["English"])
112
+
113
+
114
+ # Natural-language lead-ins so a check reply reads like an answer, not a raw
115
+ # table dump. `{n}` = item count, `{s}` = English plural suffix, `{name}` = source.
116
+ _LEADS = {
117
+ "English": {
118
+ "opener": "Here's what you have:",
119
+ "structured": "You have {n} structured data source{s}:",
120
+ "documents": "You have {n} document{s}:",
121
+ "schema": "Here's what's inside {name}:",
122
+ },
123
+ "Indonesian": {
124
+ "opener": "Berikut data yang kamu punya:",
125
+ "structured": "Kamu punya {n} sumber data terstruktur:",
126
+ "documents": "Kamu punya {n} dokumen:",
127
+ "schema": "Berikut isi {name}:",
128
+ },
129
+ }
130
+
131
+
132
+ def _lead(kind: str, reply_language: str, n: int = 0, name: str = "") -> str:
133
+ """Build a localized lead-in sentence (English pluralizes on {n})."""
134
+ bundle = _LEADS.get(reply_language, _LEADS["English"])
135
+ suffix = "s" if (reply_language == "English" and n != 1) else ""
136
+ return bundle[kind].format(n=n, s=suffix, name=name)
137
+
138
+
139
  def _intent(message: str) -> str:
140
  """Return 'knowledge', 'data', or 'both' (helicopter view) from keyword cues."""
141
  lowered = message.lower()
 
148
  return "both"
149
 
150
 
151
+ def render_tool_output(out: ToolOutput, reply_language: str = "English") -> str:
152
  """Render a `check_*` ToolOutput table into a markdown string, or '' if empty."""
153
  if out.kind == "error":
154
+ return _s(reply_language)["lookup_error"].format(error=out.error)
155
  columns = out.columns or []
156
  rows = out.rows or []
157
  if not rows:
158
  return ""
159
+
160
+ # Drop internal id columns, humanize the rest of the headers, and translate
161
+ # the source_type enum — the user sees names, not UUIDs and raw enums.
162
+ keep = [i for i, c in enumerate(columns) if c not in _HIDDEN_COLUMNS] or list(
163
+ range(len(columns))
164
+ )
165
+ labels = _HEADER_LABELS.get(reply_language, _HEADER_LABELS["English"])
166
+ type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
167
+ type_idx = columns.index("source_type") if "source_type" in columns else -1
168
+
169
+ def _cell(i: int, row: list[Any]) -> str:
170
+ if i == type_idx:
171
+ return type_labels.get(str(row[i]), str(row[i]))
172
+ return str(row[i])
173
+
174
+ header = "| " + " | ".join(labels.get(columns[i], columns[i]) for i in keep) + " |"
175
+ separator = "| " + " | ".join("---" for _ in keep) + " |"
176
  body = "\n".join(
177
+ "| " + " | ".join(_cell(i, row) for i in keep) + " |" for row in rows
178
  )
179
  return f"{header}\n{separator}\n{body}"
180
 
181
 
182
+ def _render_source_list(out: ToolOutput, reply_language: str) -> str:
183
+ """Render a check_data/check_knowledge *listing* as a bullet list, not a table.
184
+
185
+ One bullet per source: `- name — Type (N tables)`. The type + table-count
186
+ annotation is only added for structured sources (file vs database); documents
187
+ are all "unstructured", so the section header already says so — just the name.
188
+ Returns '' when there are no rows. (The column-level schema drill-down still
189
+ renders as a table via `render_tool_output` — that data is genuinely tabular.)
190
+ """
191
+ if out.kind == "error":
192
+ return _s(reply_language)["lookup_error"].format(error=out.error)
193
+ columns = out.columns or []
194
+ rows = out.rows or []
195
+ if not rows:
196
+ return ""
197
+
198
+ idx = {c: i for i, c in enumerate(columns)}
199
+ type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
200
+
201
+ def _table_word(n: int) -> str:
202
+ if reply_language == "English":
203
+ return "table" if n == 1 else "tables"
204
+ return "tabel"
205
+
206
+ items: list[str] = []
207
+ for row in rows:
208
+ name = str(row[idx["name"]]) if "name" in idx else ""
209
+ st = str(row[idx["source_type"]]) if "source_type" in idx else ""
210
+ annotation = ""
211
+ if st and st != "unstructured":
212
+ annotation = type_labels.get(st, st)
213
+ if "table_count" in idx:
214
+ tc = row[idx["table_count"]]
215
+ annotation += f" ({tc} {_table_word(int(tc))})"
216
+ items.append(f"- {name} — {annotation}" if annotation else f"- {name}")
217
+ return "\n".join(items)
218
+
219
+
220
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
221
  """All source_ids whose name appears as a whole word in the message.
222
 
 
245
  return matched
246
 
247
 
248
+ def _render_helicopter(
249
+ data_out: ToolOutput, knowledge_out: ToolOutput, reply_language: str = "English"
250
+ ) -> str:
251
  """Stitch structured + document inventory into one helicopter-view reply."""
252
+ strings = _s(reply_language)
253
  parts: list[str] = []
254
 
255
+ data_list = _render_source_list(data_out, reply_language)
256
+ if data_list:
257
+ parts.append(f"{strings['structured']}:\n{data_list}")
258
 
259
+ knowledge_list = _render_source_list(knowledge_out, reply_language)
260
+ if knowledge_list:
261
+ parts.append(f"{strings['documents']}:\n{knowledge_list}")
262
 
263
  if not parts:
264
+ return strings["none"]
265
+
266
+ opener = _lead("opener", reply_language)
267
+ return opener + "\n\n" + "\n\n".join(parts)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Schema-detail view (columns + types)
272
+ # ---------------------------------------------------------------------------
273
+
274
+ # Cues meaning "show me the columns/types", not just "what sources exist". When
275
+ # present, the handler drills into the schema of the named source(s) — or ALL
276
+ # structured sources when none is named — instead of listing. This is what lets
277
+ # "kolom apa aja yang aku miliki dan tipenya?" resolve straight to the data
278
+ # without the user having to type a full filename.
279
+ _SCHEMA_CUES = (
280
+ "kolom", "column", "tipe data", "data type", "tipe", "dtype",
281
+ "struktur", "structure", "schema", "skema", "field",
282
+ )
283
+
284
+ # A database with more tables than this is summarised (table names + counts)
285
+ # rather than dumping every column — otherwise a big DB is a wall of text.
286
+ _DB_TABLE_THRESHOLD = 3
287
+
288
+ # Friendly, localized data-type words (normalized from the catalog's raw type).
289
+ _TYPE_WORDS = {
290
+ "English": {"string": "text", "integer": "integer", "float": "decimal",
291
+ "date": "date", "boolean": "boolean", "other": "other"},
292
+ "Indonesian": {"string": "teks", "integer": "bilangan bulat", "float": "desimal",
293
+ "date": "tanggal", "boolean": "boolean", "other": "lainnya"},
294
+ }
295
+
296
+ # Localized strings for the schema view + the per-source Ringkasan.
297
+ _SCHEMA_STR = {
298
+ "English": {
299
+ "lead": "Here are the columns and data types across your {n} source{s}:",
300
+ "summary": "Summary:",
301
+ "cat": "Categorical", "int": "Numeric (integer)",
302
+ "float": "Numeric (decimal)", "date": "Date",
303
+ "note_date": "still text, consider converting to datetime",
304
+ "note_id": "likely an identifier",
305
+ "rows_word": "rows", "table_word": "Table",
306
+ "yes": "Yes", "no": "—",
307
+ "db_tables": "{name} has {n} tables:",
308
+ "db_item": "- {table} ({cols} columns{rows})",
309
+ "db_hint": "Name a table to see its columns.",
310
+ },
311
+ "Indonesian": {
312
+ "lead": "Berikut kolom dan tipe data di {n} sumber yang kamu punya:",
313
+ "summary": "Ringkasan:",
314
+ "cat": "Kategorikal", "int": "Numerik (bilangan bulat)",
315
+ "float": "Numerik (desimal)", "date": "Tanggal",
316
+ "note_date": "masih teks, sebaiknya dikonversi ke datetime",
317
+ "note_id": "kemungkinan identifier",
318
+ "rows_word": "baris", "table_word": "Tabel",
319
+ "yes": "Ya", "no": "—",
320
+ "db_tables": "{name} punya {n} tabel:",
321
+ "db_item": "- {table} ({cols} kolom{rows})",
322
+ "db_hint": "Sebut nama tabelnya untuk lihat kolomnya.",
323
+ },
324
+ }
325
+
326
+
327
+ def _sc(reply_language: str) -> dict[str, str]:
328
+ """Localized schema/summary string bundle; defaults to English."""
329
+ return _SCHEMA_STR.get(reply_language, _SCHEMA_STR["English"])
330
+
331
+
332
+ def _wants_schema(message: str) -> bool:
333
+ """True when the message asks about columns/types (schema detail)."""
334
+ lowered = message.lower()
335
+ return any(cue in lowered for cue in _SCHEMA_CUES)
336
 
337
+
338
+ def _type_category(raw: str) -> str:
339
+ """Normalize a catalog `data_type` string into a coarse category."""
340
+ r = (raw or "").lower()
341
+ if "bool" in r:
342
+ return "boolean"
343
+ if any(k in r for k in ("float", "double", "decimal", "numeric", "real")):
344
+ return "float"
345
+ if "int" in r:
346
+ return "integer"
347
+ if any(k in r for k in ("date", "time", "timestamp")):
348
+ return "date"
349
+ if any(k in r for k in ("char", "text", "string", "object", "varchar", "str")):
350
+ return "string"
351
+ return "other"
352
+
353
+
354
+ def _looks_date(name: str) -> bool:
355
+ return any(k in name for k in ("date", "tanggal", "tgl"))
356
+
357
+
358
+ def _looks_id(name: str) -> bool:
359
+ tokens = re.split(r"[^a-z0-9]+", name)
360
+ return "id" in tokens or "email" in tokens or name.endswith("id")
361
+
362
+
363
+ def _bucket_and_note(name: str, data_type: str, reply_language: str) -> tuple[str, str]:
364
+ """Assign a column to a Ringkasan bucket + an optional heuristic note.
365
+
366
+ Grouping is by data type, with two name-based overrides that mirror how a
367
+ human reads a schema: a date-named column goes to Date even when stored as
368
+ text (with a 'convert to datetime' note), and a numeric column whose name
369
+ looks like an id gets an 'identifier' note (shouldn't be treated as a
370
+ quantity). Deterministic — heuristics only, no LLM.
371
+ """
372
+ sc = _sc(reply_language)
373
+ cat = _type_category(data_type)
374
+ lname = name.lower()
375
+ if _looks_date(lname) or cat == "date":
376
+ return "date", (sc["note_date"] if cat != "date" else "")
377
+ if cat == "integer":
378
+ return "int", (sc["note_id"] if _looks_id(lname) else "")
379
+ if cat == "float":
380
+ return "float", (sc["note_id"] if _looks_id(lname) else "")
381
+ return "cat", ""
382
+
383
+
384
+ def _schema_rows(out: ToolOutput) -> list[dict[str, Any]]:
385
+ """Flatten a check_data(source_id) ToolOutput into per-column dicts."""
386
+ cols = out.columns or []
387
+ idx = {c: i for i, c in enumerate(cols)}
388
+
389
+ def _get(row: list[Any], key: str, default: object = None) -> object:
390
+ return row[idx[key]] if key in idx else default
391
+
392
+ rows: list[dict[str, Any]] = []
393
+ for r in out.rows or []:
394
+ rows.append({
395
+ "table": str(_get(r, "table_name", "")),
396
+ "table_rows": _get(r, "table_row_count"),
397
+ "column": str(_get(r, "column_name", "")),
398
+ "data_type": str(_get(r, "data_type", "")),
399
+ "nullable": bool(_get(r, "nullable", False)),
400
+ "pii": bool(_get(r, "pii_flag", False)),
401
+ })
402
+ return rows
403
+
404
+
405
+ def _columns_table(cols: list[dict[str, Any]], reply_language: str) -> str:
406
+ """Lean per-table markdown: Column · Data type (+ Nullable if mixed, + PII if any)."""
407
+ labels = _HEADER_LABELS.get(reply_language, _HEADER_LABELS["English"])
408
+ types = _TYPE_WORDS.get(reply_language, _TYPE_WORDS["English"])
409
+ sc = _sc(reply_language)
410
+ # Nullable only carries signal when a table mixes nullable + non-nullable;
411
+ # PII only when at least one column is flagged. Otherwise the column is noise.
412
+ show_nullable = len({c["nullable"] for c in cols}) > 1
413
+ show_pii = any(c["pii"] for c in cols)
414
+
415
+ headers = [labels["column_name"], labels["data_type"]]
416
+ if show_nullable:
417
+ headers.append(labels["nullable"])
418
+ if show_pii:
419
+ headers.append(labels["pii_flag"])
420
+ lines = [
421
+ "| " + " | ".join(headers) + " |",
422
+ "| " + " | ".join("---" for _ in headers) + " |",
423
+ ]
424
+ for c in cols:
425
+ cells = [c["column"], types.get(_type_category(c["data_type"]), c["data_type"])]
426
+ if show_nullable:
427
+ cells.append(sc["yes"] if c["nullable"] else sc["no"])
428
+ if show_pii:
429
+ cells.append(sc["yes"] if c["pii"] else sc["no"])
430
+ lines.append("| " + " | ".join(cells) + " |")
431
+ return "\n".join(lines)
432
+
433
+
434
+ def _render_summary(cols: list[dict[str, Any]], reply_language: str) -> str:
435
+ """Adaptive per-source Ringkasan: only buckets that actually have columns."""
436
+ sc = _sc(reply_language)
437
+ buckets: dict[str, list[str]] = {"cat": [], "date": [], "int": [], "float": []}
438
+ for c in cols:
439
+ bucket, note = _bucket_and_note(c["column"], c["data_type"], reply_language)
440
+ buckets[bucket].append(f"{c['column']} ({note})" if note else c["column"])
441
+ lines = [sc["summary"]]
442
+ emitted = False
443
+ for key in ("cat", "date", "int", "float"):
444
+ if buckets[key]:
445
+ emitted = True
446
+ lines.append(f"- {sc[key]}: " + ", ".join(buckets[key]))
447
+ return "\n".join(lines) if emitted else ""
448
+
449
+
450
+ def _render_schema_source(out: ToolOutput, reply_language: str) -> str:
451
+ """Render one source's schema: columns per table + a Ringkasan.
452
+
453
+ A database with more than `_DB_TABLE_THRESHOLD` tables is summarised at the
454
+ table level instead (name + column/row counts) so it doesn't become a wall
455
+ of text; the user names a table to drill into its columns.
456
+ """
457
+ if out.kind == "error":
458
+ return _s(reply_language)["lookup_error"].format(error=out.error)
459
+ rows = _schema_rows(out)
460
+ if not rows:
461
+ return ""
462
+ meta = out.meta or {}
463
+ name = str(meta.get("source_name") or "source")
464
+ source_type = str(meta.get("source_type") or "")
465
+ sc = _sc(reply_language)
466
+
467
+ tables: dict[str, list[dict[str, Any]]] = {}
468
+ for r in rows:
469
+ tables.setdefault(r["table"], []).append(r)
470
+
471
+ def _rows_suffix(rc: object) -> str:
472
+ return f", {rc} {sc['rows_word']}" if rc else ""
473
+
474
+ if source_type == "schema" and len(tables) > _DB_TABLE_THRESHOLD:
475
+ lines = [sc["db_tables"].format(name=name, n=len(tables))]
476
+ for tname, tcols in tables.items():
477
+ rc = tcols[0]["table_rows"]
478
+ lines.append(
479
+ sc["db_item"].format(table=tname, cols=len(tcols), rows=_rows_suffix(rc))
480
+ )
481
+ lines.append(sc["db_hint"])
482
+ return "\n".join(lines)
483
+
484
+ parts: list[str] = []
485
+ if len(tables) == 1:
486
+ tname, tcols = next(iter(tables.items()))
487
+ rc = tcols[0]["table_rows"]
488
+ head = f"**{name}**" + (f" ({rc} {sc['rows_word']})" if rc else "")
489
+ parts.append(f"{head}\n{_columns_table(tcols, reply_language)}")
490
+ else:
491
+ parts.append(f"**{name}**")
492
+ for tname, tcols in tables.items():
493
+ rc = tcols[0]["table_rows"]
494
+ sub = f"{sc['table_word']} {tname}" + (f" ({rc} {sc['rows_word']})" if rc else "") + ":"
495
+ parts.append(f"{sub}\n{_columns_table(tcols, reply_language)}")
496
+
497
+ summary = _render_summary(rows, reply_language)
498
+ if summary:
499
+ parts.append(summary)
500
  return "\n\n".join(parts)
501
 
502
 
503
+ def _render_schemas(schemas: list[ToolOutput], reply_language: str) -> str:
504
+ """Stitch one or more source schemas under a single lead-in sentence."""
505
+ blocks = [b for b in (_render_schema_source(o, reply_language) for o in schemas) if b]
506
+ if not blocks:
507
+ return ""
508
+ n = len(blocks)
509
+ suffix = "s" if (reply_language == "English" and n != 1) else ""
510
+ lead = _sc(reply_language)["lead"].format(n=n, s=suffix)
511
+ return lead + "\n\n" + "\n\n".join(blocks)
512
+
513
+
514
+ async def run_check(
515
+ message: str, invoker: ToolInvoker, reply_language: str = "English"
516
+ ) -> str:
517
+ """Route to check_data, check_knowledge, or both (helicopter view) based on cues.
518
+
519
+ `reply_language` ("Indonesian"/"English", from `detect_reply_language`)
520
+ localizes the fixed prose so this deterministic path answers in the user's
521
+ language like the LLM paths do.
522
+ """
523
  intent = _intent(message)
524
 
525
+ _no_match = _s(reply_language)["no_match"]
526
 
527
  if intent == "knowledge":
528
  out = await invoker.invoke("check_knowledge", {})
529
+ listing = _render_source_list(out, reply_language)
530
+ if not listing:
531
+ return _no_match
532
+ return _lead("documents", reply_language, len(out.rows or [])) + "\n\n" + listing
533
 
534
+ # Schema-detail question ("kolom apa aja + tipe datanya"): drill into the
535
+ # schema of the named source(s), or ALL structured sources when none is
536
+ # named — so the user never has to type a full filename to see their columns
537
+ # and types. This supersedes name-matched drill-down (a source referred to as
538
+ # "dataset itu" now resolves via the single-/all-source fallback).
539
+ if _wants_schema(message):
540
  inventory = await invoker.invoke("check_data", {})
541
  if inventory.kind == "error":
542
+ return render_tool_output(inventory, reply_language)
543
+ if not (inventory.rows or []):
544
+ return _no_match
545
+ named = _matched_source_ids(message, inventory)
546
+ cols = inventory.columns or []
547
+ sid_i = cols.index("source_id") if "source_id" in cols else 0
548
+ ids = named or [str(r[sid_i]) for r in (inventory.rows or [])]
549
  schemas = await asyncio.gather(
550
+ *(invoker.invoke("check_data", {"source_id": sid}) for sid in ids)
551
  )
552
+ return _render_schemas(schemas, reply_language) or _no_match
553
+
554
+ if intent == "data":
555
+ inventory = await invoker.invoke("check_data", {})
556
+ if inventory.kind == "error":
557
+ return render_tool_output(inventory, reply_language)
558
+ listing = _render_source_list(inventory, reply_language)
559
+ if not listing:
560
+ return _no_match
561
+ n = len(inventory.rows or [])
562
+ return _lead("structured", reply_language, n) + "\n\n" + listing
563
 
564
  # broad / ambiguous → helicopter view: call both concurrently
565
  data_out, knowledge_out = await asyncio.gather(
566
  invoker.invoke("check_data", {}),
567
  invoker.invoke("check_knowledge", {}),
568
  )
569
+ return _render_helicopter(data_out, knowledge_out, reply_language)
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
@@ -75,11 +93,11 @@ async def chat_stream(
75
  """Chat endpoint with streaming response (v2 — keyed on `analysis_id`).
76
 
77
  SSE event sequence:
78
- 1. sources — JSON array of source refs (table for structured; deduped
79
- document_id/page_label for unstructured; [] for chat/help/error)
80
  2. status — slow-path progress pings (optional)
81
  3. chunk — text fragments of the answer
82
- 4. done — {"message_id": "..."} for the observability lookup
83
  """
84
  analysis_id = body.analysis_id
85
  message_id = _mint_message_id()
@@ -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, analysis_id=analysis_id
 
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
 
93
  """Chat endpoint with streaming response (v2 — keyed on `analysis_id`).
94
 
95
  SSE event sequence:
96
+ 1. sources — always `[]` (KM-691): sources moved to GET /api/v1/traceability;
97
+ the stream stays text-only. Event kept for backward-compat.
98
  2. status — slow-path progress pings (optional)
99
  3. chunk — text fragments of the answer
100
+ 4. done — {"message_id": "..."} for the traceability lookup
101
  """
102
  analysis_id = body.analysis_id
103
  message_id = _mint_message_id()
 
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/config/prompts/planner.md CHANGED
@@ -84,6 +84,21 @@ only a `TaskList` object that conforms to the provided schema.
84
  - For `in`/`not_in` (value is a list) or `between` (value is `[low, high]`), it
85
  is still the ELEMENT type — a list of names is `"string"`, a date range is
86
  `"date"`. It is **never** `"list"`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  - **Use only the `args` a tool lists** — e.g. `analyze_aggregate` takes only
88
  `data`/`aggregations`/`group_by`, so never add `order_by`/`limit` to it.
89
  - **Top-N ("top/most/least N by <metric>") is a single `retrieve_data` query**,
 
84
  - For `in`/`not_in` (value is a list) or `between` (value is `[low, high]`), it
85
  is still the ELEMENT type — a list of names is `"string"`, a date range is
86
  `"date"`. It is **never** `"list"`.
87
+ - **Filters are ANDed — never stack two disjoint ranges on one column.** Every
88
+ `filters[]` entry combines with AND, so two non-overlapping `between` ranges on the
89
+ SAME column (e.g. a date in Q1 2025 AND in Q1 2026) match **zero rows**. Never AND
90
+ two ranges on one column expecting an OR.
91
+ - **Period-over-period comparison = one query per period.** To compare two time
92
+ windows (e.g. Q1 2025 vs Q1 2026), emit a SEPARATE `retrieve_data` (+ its own
93
+ `analyze_*`) task for EACH period, each carrying its own `between` filter for that
94
+ window; the final answer compares the per-period results. Do NOT attempt it in one
95
+ query — there is no way to bucket a date into a quarter/month (no derived columns;
96
+ `group_by` is over EXISTING columns only), so a single spanning range collapses the
97
+ periods into one number and the comparison is lost.
98
+ - **Time-bound a metric by the event's OWN date.** Filter a measure by the column
99
+ that dates the event you are measuring — revenue/sales by the order/transaction
100
+ date, NOT by an entity-creation date like `products.created_at` or
101
+ `customers.registered_at`. Join to the table that holds that date if needed.
102
  - **Use only the `args` a tool lists** — e.g. `analyze_aggregate` takes only
103
  `data`/`aggregations`/`group_by`, so never add `order_by`/`limit` to it.
104
  - **Top-N ("top/most/least N by <metric>") is a single `retrieve_data` query**,
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
- logger.info("pandas query", query=_render_query(ir, {c.column_id: c for c in table.columns}))
 
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/query/ir/validator.py CHANGED
@@ -5,6 +5,8 @@ is re-prompted with the error context (max 3 retries) — error messages
5
  must therefore be specific enough that the LLM can self-correct.
6
  """
7
 
 
 
8
  from ...catalog.models import Catalog, Column, Source, Table
9
  from .models import QueryIR
10
  from .operators import (
@@ -80,6 +82,8 @@ class IRValidator:
80
  f"(allowed: {sorted(allowed)})"
81
  )
82
 
 
 
83
  for i, col_id in enumerate(ir.group_by):
84
  self._require_column(columns_by_id, col_id, f"group_by[{i}]")
85
 
@@ -100,6 +104,36 @@ class IRValidator:
100
  f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
101
  )
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _validate_joins(
104
  self, ir: QueryIR, source: Source, columns_by_id: dict[str, Column]
105
  ) -> None:
 
5
  must therefore be specific enough that the LLM can self-correct.
6
  """
7
 
8
+ from typing import Any
9
+
10
  from ...catalog.models import Catalog, Column, Source, Table
11
  from .models import QueryIR
12
  from .operators import (
 
82
  f"(allowed: {sorted(allowed)})"
83
  )
84
 
85
+ self._reject_contradictory_ranges(ir)
86
+
87
  for i, col_id in enumerate(ir.group_by):
88
  self._require_column(columns_by_id, col_id, f"group_by[{i}]")
89
 
 
104
  f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
105
  )
106
 
107
+ @staticmethod
108
+ def _reject_contradictory_ranges(ir: QueryIR) -> None:
109
+ """Reject disjoint BETWEEN ranges on the same column.
110
+
111
+ All filters in an IR are ANDed, so two non-overlapping BETWEEN ranges on one
112
+ column (e.g. Q1 2025 AND Q1 2026 on order_date) match no rows — a common LLM
113
+ slip when it means a multi-period comparison. Rejecting lets the planner's
114
+ re-prompt retry correct it (one spanning range + group-by the period, or one
115
+ query per period). Best-effort: incomparable values are skipped, not raised.
116
+ """
117
+ ranges: dict[str, list[tuple[Any, Any]]] = {}
118
+ for f in ir.filters:
119
+ if f.op == "between" and isinstance(f.value, list) and len(f.value) == 2:
120
+ ranges.setdefault(f.column_id, []).append((f.value[0], f.value[1]))
121
+ for col_id, rs in ranges.items():
122
+ if len(rs) < 2:
123
+ continue
124
+ try:
125
+ # AND of ranges = intersection [max(lo), min(hi)]; empty when lo > hi.
126
+ empty = max(lo for lo, _ in rs) > min(hi for _, hi in rs)
127
+ except TypeError:
128
+ continue # incomparable value types — skip (fail-open)
129
+ if empty:
130
+ raise IRValidationError(
131
+ f"filters place {len(rs)} non-overlapping BETWEEN ranges on column "
132
+ f"{col_id!r}; because all filters are ANDed this matches no rows. "
133
+ "For a multi-period comparison use a single spanning range and group "
134
+ "by the period, or emit one query per period."
135
+ )
136
+
137
  def _validate_joins(
138
  self, ir: QueryIR, source: Source, columns_by_id: dict[str, Column]
139
  ) -> None:
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,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 pydantic import BaseModel
18
+
19
+ from src.middlewares.logging import get_logger
20
+
21
+ from .schemas import PlanningInfo, PlanStep, ToolCallInfo, TraceabilityPayload
22
+
23
+ logger = get_logger("traceability")
24
+
25
+ # Truncation caps (bound the JSONB payload) — see plan §3.
26
+ CAP_PREVIEW_ROWS = 5
27
+ CAP_STR = 300
28
+ # Executed queries get a higher cap than free strings: the SQL/query is the point of
29
+ # the feature, and 300 chars mangles all but the smallest statements.
30
+ CAP_QUERY = 2000
31
+
32
+
33
+ def _truncate(obj: Any) -> Any:
34
+ """Recursively cap strings to CAP_STR and summarize embedded tool results.
35
+
36
+ A Pattern-A `analyze_*` input carries its upstream `retrieve_data` result as a
37
+ `ToolOutput` (a BaseModel). Left untouched that would re-embed the FULL upstream
38
+ table (all rows + the untruncated query) into the payload, defeating the caps —
39
+ so a tool-result-shaped model is summarized via `_output_to_dict` (preview ≤ 5
40
+ rows, `row_count` kept), and any other BaseModel is dumped then recursed.
41
+ """
42
+ if isinstance(obj, str):
43
+ return obj[:CAP_STR]
44
+ if isinstance(obj, BaseModel):
45
+ if hasattr(obj, "kind") and hasattr(obj, "rows"): # a ToolOutput-shaped result
46
+ return _output_to_dict(obj)
47
+ return _truncate(obj.model_dump(mode="json"))
48
+ if isinstance(obj, dict):
49
+ return {k: _truncate(v) for k, v in obj.items()}
50
+ if isinstance(obj, list):
51
+ return [_truncate(v) for v in obj]
52
+ return obj
53
+
54
+
55
+ def _output_to_dict(output: Any) -> dict[str, Any]:
56
+ """Normalize a tool result (`ToolOutput` or a synth dict) to the wire shape:
57
+ kind/columns/row_count/preview/value/error, all truncation-capped."""
58
+ if isinstance(output, dict):
59
+ return _truncate(output)
60
+
61
+ kind = getattr(output, "kind", None)
62
+ result: dict[str, Any] = {"kind": kind}
63
+ rows = getattr(output, "rows", None)
64
+ if rows is not None:
65
+ result["row_count"] = len(rows)
66
+ columns = getattr(output, "columns", None)
67
+ if columns is not None:
68
+ result["columns"] = list(columns)
69
+ result["preview"] = [
70
+ [_truncate(cell) for cell in row] for row in rows[:CAP_PREVIEW_ROWS]
71
+ ]
72
+ value = getattr(output, "value", None)
73
+ if value is not None:
74
+ result["value"] = _truncate(value)
75
+ error = getattr(output, "error", None)
76
+ if error is not None:
77
+ result["error"] = _truncate(error)
78
+ return result
79
+
80
+
81
+ def _meta_of(output: Any) -> dict[str, Any]:
82
+ """Best-effort read of a tool result's `meta` dict (ToolOutput or plain dict)."""
83
+ if isinstance(output, dict):
84
+ meta = output.get("meta")
85
+ else:
86
+ meta = getattr(output, "meta", None)
87
+ return meta if isinstance(meta, dict) else {}
88
+
89
+
90
+ class TraceabilityScratchpad:
91
+ """Mutable per-request accumulator; `build()` freezes it into a payload."""
92
+
93
+ def __init__(self) -> None:
94
+ self.message_id: str | None = None # set at handler entry; None => no flush
95
+ self.intent: str = "chat" # default until the router classifies
96
+ self._planning: PlanningInfo | None = None
97
+ self._tool_calls: list[ToolCallInfo] = []
98
+ self._db_sources: list[dict[str, Any]] = []
99
+ self._doc_sources: list[dict[str, Any]] = []
100
+ self._doc_seen: set[tuple[Any, Any]] = set()
101
+
102
+ def set_intent(self, intent: str) -> None:
103
+ self.intent = intent
104
+
105
+ def record_tool_call(
106
+ self,
107
+ name: str,
108
+ args: dict[str, Any],
109
+ output: Any,
110
+ task_id: str | None = None,
111
+ ) -> None:
112
+ """Append one tool call (input + normalized output). For `retrieve_data`,
113
+ also derive a database source from the args + executed query in `meta`."""
114
+ out_dict = _output_to_dict(output)
115
+ status = "error" if out_dict.get("kind") == "error" else "success"
116
+ self._tool_calls.append(
117
+ ToolCallInfo(
118
+ order=len(self._tool_calls) + 1,
119
+ task_id=task_id,
120
+ name=name,
121
+ input=_truncate(dict(args)),
122
+ output=out_dict,
123
+ status=status,
124
+ error=out_dict.get("error"),
125
+ )
126
+ )
127
+ if name == "retrieve_data":
128
+ self._record_db_source(output)
129
+
130
+ def _record_db_source(self, output: Any) -> None:
131
+ # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
132
+ # live on the tool OUTPUT meta (see tools/data_access.py::_retrieve_data).
133
+ meta = _meta_of(output)
134
+ query = meta.get("query")
135
+ table = meta.get("table_name") or meta.get("table_id")
136
+ self._db_sources.append({
137
+ "type": "database",
138
+ "source_id": meta.get("source_id"),
139
+ "name": table,
140
+ "query": query[:CAP_QUERY] if isinstance(query, str) else None,
141
+ "detail": {"table": table, "row_count": meta.get("row_count")},
142
+ })
143
+
144
+ def set_planning_from_record(self, record: Any) -> None:
145
+ """Map an `AnalysisRecord` (goal_restated + tasks_run) to `PlanningInfo`."""
146
+ try:
147
+ steps = [
148
+ PlanStep(
149
+ step=i + 1,
150
+ stage=str(getattr(task, "stage", "")),
151
+ objective=getattr(task, "objective", ""),
152
+ status=str(getattr(task, "status", "")),
153
+ tools_used=list(getattr(task, "tools_used", []) or []),
154
+ )
155
+ for i, task in enumerate(getattr(record, "tasks_run", []) or [])
156
+ ]
157
+ self._planning = PlanningInfo(
158
+ goal_restated=getattr(record, "goal_restated", "") or "",
159
+ assumptions=[], # AnalysisRecord carries no assumptions field (honest: empty)
160
+ steps=steps,
161
+ )
162
+ except Exception as exc: # never break the answer on a mapping slip
163
+ logger.warning("traceability planning mapping failed", error=str(exc))
164
+
165
+ def add_document_sources(self, raw_chunks: Any, query: str) -> None:
166
+ """Dedupe retrieved chunks by (document_id, page_label) into document
167
+ sources, stamped with the query. Sole source of document provenance now
168
+ that the stream no longer emits sources (KM-691)."""
169
+ for item in raw_chunks or []:
170
+ if hasattr(item, "metadata"):
171
+ data = item.metadata.get("data", {})
172
+ elif isinstance(item, dict):
173
+ data = item
174
+ else:
175
+ continue
176
+ key = (data.get("document_id"), data.get("page_label"))
177
+ if key == (None, None) or key in self._doc_seen:
178
+ continue
179
+ self._doc_seen.add(key)
180
+ source: dict[str, Any] = {
181
+ "type": "document",
182
+ "document_id": data.get("document_id"),
183
+ "filename": data.get("filename", "Unknown"),
184
+ "page_label": data.get("page_label"),
185
+ "query": _truncate(query),
186
+ }
187
+ snippet = data.get("snippet") or data.get("content") or data.get("text")
188
+ if isinstance(snippet, str):
189
+ source["snippet"] = snippet[:CAP_STR]
190
+ score = data.get("score")
191
+ if score is not None:
192
+ source["score"] = score
193
+ self._doc_sources.append(source)
194
+
195
+ def build(self, analysis_id: str, user_id: str, message_id: str) -> TraceabilityPayload:
196
+ """Freeze the accumulated state into a `TraceabilityPayload`."""
197
+ from datetime import UTC, datetime
198
+
199
+ return TraceabilityPayload(
200
+ analysis_id=analysis_id,
201
+ message_id=message_id,
202
+ user_id=user_id,
203
+ intent=self.intent,
204
+ generated_at=datetime.now(UTC),
205
+ planning=self._planning,
206
+ thinking=None,
207
+ tool_calls=list(self._tool_calls),
208
+ sources=self._doc_sources + self._db_sources,
209
+ )
210
+
211
+
212
+ class TraceabilityToolInvoker:
213
+ """Wraps a ToolInvoker to record each call's full I/O into a scratchpad.
214
+
215
+ Implements the ToolInvoker protocol (`async invoke(tool_name, args)`). Recording
216
+ is never-throw so a trace slip can't break the tool run. Distinct from
217
+ `TracingToolInvoker` (Langfuse, masked-metadata-only) — that name is taken.
218
+ """
219
+
220
+ def __init__(self, inner: Any, pad: TraceabilityScratchpad) -> None:
221
+ self._inner = inner
222
+ self._pad = pad
223
+
224
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> Any:
225
+ out = await self._inner.invoke(tool_name, args)
226
+ try:
227
+ self._pad.record_tool_call(tool_name, args, out)
228
+ except Exception as exc: # never break the tool run
229
+ logger.warning("traceability tool record failed", tool=tool_name, error=str(exc))
230
+ 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)