/feat traceability
#11
by rhbt6767 - opened
- API_CONTRACT_BE_PYTHON.md +64 -49
- DEV_PLAN.md +7 -8
- REPO_STATUS.md +5 -5
- main.py +2 -0
- src/agents/chat_handler.py +96 -62
- src/agents/handlers/check.py +444 -40
- src/api/v1/help.py +1 -0
- src/api/v1/traceability.py +43 -0
- src/api/v2/chat.py +27 -4
- src/config/prompts/planner.md +15 -0
- src/db/postgres/models.py +28 -0
- src/query/executor/base.py +3 -0
- src/query/executor/db.py +1 -0
- src/query/executor/tabular.py +3 -1
- src/query/ir/validator.py +34 -0
- src/tools/data_access.py +2 -0
- src/traceability/__init__.py +32 -0
- src/traceability/schemas.py +76 -0
- src/traceability/scratchpad.py +230 -0
- src/traceability/store.py +110 -0
API_CONTRACT_BE_PYTHON.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Backend Agentic Service API Contract
|
| 2 |
|
| 3 |
-
This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and
|
| 4 |
|
| 5 |
Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
|
| 6 |
|
|
@@ -11,14 +11,14 @@ The Python backend owns the generative AI interaction surface:
|
|
| 11 |
1. Stream chat answers from the AI agent.
|
| 12 |
2. Execute tool-style actions for help and report generation.
|
| 13 |
3. Return report versions and report details.
|
| 14 |
-
4. Return
|
| 15 |
|
| 16 |
The frontend uses this service during the analysis conversation flow:
|
| 17 |
|
| 18 |
1. User sends a chat message.
|
| 19 |
2. Frontend calls `POST /api/v2/chat/stream` and renders the streamed answer.
|
| 20 |
3. When the stream emits `done`, frontend stores or reads the returned `message_id`.
|
| 21 |
-
4. Frontend calls `GET /api/v1/
|
| 22 |
5. Frontend calls `/api/v1/tools/help` for guided help and `/api/v1/tools/report` for report generation.
|
| 23 |
|
| 24 |
## Endpoint Summary
|
|
@@ -31,7 +31,7 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 31 |
| `POST` | `/api/v1/tools/report` | Generate and persist a new report version. |
|
| 32 |
| `GET` | `/api/v1/tools/report/{analysis_id}` | List report versions for an analysis. |
|
| 33 |
| `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
|
| 34 |
-
| `GET` | `/api/v1/
|
| 35 |
|
| 36 |
## Common Concepts
|
| 37 |
|
|
@@ -39,7 +39,7 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 39 |
|
| 40 |
- `user_id`: user identifier passed by the frontend.
|
| 41 |
- `analysis_id`: analysis conversation identifier.
|
| 42 |
-
- `message_id`: assistant answer identifier used to correlate chat streaming with
|
| 43 |
|
| 44 |
### Server-Sent Events
|
| 45 |
|
|
@@ -51,13 +51,13 @@ Common event types:
|
|
| 51 |
|
| 52 |
| Event | Data | Meaning |
|
| 53 |
| --- | --- | --- |
|
| 54 |
-
| `sources` | JSON array |
|
| 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/
|
| 61 |
|
| 62 |
## Chat
|
| 63 |
|
|
@@ -82,7 +82,7 @@ Fields:
|
|
| 82 |
| --- | --- | --- |
|
| 83 |
| `user_id` | Yes | User identifier. |
|
| 84 |
| `analysis_id` | Yes | Analysis conversation identifier. |
|
| 85 |
-
| `message_id` | No | Assistant answer id for
|
| 86 |
| `message` | Yes | User message text. |
|
| 87 |
|
| 88 |
Response: `text/event-stream`.
|
|
@@ -91,7 +91,7 @@ Example structured answer:
|
|
| 91 |
|
| 92 |
```text
|
| 93 |
event: sources
|
| 94 |
-
data: [
|
| 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`
|
| 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 |
-
##
|
|
|
|
|
|
|
| 361 |
|
| 362 |
-
### `GET /api/v1/
|
| 363 |
|
| 364 |
-
Returns
|
| 365 |
|
| 366 |
-
The frontend should call this after the chat/help stream emits `done`, using the `message_id` from the `done` event.
|
| 367 |
|
| 368 |
Query params:
|
| 369 |
|
|
@@ -375,15 +377,19 @@ Query params:
|
|
| 375 |
Example:
|
| 376 |
|
| 377 |
```text
|
| 378 |
-
GET /api/v1/
|
| 379 |
```
|
| 380 |
|
|
|
|
|
|
|
| 381 |
Field rules:
|
| 382 |
|
| 383 |
-
- `planning`: present only when the planner ran; otherwise `null`.
|
| 384 |
-
- `thinking`:
|
| 385 |
-
- `tool_calls`: every invoked tool with input, output, and
|
| 386 |
-
- `sources`: required for retrieval flows; empty for chat/help
|
|
|
|
|
|
|
| 387 |
|
| 388 |
Response `200` for `structured_flow`:
|
| 389 |
|
|
@@ -391,49 +397,58 @@ Response `200` for `structured_flow`:
|
|
| 391 |
{
|
| 392 |
"analysis_id": "an_42",
|
| 393 |
"message_id": "msg_88f1",
|
|
|
|
| 394 |
"intent": "structured_flow",
|
| 395 |
-
"generated_at": "2026-
|
| 396 |
"planning": {
|
| 397 |
"goal_restated": "Find which regions drive revenue and why Q1 dipped.",
|
| 398 |
-
"assumptions": [
|
| 399 |
"steps": [
|
| 400 |
{
|
| 401 |
"step": 1,
|
| 402 |
"stage": "data_understanding",
|
| 403 |
-
"objective": "Inventory the sales source"
|
|
|
|
|
|
|
| 404 |
},
|
| 405 |
{
|
| 406 |
"step": 2,
|
| 407 |
"stage": "modeling",
|
| 408 |
-
"objective": "Aggregate revenue by region"
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
]
|
| 411 |
},
|
| 412 |
-
"thinking":
|
| 413 |
"tool_calls": [
|
| 414 |
{
|
| 415 |
"order": 1,
|
|
|
|
| 416 |
"name": "check_data",
|
| 417 |
"input": { "source_hint": "structured" },
|
| 418 |
-
"output": {
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
},
|
| 421 |
{
|
| 422 |
"order": 2,
|
|
|
|
| 423 |
"name": "retrieve_data",
|
| 424 |
-
"input": {
|
| 425 |
-
"source_id": "src_sales_db",
|
| 426 |
-
"table_id": "orders",
|
| 427 |
-
"select": ["region", "amount"],
|
| 428 |
-
"group_by": ["region"]
|
| 429 |
-
},
|
| 430 |
"output": {
|
| 431 |
"kind": "table",
|
| 432 |
"columns": ["region", "total"],
|
| 433 |
"row_count": 4,
|
| 434 |
"preview": [["Central", 1210000], ["East", 740000]]
|
| 435 |
},
|
| 436 |
-
"status": "success"
|
|
|
|
| 437 |
}
|
| 438 |
],
|
| 439 |
"sources": [
|
|
@@ -443,37 +458,36 @@ Response `200` for `structured_flow`:
|
|
| 443 |
"name": "orders",
|
| 444 |
"query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
|
| 445 |
"detail": {
|
| 446 |
-
"
|
| 447 |
-
"row_count":
|
| 448 |
}
|
| 449 |
}
|
| 450 |
]
|
| 451 |
}
|
| 452 |
```
|
| 453 |
|
|
|
|
|
|
|
| 454 |
Response `200` for `unstructured_flow`:
|
| 455 |
|
| 456 |
```json
|
| 457 |
{
|
| 458 |
"analysis_id": "an_42",
|
| 459 |
"message_id": "msg_55",
|
|
|
|
| 460 |
"intent": "unstructured_flow",
|
| 461 |
-
"generated_at": "2026-
|
| 462 |
"planning": null,
|
| 463 |
"thinking": null,
|
| 464 |
"tool_calls": [
|
| 465 |
{
|
| 466 |
"order": 1,
|
|
|
|
| 467 |
"name": "retrieve_knowledge",
|
| 468 |
-
"input": {
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
"output": {
|
| 473 |
-
"kind": "documents",
|
| 474 |
-
"row_count": 4
|
| 475 |
-
},
|
| 476 |
-
"status": "success"
|
| 477 |
}
|
| 478 |
],
|
| 479 |
"sources": [
|
|
@@ -490,14 +504,15 @@ Response `200` for `unstructured_flow`:
|
|
| 490 |
}
|
| 491 |
```
|
| 492 |
|
| 493 |
-
Response `200` for
|
| 494 |
|
| 495 |
```json
|
| 496 |
{
|
| 497 |
"analysis_id": "an_42",
|
| 498 |
"message_id": "msg_12",
|
|
|
|
| 499 |
"intent": "chat",
|
| 500 |
-
"generated_at": "2026-
|
| 501 |
"planning": null,
|
| 502 |
"thinking": null,
|
| 503 |
"tool_calls": [],
|
|
@@ -509,13 +524,13 @@ Response `404`:
|
|
| 509 |
|
| 510 |
```json
|
| 511 |
{
|
| 512 |
-
"detail": "No
|
| 513 |
}
|
| 514 |
```
|
| 515 |
|
| 516 |
Frontend rendering guidance:
|
| 517 |
|
| 518 |
-
- Render
|
| 519 |
- Default state can be collapsed.
|
| 520 |
- Show planning, tool calls, and sources as separate sections.
|
| 521 |
- Treat `planning: null`, `tool_calls: []`, and `sources: []` as valid states.
|
|
|
|
| 1 |
# Backend Agentic Service API Contract
|
| 2 |
|
| 3 |
+
This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and traceability data shown alongside chat answers.
|
| 4 |
|
| 5 |
Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
|
| 6 |
|
|
|
|
| 11 |
1. Stream chat answers from the AI agent.
|
| 12 |
2. Execute tool-style actions for help and report generation.
|
| 13 |
3. Return report versions and report details.
|
| 14 |
+
4. Return traceability/provenance for a completed assistant answer.
|
| 15 |
|
| 16 |
The frontend uses this service during the analysis conversation flow:
|
| 17 |
|
| 18 |
1. User sends a chat message.
|
| 19 |
2. Frontend calls `POST /api/v2/chat/stream` and renders the streamed answer.
|
| 20 |
3. When the stream emits `done`, frontend stores or reads the returned `message_id`.
|
| 21 |
+
4. Frontend calls `GET /api/v1/traceability` for planning, tool calls, and source provenance.
|
| 22 |
5. Frontend calls `/api/v1/tools/help` for guided help and `/api/v1/tools/report` for report generation.
|
| 23 |
|
| 24 |
## Endpoint Summary
|
|
|
|
| 31 |
| `POST` | `/api/v1/tools/report` | Generate and persist a new report version. |
|
| 32 |
| `GET` | `/api/v1/tools/report/{analysis_id}` | List report versions for an analysis. |
|
| 33 |
| `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
|
| 34 |
+
| `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
|
| 35 |
|
| 36 |
## Common Concepts
|
| 37 |
|
|
|
|
| 39 |
|
| 40 |
- `user_id`: user identifier passed by the frontend.
|
| 41 |
- `analysis_id`: analysis conversation identifier.
|
| 42 |
+
- `message_id`: assistant answer identifier used to correlate chat streaming with traceability.
|
| 43 |
|
| 44 |
### Server-Sent Events
|
| 45 |
|
|
|
|
| 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** |
|
| 34 |
-
| **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi |
|
| 35 |
-
| **3 — tools + obs** | Build `GET /api/v1/
|
| 36 |
-
| **3 — tools + obs** | Keep stream **text-only**;
|
| 37 |
-
| **3 — tools + obs** | Resolve `message_id` correlation (stream ↔
|
| 38 |
| **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
|
| 39 |
| **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
|
| 40 |
| **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
|
| 41 |
|
| 42 |
**Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
|
| 43 |
under `/api/v1/tools/`). The `message_id` correlation contract is now settled in **pr/6** (Python sole
|
| 44 |
-
minter, stream-only). The
|
| 45 |
-
(
|
| 46 |
-
questions, Go-blocked).
|
| 47 |
|
| 48 |
---
|
| 49 |
|
|
|
|
| 30 |
| **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia | ✅ | Renamed route `GET /api/v1/tools` → `GET /api/v1/tools/list` ([tools.py:133](src/api/v1/tools.py:133)). |
|
| 31 |
| **2 — v2 + regroup** | FE: slash menu = `/help` only; report = right-side button | Mentor (FE) | ⬜ | Coordination note, not Python work. |
|
| 32 |
| **3 — tools + obs** | Finish `help` so it actually **calls** (not just lists) + test | Sofhia | ⬜ | Mentor: help currently only lists tools. Core #2 after chat. |
|
| 33 |
+
| **3 — tools + obs** | Traceability **scratchpad** accumulating in the chat agent | Rifqi + Sofhia | ✅ | **KM-691.** `TraceabilityScratchpad` + `TraceabilityToolInvoker` (`src/traceability/`) capture planning / tool I/O / sources during the run; flushed one row before every `done` (all 8 sites; error turns = no row). Renamed observability→traceability (vs. Langfuse). |
|
| 34 |
+
| **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi | ✅ | **KM-691.** Chose a dedicated store: `message_traceability` = 1 JSONB row per message (Python-owned, like `report_inputs`; DDL run manually against dedorch, handed to Harry). Langfuse kept for engineering. |
|
| 35 |
+
| **3 — tools + obs** | Build `GET /api/v1/traceability` (one merged response) | Rifqi | ✅ | **KM-691.** `src/api/v1/traceability.py` → store.get → payload/404. Intent-based source rules (greeting/help/refusals = none; retrieve = required); full planning only on slow path. Contract §7 updated. |
|
| 36 |
+
| **3 — tools + obs** | Keep stream **text-only**; traceability is a separate parallel call | Rifqi | ✅ | **KM-691.** No trace data in the SSE stream; the FE fetches `/traceability` on `done`. |
|
| 37 |
+
| **3 — tools + obs** | Resolve `message_id` correlation (stream ↔ traceability) with Harry | Rifqi ↔ Harry | ✅ | **RESOLVED (pr/6):** Python is the **sole minter** — `message_id` dropped from the `/api/v2/chat/stream` + `/api/v1/tools/help` request bodies; always minted server-side (server-authoritative, FE-security) and returned on `done`. Any caller-sent `message_id` is ignored. Contract open-Q #1 closed. |
|
| 38 |
| **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
|
| 39 |
| **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
|
| 40 |
| **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
|
| 41 |
|
| 42 |
**Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
|
| 43 |
under `/api/v1/tools/`). The `message_id` correlation contract is now settled in **pr/6** (Python sole
|
| 44 |
+
minter, stream-only). The **Phase 3 traceability build** — scratchpad + `GET /api/v1/traceability`
|
| 45 |
+
(contract §7) — is **done (KM-691)**. Next is **Phase 4** (business questions, Go-blocked).
|
|
|
|
| 46 |
|
| 47 |
---
|
| 48 |
|
REPO_STATUS.md
CHANGED
|
@@ -18,7 +18,7 @@ are placeholders (see §12).
|
|
| 18 |
> [DEV_PLAN §0](DEV_PLAN.md)). **Python is becoming a generation/AI-only service** — Go owns the full
|
| 19 |
> analysis lifecycle *and* the data-plane endpoints. Scope:
|
| 20 |
> - **Unwired from `main` + Swagger** (router files kept, *not* deleted): `analysis` CRUD, `room`, `db_client`, `document`, `data_catalog`, `users`/login. **✅ DONE — KM-686, commit `0b2d678`** (so the §7 rows for these are now commented out of `main.py`).
|
| 21 |
-
> - **AI surface that stays live:** `chat` → **`POST /api/v2/chat/stream`** (explicit **`analysis_id`**, not `room_id`); the skills regroup under **`/api/v1/tools/`** (`list` · `help` · `report`); plus a **new `GET /api/v1/
|
| 22 |
> - **Only `chat/stream` moves to `/api/v2`;** everything else stays `/api/v1`.
|
| 23 |
>
|
| 24 |
> §2/§4/§7 below still describe the **pre-restructure wiring** except the unwire above, which has landed.
|
|
@@ -50,7 +50,7 @@ streaming.
|
|
| 50 |
|
| 51 |
> **» pr/5 (decided, not yet in code):** Python's non-AI endpoints (analysis CRUD, `room`, `document`,
|
| 52 |
> `db_client`, `data_catalog`, `users`/login) are being **unwired** — Python keeps only the
|
| 53 |
-
> generation/AI surface (chat, tools: `help`/`report`/`list`,
|
| 54 |
|
| 55 |
Shared infra: **Postgres** (app tables + `data_catalog` jsonb + PGVector `langchain_pg_embedding`), **Azure Blob**, and (Python-only) **Redis**.
|
| 56 |
|
|
@@ -78,8 +78,8 @@ Entry: `POST /api/v1/chat/stream` (`src/api/v1/chat.py`) → `ChatHandler.handle
|
|
| 78 |
(`src/agents/chat_handler.py`). One shared `ChatHandler` per process keeps the Azure clients warm.
|
| 79 |
|
| 80 |
> **» pr/5:** this endpoint moves to **`POST /api/v2/chat/stream`** with an explicit **`analysis_id`**
|
| 81 |
-
> field (replacing `room_id`), and the
|
| 82 |
-
> the stream to a separate `GET /api/v1/
|
| 83 |
|
| 84 |
```
|
| 85 |
POST /chat/stream { user_id, room_id, message }
|
|
@@ -156,7 +156,7 @@ Two facts to internalise:
|
|
| 156 |
## 7. API surface (this repo, all under `/api/v1`)
|
| 157 |
|
| 158 |
> **» pr/5 (decided, not yet in code):** chat → `/api/v2/chat/stream` (`analysis_id`); `/tools` splits
|
| 159 |
-
> into `/tools/list` + `/tools/help` + `/tools/report`; new `/api/v1/
|
| 160 |
> analysis-CRUD / `room` / `users` / `document` / `db_client` / `data_catalog` rows are unwired from
|
| 161 |
> `main` + Swagger. See the Direction-update banner.
|
| 162 |
|
|
|
|
| 18 |
> [DEV_PLAN §0](DEV_PLAN.md)). **Python is becoming a generation/AI-only service** — Go owns the full
|
| 19 |
> analysis lifecycle *and* the data-plane endpoints. Scope:
|
| 20 |
> - **Unwired from `main` + Swagger** (router files kept, *not* deleted): `analysis` CRUD, `room`, `db_client`, `document`, `data_catalog`, `users`/login. **✅ DONE — KM-686, commit `0b2d678`** (so the §7 rows for these are now commented out of `main.py`).
|
| 21 |
+
> - **AI surface that stays live:** `chat` → **`POST /api/v2/chat/stream`** (explicit **`analysis_id`**, not `room_id`); the skills regroup under **`/api/v1/tools/`** (`list` · `help` · `report`); plus a **new `GET /api/v1/traceability`** (user-facing provenance per answer, backed by a Python-owned `message_traceability` store — renamed from `observability`, KM-691). **✅ built.**
|
| 22 |
> - **Only `chat/stream` moves to `/api/v2`;** everything else stays `/api/v1`.
|
| 23 |
>
|
| 24 |
> §2/§4/§7 below still describe the **pre-restructure wiring** except the unwire above, which has landed.
|
|
|
|
| 50 |
|
| 51 |
> **» pr/5 (decided, not yet in code):** Python's non-AI endpoints (analysis CRUD, `room`, `document`,
|
| 52 |
> `db_client`, `data_catalog`, `users`/login) are being **unwired** — Python keeps only the
|
| 53 |
+
> generation/AI surface (chat, tools: `help`/`report`/`list`, traceability). See the Direction-update banner.
|
| 54 |
|
| 55 |
Shared infra: **Postgres** (app tables + `data_catalog` jsonb + PGVector `langchain_pg_embedding`), **Azure Blob**, and (Python-only) **Redis**.
|
| 56 |
|
|
|
|
| 78 |
(`src/agents/chat_handler.py`). One shared `ChatHandler` per process keeps the Azure clients warm.
|
| 79 |
|
| 80 |
> **» pr/5:** this endpoint moves to **`POST /api/v2/chat/stream`** with an explicit **`analysis_id`**
|
| 81 |
+
> field (replacing `room_id`), and the traceability detail (planning / tool I/O / sources) moves out of
|
| 82 |
+
> the stream to a separate `GET /api/v1/traceability` call. See the Direction-update banner.
|
| 83 |
|
| 84 |
```
|
| 85 |
POST /chat/stream { user_id, room_id, message }
|
|
|
|
| 156 |
## 7. API surface (this repo, all under `/api/v1`)
|
| 157 |
|
| 158 |
> **» pr/5 (decided, not yet in code):** chat → `/api/v2/chat/stream` (`analysis_id`); `/tools` splits
|
| 159 |
+
> into `/tools/list` + `/tools/help` + `/tools/report`; new `/api/v1/traceability`; and the
|
| 160 |
> analysis-CRUD / `room` / `users` / `document` / `db_client` / `data_catalog` rows are unwired from
|
| 161 |
> `main` + Swagger. See the Direction-update banner.
|
| 162 |
|
main.py
CHANGED
|
@@ -21,6 +21,7 @@ from slowapi.errors import RateLimitExceeded
|
|
| 21 |
from src.api.v1.report import router as report_router
|
| 22 |
from src.api.v1.tools import router as tools_router
|
| 23 |
from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
|
|
|
|
| 24 |
from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
|
| 25 |
from src.db.postgres.init_db import init_db
|
| 26 |
from src.config.settings import settings
|
|
@@ -67,6 +68,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
| 67 |
app.include_router(report_router)
|
| 68 |
app.include_router(tools_router)
|
| 69 |
app.include_router(help_router)
|
|
|
|
| 70 |
app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
|
| 71 |
|
| 72 |
|
|
|
|
| 21 |
from src.api.v1.report import router as report_router
|
| 22 |
from src.api.v1.tools import router as tools_router
|
| 23 |
from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
|
| 24 |
+
from src.api.v1.traceability import router as traceability_router # KM-691
|
| 25 |
from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
|
| 26 |
from src.db.postgres.init_db import init_db
|
| 27 |
from src.config.settings import settings
|
|
|
|
| 68 |
app.include_router(report_router)
|
| 69 |
app.include_router(tools_router)
|
| 70 |
app.include_router(help_router)
|
| 71 |
+
app.include_router(traceability_router) # KM-691: GET /api/v1/traceability
|
| 72 |
app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
|
| 73 |
|
| 74 |
|
src/agents/chat_handler.py
CHANGED
|
@@ -38,6 +38,7 @@ from langchain_core.messages import BaseMessage
|
|
| 38 |
|
| 39 |
from src.middlewares.logging import get_logger
|
| 40 |
from src.retrieval.base import RetrievalResult
|
|
|
|
| 41 |
|
| 42 |
from .chatbot import ChatbotAgent, DocumentChunk
|
| 43 |
from .guard import InputGuard
|
|
@@ -54,6 +55,7 @@ from .refusals import blocked_message, out_of_scope_message
|
|
| 54 |
if TYPE_CHECKING:
|
| 55 |
from ..catalog.reader import CatalogReader
|
| 56 |
from ..retrieval.router import RetrievalRouter
|
|
|
|
| 57 |
from .gate import AnalysisState
|
| 58 |
from .slow_path.coordinator import SlowPathCoordinator
|
| 59 |
from .slow_path.store import ReportInputStore
|
|
@@ -102,6 +104,7 @@ class ChatHandler:
|
|
| 102 |
Callable[[str], SlowPathCoordinator] | None
|
| 103 |
) = None,
|
| 104 |
analysis_store: ReportInputStore | None = None,
|
|
|
|
| 105 |
check_invoker_factory: Callable[[str], Any] | None = None,
|
| 106 |
ps_agent: ProblemStatementAgent | None = None,
|
| 107 |
help_agent: HelpAgent | None = None,
|
|
@@ -123,6 +126,9 @@ class ChatHandler:
|
|
| 123 |
# factory + store are injectable for tests.
|
| 124 |
self._slow_path_factory = slow_path_coordinator_factory
|
| 125 |
self._analysis_store = analysis_store
|
|
|
|
|
|
|
|
|
|
| 126 |
# `check` skill: builds the data-access invoker (check_data/check_knowledge)
|
| 127 |
# per request with the authenticated user_id. Injectable for tests.
|
| 128 |
self._check_invoker_factory = check_invoker_factory
|
|
@@ -251,6 +257,7 @@ class ChatHandler:
|
|
| 251 |
analysis_id: str | None,
|
| 252 |
history: list[BaseMessage] | None = None,
|
| 253 |
message: str | None = None,
|
|
|
|
| 254 |
) -> AsyncIterator[dict[str, Any]]:
|
| 255 |
"""Deterministic `help` dispatch for the dedicated `/api/v1/tools/help` endpoint.
|
| 256 |
|
|
@@ -262,6 +269,12 @@ class ChatHandler:
|
|
| 262 |
documents), `chunk`*, then `done` (data left empty; the endpoint stamps the
|
| 263 |
`message_id`). On failure, yields a terminal `error` event.
|
| 264 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
# Load (or lazily create) the analysis state; fail closed to a not-validated
|
| 266 |
# stub so help degrades gracefully on a missing row / read error / legacy id.
|
| 267 |
state: AnalysisState | None = None
|
|
@@ -292,6 +305,7 @@ class ChatHandler:
|
|
| 292 |
logger.error("help streaming failed", user_id=user_id, error=str(e))
|
| 293 |
yield {"event": "error", "data": f"Help generation failed: {e}"}
|
| 294 |
return
|
|
|
|
| 295 |
yield {"event": "done", "data": ""}
|
| 296 |
|
| 297 |
async def handle(
|
|
@@ -300,8 +314,14 @@ class ChatHandler:
|
|
| 300 |
user_id: str,
|
| 301 |
history: list[BaseMessage] | None = None,
|
| 302 |
analysis_id: str | None = None,
|
|
|
|
| 303 |
) -> AsyncIterator[dict[str, Any]]:
|
| 304 |
tracer = self._make_tracer(user_id, message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
# ---- 0. Input guard ------------------------------------------
|
| 307 |
# Deliberate input-filtering layer BEFORE the router: screen for prompt-
|
|
@@ -319,6 +339,8 @@ class ChatHandler:
|
|
| 319 |
yield {"event": "sources", "data": json.dumps([])}
|
| 320 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 321 |
tracer.end()
|
|
|
|
|
|
|
| 322 |
yield {"event": "done", "data": ""}
|
| 323 |
return
|
| 324 |
|
|
@@ -335,6 +357,8 @@ class ChatHandler:
|
|
| 335 |
yield {"event": "sources", "data": json.dumps([])}
|
| 336 |
yield {"event": "chunk", "data": blocked_message(message)}
|
| 337 |
tracer.end()
|
|
|
|
|
|
|
| 338 |
yield {"event": "done", "data": ""}
|
| 339 |
return
|
| 340 |
logger.error("intent classification failed", error=repr(e))
|
|
@@ -345,6 +369,7 @@ class ChatHandler:
|
|
| 345 |
return
|
| 346 |
|
| 347 |
intent = decision.intent
|
|
|
|
| 348 |
# ---- 1a. Ensure session state row (T-A) ----------------------
|
| 349 |
# Rooms created via /room/create have no `analysis` row. Without one, Help and
|
| 350 |
# the report_id write-back silently no-op. Lazily get-or-create it (idempotent).
|
|
@@ -392,6 +417,7 @@ class ChatHandler:
|
|
| 392 |
yield {"event": "sources", "data": json.dumps([])}
|
| 393 |
yield {"event": "chunk", "data": out_of_scope_message(message)}
|
| 394 |
tracer.end()
|
|
|
|
| 395 |
yield {"event": "done", "data": ""}
|
| 396 |
return
|
| 397 |
if intent == "structured_flow":
|
|
@@ -416,7 +442,8 @@ class ChatHandler:
|
|
| 416 |
# make the assembled answer English for an Indonesian question).
|
| 417 |
reply_language = detect_reply_language(history, message=message)
|
| 418 |
async for event in self._run_slow_path(
|
| 419 |
-
user_id, rewritten, catalog, tracer, reader, analysis_id,
|
|
|
|
| 420 |
):
|
| 421 |
yield event
|
| 422 |
return
|
|
@@ -434,6 +461,14 @@ class ChatHandler:
|
|
| 434 |
rewritten, user_id
|
| 435 |
)
|
| 436 |
chunks = _normalize_chunks(raw_chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
except Exception as e:
|
| 438 |
logger.error(
|
| 439 |
"unstructured route failed", user_id=user_id, error=str(e)
|
|
@@ -442,13 +477,19 @@ class ChatHandler:
|
|
| 442 |
return
|
| 443 |
elif intent == "check":
|
| 444 |
try:
|
| 445 |
-
invoker
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 69 |
columns = out.columns or []
|
| 70 |
rows = out.rows or []
|
| 71 |
if not rows:
|
| 72 |
return ""
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
body = "\n".join(
|
| 76 |
-
"| " + " | ".join(
|
| 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(
|
|
|
|
|
|
|
| 110 |
"""Stitch structured + document inventory into one helicopter-view reply."""
|
|
|
|
| 111 |
parts: list[str] = []
|
| 112 |
|
| 113 |
-
|
| 114 |
-
if
|
| 115 |
-
parts.append(f"
|
| 116 |
|
| 117 |
-
|
| 118 |
-
if
|
| 119 |
-
parts.append(f"
|
| 120 |
|
| 121 |
if not parts:
|
| 122 |
-
return "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
return "\n\n".join(parts)
|
| 125 |
|
| 126 |
|
| 127 |
-
|
| 128 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
intent = _intent(message)
|
| 130 |
|
| 131 |
-
_no_match = "
|
| 132 |
|
| 133 |
if intent == "knowledge":
|
| 134 |
out = await invoker.invoke("check_knowledge", {})
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
inventory = await invoker.invoke("check_data", {})
|
| 139 |
if inventory.kind == "error":
|
| 140 |
-
return render_tool_output(inventory)
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
| 146 |
schemas = await asyncio.gather(
|
| 147 |
-
*(invoker.invoke("check_data", {"source_id": sid}) for sid in
|
| 148 |
)
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 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 —
|
| 79 |
-
|
| 80 |
2. status — slow-path progress pings (optional)
|
| 81 |
3. chunk — text fragments of the answer
|
| 82 |
-
4. done — {"message_id": "..."} for the
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
| 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)
|