/fix planner and report
#13
by rhbt6767 - opened
- API_CONTRACT_BE_PYTHON.md +86 -3
- DEV_PLAN.md +21 -1
- REPO_STATUS.md +1 -1
- src/agents/planner/examples.py +213 -0
- src/agents/planner/schemas.py +15 -0
- src/agents/planner/validator.py +31 -18
- src/agents/refusals.py +33 -0
- src/agents/report/generator.py +263 -45
- src/agents/report/schemas.py +54 -0
- src/agents/slow_path/coordinator.py +33 -1
- src/api/v1/help.py +3 -2
- src/api/v1/report.py +89 -6
- src/api/v2/chat.py +5 -2
- src/catalog/sample_decode.py +119 -0
- src/catalog/store.py +10 -2
- src/config/prompts/planner.md +45 -3
- src/config/prompts/report_summary.md +18 -2
- src/db/postgres/models.py +2 -2
- src/models/api/report.py +29 -0
- src/query/ir/validator.py +16 -0
- src/tools/analytics/merge.py +136 -0
- src/tools/analytics/temporal.py +65 -1
- src/tools/data_access.py +3 -1
- src/tools/invoker.py +15 -0
- src/tools/registry.py +17 -0
- src/traceability/scratchpad.py +5 -1
API_CONTRACT_BE_PYTHON.md
CHANGED
|
@@ -30,6 +30,8 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 30 |
| `POST` | `/api/v1/tools/help` | Stream contextual help for the current analysis conversation. |
|
| 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 |
|
|
@@ -39,7 +41,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 traceability.
|
| 43 |
|
| 44 |
### Server-Sent Events
|
| 45 |
|
|
@@ -71,7 +73,6 @@ Request body:
|
|
| 71 |
{
|
| 72 |
"user_id": "u_1a2b3c",
|
| 73 |
"analysis_id": "an_42",
|
| 74 |
-
"message_id": "msg_88f1",
|
| 75 |
"message": "What were total sales by region last quarter?"
|
| 76 |
}
|
| 77 |
```
|
|
@@ -82,7 +83,7 @@ Fields:
|
|
| 82 |
| --- | --- | --- |
|
| 83 |
| `user_id` | Yes | User identifier. |
|
| 84 |
| `analysis_id` | Yes | Analysis conversation identifier. |
|
| 85 |
-
| `message_id` |
|
| 86 |
| `message` | Yes | User message text. |
|
| 87 |
|
| 88 |
Response: `text/event-stream`.
|
|
@@ -218,11 +219,13 @@ Query params:
|
|
| 218 |
| --- | --- | --- |
|
| 219 |
| `analysis_id` | Yes | Analysis identifier. |
|
| 220 |
| `user_id` | Yes | User identifier. |
|
|
|
|
| 221 |
|
| 222 |
Example:
|
| 223 |
|
| 224 |
```text
|
| 225 |
POST /api/v1/tools/report?analysis_id=an_42&user_id=u_1a2b3c
|
|
|
|
| 226 |
```
|
| 227 |
|
| 228 |
Status codes:
|
|
@@ -251,6 +254,20 @@ Response `201`:
|
|
| 251 |
},
|
| 252 |
"record_ids": ["rec_a1", "rec_b2"],
|
| 253 |
"executive_summary": "Revenue is concentrated in the Central region (38% of total). The West was the only region to contract, down 12% QoQ, the main driver of the Q1 dip.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
"findings": [
|
| 255 |
{
|
| 256 |
"text": "Central region contributed 38% of total revenue, the largest share.",
|
|
@@ -275,6 +292,23 @@ Response `201`:
|
|
| 275 |
"record_ids": ["rec_b2"]
|
| 276 |
}
|
| 277 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
"data_sources": [
|
| 279 |
{
|
| 280 |
"source_id": "src_sales_db",
|
|
@@ -315,6 +349,13 @@ Response `409`:
|
|
| 315 |
}
|
| 316 |
```
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
Precondition:
|
| 319 |
|
| 320 |
- Reports require at least one completed analysis record for the session.
|
|
@@ -345,6 +386,48 @@ Response `200`:
|
|
| 345 |
|
| 346 |
If no reports exist, returns `[]`.
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
### `GET /api/v1/tools/report/{analysis_id}/{version}`
|
| 349 |
|
| 350 |
Returns one report version. Shape is the same as the `201` response from `POST /api/v1/tools/report`.
|
|
|
|
| 30 |
| `POST` | `/api/v1/tools/help` | Stream contextual help for the current analysis conversation. |
|
| 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}/records` | List analysis records for report curation (added 2026-07-09). |
|
| 34 |
+
| `GET` | `/api/v1/tools/report/{analysis_id}/readiness` | Report-readiness signal for the Generate-Report button (added 2026-07-09). |
|
| 35 |
| `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
|
| 36 |
| `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
|
| 37 |
|
|
|
|
| 41 |
|
| 42 |
- `user_id`: user identifier passed by the frontend.
|
| 43 |
- `analysis_id`: analysis conversation identifier.
|
| 44 |
+
- `message_id`: assistant answer identifier used to correlate chat streaming with traceability. **Server-minted, never accepted from the client.** **Updated 2026-07-09:** it is a UUID string (e.g. `77f06761-0fdf-4cc5-84f8-5f81bcbb6f84`), matching the shape of Go's `analyses_messages.id`. The `msg_…` values in the examples below are illustrative placeholders only.
|
| 45 |
|
| 46 |
### Server-Sent Events
|
| 47 |
|
|
|
|
| 73 |
{
|
| 74 |
"user_id": "u_1a2b3c",
|
| 75 |
"analysis_id": "an_42",
|
|
|
|
| 76 |
"message": "What were total sales by region last quarter?"
|
| 77 |
}
|
| 78 |
```
|
|
|
|
| 83 |
| --- | --- | --- |
|
| 84 |
| `user_id` | Yes | User identifier. |
|
| 85 |
| `analysis_id` | Yes | Analysis conversation identifier. |
|
| 86 |
+
| ~~`message_id`~~ | — | **Updated 2026-07-09:** not a request field. Python always mints the id server-side and returns it on `done`; any caller-sent value is ignored (server-authoritative — open-Q #1). |
|
| 87 |
| `message` | Yes | User message text. |
|
| 88 |
|
| 89 |
Response: `text/event-stream`.
|
|
|
|
| 219 |
| --- | --- | --- |
|
| 220 |
| `analysis_id` | Yes | Analysis identifier. |
|
| 221 |
| `user_id` | Yes | User identifier. |
|
| 222 |
+
| `exclude_record_ids` | No | Record ids to leave out of this version (repeat the param per id). Added 2026-07-09; get ids from `GET /tools/report/{analysis_id}/records`. Excluded runs are listed in the report's "Excluded Analyses" section. Excluding every substantive record returns `409`. |
|
| 223 |
|
| 224 |
Example:
|
| 225 |
|
| 226 |
```text
|
| 227 |
POST /api/v1/tools/report?analysis_id=an_42&user_id=u_1a2b3c
|
| 228 |
+
POST /api/v1/tools/report?analysis_id=an_42&user_id=u_1a2b3c&exclude_record_ids=rec_a1&exclude_record_ids=rec_c3
|
| 229 |
```
|
| 230 |
|
| 231 |
Status codes:
|
|
|
|
| 254 |
},
|
| 255 |
"record_ids": ["rec_a1", "rec_b2"],
|
| 256 |
"executive_summary": "Revenue is concentrated in the Central region (38% of total). The West was the only region to contract, down 12% QoQ, the main driver of the Q1 dip.",
|
| 257 |
+
"bq_answers": [
|
| 258 |
+
{
|
| 259 |
+
"question": "Which regions contribute most to total revenue?",
|
| 260 |
+
"answer": "The Central region leads with 38% of total revenue.",
|
| 261 |
+
"status": "answered",
|
| 262 |
+
"record_ids": ["rec_a1"]
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"question": "Did any region decline quarter-over-quarter?",
|
| 266 |
+
"answer": "Yes — the West region fell 12% QoQ.",
|
| 267 |
+
"status": "answered",
|
| 268 |
+
"record_ids": ["rec_b2"]
|
| 269 |
+
}
|
| 270 |
+
],
|
| 271 |
"findings": [
|
| 272 |
{
|
| 273 |
"text": "Central region contributed 38% of total revenue, the largest share.",
|
|
|
|
| 292 |
"record_ids": ["rec_b2"]
|
| 293 |
}
|
| 294 |
],
|
| 295 |
+
"unresolved": [
|
| 296 |
+
{
|
| 297 |
+
"text": "Correlate churn with tenure — churn column not found in the source.",
|
| 298 |
+
"record_ids": ["rec_d4"]
|
| 299 |
+
}
|
| 300 |
+
],
|
| 301 |
+
"excluded": [],
|
| 302 |
+
"evidence_tables": {
|
| 303 |
+
"rec_a1": [
|
| 304 |
+
{
|
| 305 |
+
"title": "Aggregate revenue by region",
|
| 306 |
+
"columns": ["region", "total_revenue"],
|
| 307 |
+
"rows": [["Central", "18321"], ["West", "9954"]],
|
| 308 |
+
"truncated": false
|
| 309 |
+
}
|
| 310 |
+
]
|
| 311 |
+
},
|
| 312 |
"data_sources": [
|
| 313 |
{
|
| 314 |
"source_id": "src_sales_db",
|
|
|
|
| 349 |
}
|
| 350 |
```
|
| 351 |
|
| 352 |
+
Report v2 fields (added 2026-07-09; all default-empty, so older stored reports read back unchanged):
|
| 353 |
+
|
| 354 |
+
- `bq_answers` — one entry per business question. `status` is `answered` | `partial` | `unanswered`; `record_ids` cite the backing analyses. Written in the analysis's language (Indonesian objective → Indonesian answers).
|
| 355 |
+
- `unresolved` — runs that were attempted but produced no usable evidence (every `analyze_*` step failed). Not part of the findings body.
|
| 356 |
+
- `excluded` — runs the caller excluded via `exclude_record_ids`.
|
| 357 |
+
- `evidence_tables` — `record_id` → small result tables copied from the run's stored outputs (max 3 tables per record, max 10 rows each; `truncated: true` when rows were capped). Rendered as markdown tables under the matching Key Findings group in `rendered_markdown`.
|
| 358 |
+
|
| 359 |
Precondition:
|
| 360 |
|
| 361 |
- Reports require at least one completed analysis record for the session.
|
|
|
|
| 386 |
|
| 387 |
If no reports exist, returns `[]`.
|
| 388 |
|
| 389 |
+
### `GET /api/v1/tools/report/{analysis_id}/records` (added 2026-07-09)
|
| 390 |
+
|
| 391 |
+
Lists the persisted analysis runs a report would be built from, oldest first. The frontend shows this before generating so the user can deselect runs; the chosen ids go to `POST /tools/report` as `exclude_record_ids`.
|
| 392 |
+
|
| 393 |
+
Response `200`:
|
| 394 |
+
|
| 395 |
+
```json
|
| 396 |
+
[
|
| 397 |
+
{
|
| 398 |
+
"record_id": "rec_a1",
|
| 399 |
+
"goal_restated": "Rank regions by total revenue",
|
| 400 |
+
"created_at": "2026-06-30T08:55:02Z",
|
| 401 |
+
"substantive": true,
|
| 402 |
+
"findings_count": 2
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"record_id": "rec_d4",
|
| 406 |
+
"goal_restated": "Correlate churn with tenure",
|
| 407 |
+
"created_at": "2026-06-30T09:01:47Z",
|
| 408 |
+
"substantive": false,
|
| 409 |
+
"findings_count": 1
|
| 410 |
+
}
|
| 411 |
+
]
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
`substantive: false` means no `analyze_*` step succeeded — that run is listed in the report's `unresolved` JSON field rather than the findings body. (Since 2026-07-09 the rendered markdown is compact and no longer includes "Attempted, Unresolved" / "Notes & Limitations" / "How This Was Analyzed" sections; the JSON fields `unresolved` / `caveats` / `open_questions` / `method_steps` are unchanged.) If no runs exist, returns `[]`.
|
| 415 |
+
|
| 416 |
+
### `GET /api/v1/tools/report/{analysis_id}/readiness` (added 2026-07-09)
|
| 417 |
+
|
| 418 |
+
Deterministic report-readiness signal for the Generate-Report button — the same producer as Help's readiness signal, including the advisory delta-since-report check, so the button, Help, and this endpoint never disagree.
|
| 419 |
+
|
| 420 |
+
Response `200`:
|
| 421 |
+
|
| 422 |
+
```json
|
| 423 |
+
{
|
| 424 |
+
"ready": false,
|
| 425 |
+
"missing": ["a new analysis since the last report"]
|
| 426 |
+
}
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
Note: `POST /tools/report` itself only enforces the floor (`at least one completed analysis`) — a new version is always allowed. The delta gap in `missing` is a soft warning the frontend can surface ("nothing new since the last report") without blocking the button.
|
| 430 |
+
|
| 431 |
### `GET /api/v1/tools/report/{analysis_id}/{version}`
|
| 432 |
|
| 433 |
Returns one report version. Shape is the same as the `201` response from `POST /api/v1/tools/report`.
|
DEV_PLAN.md
CHANGED
|
@@ -34,7 +34,7 @@ the endpoint contract *before* coding the tools. Status legend: ⬜ not started
|
|
| 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. |
|
|
@@ -46,6 +46,26 @@ minter, stream-only). The **Phase 3 traceability build** — scratchpad + `GET /
|
|
| 46 |
|
| 47 |
---
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
## 1. The direction change (locked decisions from 2026-06-24)
|
| 50 |
|
| 51 |
1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
|
|
|
|
| 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. **Updated 2026-07-09 (pr/13):** id format changed from `msg_<hex>` to a canonical UUID string (`str(uuid.uuid4())`, both mint sites) to mirror Go's `analyses_messages.id` shape — the value is still independently Python-minted (not the real row id), only format-compatible for a future swap. |
|
| 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. |
|
|
|
|
| 46 |
|
| 47 |
---
|
| 48 |
|
| 49 |
+
## 0.5. pr/13 sprint — agent-quality fixes (2026-07-08 live-test review)
|
| 50 |
+
|
| 51 |
+
Findings from the scoped live sessions (mining analysis, 2026-07-07/08 traces): the planner
|
| 52 |
+
force-mapped absent measures (`pa` aliased as "revenue"), top-N ranked raw rows (duplicate models),
|
| 53 |
+
`analyze_trend` collapsed integer months into a single 1970-01 bucket, an invalid grouped IR reached
|
| 54 |
+
Postgres, failed retrievals wrote all-null traceability sources, and numeric catalog samples arrive
|
| 55 |
+
base64-mangled from Go. Fix tasks (same status legend as §0):
|
| 56 |
+
|
| 57 |
+
| # | Task | Owner | Status | Note |
|
| 58 |
+
|---|---|---|---|---|
|
| 59 |
+
| Q1 | IR validator: reject bare selects under `group_by` (planner retry self-corrects) | Rifqi | ✅ | `query/ir/validator.py` |
|
| 60 |
+
| Q2 | Planner **infeasible** path: `TaskList.infeasible_reason` + deterministic EN/ID data-gap reply | Rifqi | ✅ | schemas/validator/coordinator/refusals + planner.md "When the catalog cannot answer"; refusal wording → Rifqi to review |
|
| 61 |
+
| Q3 | `analyze_trend`: integer year/month handling (epoch-parse bug) | Rifqi | ✅ | `temporal.py` + 5 local tests |
|
| 62 |
+
| Q4 | Planner few-shots: top-N (Example G) + infeasible (Example H) + entity-vs-row ranking rule | Rifqi | ✅ | live-tested 2026-07-08: backlog top-3 correct via single-IR group+sum; "best PA performance" correct in-process (avg-per-model, assumption recorded). Stale-server trace was a false alarm |
|
| 63 |
+
| Q5 | Catalog numeric `sample_values` base64-decode stopgap (`catalog/sample_decode.py`) | Rifqi | ✅ | self-disabling; **primary fix = Go marshaling — DDL-free handoff to Harry** |
|
| 64 |
+
| Q6 | Traceability null-source suppression + `check_data` `-1` row-count hiding | Rifqi | ✅ | `scratchpad.py` / `data_access.py` |
|
| 65 |
+
| Q7 | `analyze_merge` two-table combine tool (unblocks "worst A + biggest B" questions) | tool owner | ✅ | tool shipped by Sofia (8abf635, KM-703); planner slice done 2026-07-09: `_validate_data_source` guards `data_right`, two-retrieve→merge few-shot (Example I), planner.md "Two measures per entity" bullet |
|
| 66 |
+
| Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia | ✅ | done 2026-07-09: still exactly ONE LLM call (extended to also draft `bq_answers`, index-based record refs, deterministic fallback = v1 behavior); evidence tables from table-kind outputs (≤3/record, ≤10 rows, ≤8 cols, `check_*` skipped); reply language via `detect_reply_language` on objective+BQs; verified in-process against live analysis 935a091e |
|
| 67 |
+
| Q9 | Record-curation endpoint (`GET …/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi ↔ FE | ✅ | done 2026-07-09: `GET /tools/report/{analysis_id}/records` + `/readiness` (registered before `/{version}` — int-coercion route-order trap), `exclude_record_ids` on POST; contract updated same change; FE wiring pending (Rifqi → FE) |
|
| 68 |
+
|
| 69 |
## 1. The direction change (locked decisions from 2026-06-24)
|
| 70 |
|
| 71 |
1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
|
REPO_STATUS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
**Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
|
| 4 |
**Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
|
| 5 |
-
**Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
|
| 6 |
the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
|
| 7 |
own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
|
| 8 |
**`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
|
|
|
|
| 2 |
|
| 3 |
**Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
|
| 4 |
**Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
|
| 5 |
+
**Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
|
| 6 |
the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
|
| 7 |
own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
|
| 8 |
**`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
|
src/agents/planner/examples.py
CHANGED
|
@@ -524,6 +524,212 @@ _EXAMPLE_F = TaskList(
|
|
| 524 |
)
|
| 525 |
|
| 526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
EXAMPLES: list[tuple[str, TaskList]] = [
|
| 528 |
("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
|
| 529 |
("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
|
|
@@ -531,6 +737,13 @@ EXAMPLES: list[tuple[str, TaskList]] = [
|
|
| 531 |
("What is the average and total order value per region?", _EXAMPLE_D),
|
| 532 |
("Total revenue for the East and West regions, counting orders of at least 100.", _EXAMPLE_E),
|
| 533 |
("Give me the summary statistics for order revenue and quantity.", _EXAMPLE_F),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
]
|
| 535 |
|
| 536 |
|
|
|
|
| 524 |
)
|
| 525 |
|
| 526 |
|
| 527 |
+
# --------------------------------------------------------------------------- #
|
| 528 |
+
# Example G — top-N ranking.
|
| 529 |
+
# "Top 3 product categories by total revenue."
|
| 530 |
+
# Shows: top-N is ONE retrieve_data query — group by the entity, aggregate the
|
| 531 |
+
# measure with an alias, order by that alias, limit N. NEVER a bare
|
| 532 |
+
# order-by-measure + limit (that ranks raw rows, so the same entity can appear
|
| 533 |
+
# twice — observed in production: "top 3 models" returned one model twice).
|
| 534 |
+
# --------------------------------------------------------------------------- #
|
| 535 |
+
|
| 536 |
+
_EXAMPLE_G = TaskList(
|
| 537 |
+
plan_id="example_g",
|
| 538 |
+
goal_restated="Rank product categories by total revenue and return the top 3.",
|
| 539 |
+
assumptions=[],
|
| 540 |
+
open_questions=[],
|
| 541 |
+
tasks=[
|
| 542 |
+
Task(
|
| 543 |
+
id="t1",
|
| 544 |
+
stage="data_understanding",
|
| 545 |
+
objective="Confirm the sales source exposes category and revenue.",
|
| 546 |
+
tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
|
| 547 |
+
expected_output="source_shape",
|
| 548 |
+
success_criteria=(
|
| 549 |
+
"Produced the orders table schema; category and revenue columns "
|
| 550 |
+
"are present."
|
| 551 |
+
),
|
| 552 |
+
depends_on=[],
|
| 553 |
+
estimated_cost="low",
|
| 554 |
+
),
|
| 555 |
+
Task(
|
| 556 |
+
id="t2",
|
| 557 |
+
stage="data_preparation",
|
| 558 |
+
objective="Aggregate revenue per category, rank descending, keep the top 3.",
|
| 559 |
+
tool_calls=[
|
| 560 |
+
ToolCall(
|
| 561 |
+
tool="retrieve_data",
|
| 562 |
+
args={
|
| 563 |
+
"ir": {
|
| 564 |
+
"source_id": "src_sales",
|
| 565 |
+
"table_id": "t_orders",
|
| 566 |
+
"select": [
|
| 567 |
+
{"kind": "column", "column_id": "c_category", "alias": "category"},
|
| 568 |
+
{
|
| 569 |
+
"kind": "agg",
|
| 570 |
+
"fn": "sum",
|
| 571 |
+
"column_id": "c_revenue",
|
| 572 |
+
"alias": "total_revenue",
|
| 573 |
+
},
|
| 574 |
+
],
|
| 575 |
+
"group_by": ["c_category"],
|
| 576 |
+
"order_by": [{"column_id": "total_revenue", "dir": "desc"}],
|
| 577 |
+
"limit": 3,
|
| 578 |
+
}
|
| 579 |
+
},
|
| 580 |
+
)
|
| 581 |
+
],
|
| 582 |
+
expected_output="top3_categories",
|
| 583 |
+
success_criteria=(
|
| 584 |
+
"Produced at most 3 rows, one distinct category each, ranked by "
|
| 585 |
+
"total revenue."
|
| 586 |
+
),
|
| 587 |
+
depends_on=["t1"],
|
| 588 |
+
estimated_cost="low",
|
| 589 |
+
),
|
| 590 |
+
],
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
# --------------------------------------------------------------------------- #
|
| 594 |
+
# Example H — infeasible question (see planner.md "When the catalog cannot
|
| 595 |
+
# answer"). "What is our customer churn rate?" against a sales catalog with no
|
| 596 |
+
# subscription/churn data: no task list is forced onto unrelated columns;
|
| 597 |
+
# instead `infeasible_reason` states the gap + the nearest available data.
|
| 598 |
+
# --------------------------------------------------------------------------- #
|
| 599 |
+
|
| 600 |
+
_EXAMPLE_H = TaskList(
|
| 601 |
+
plan_id="example_h",
|
| 602 |
+
goal_restated="Measure the customer churn rate.",
|
| 603 |
+
assumptions=[],
|
| 604 |
+
open_questions=[],
|
| 605 |
+
tasks=[],
|
| 606 |
+
infeasible_reason=(
|
| 607 |
+
"The connected source has no churn or subscription-status data — the "
|
| 608 |
+
"orders table only carries order-level category, revenue, quantity, and "
|
| 609 |
+
"dates. Nearest available analyses: repeat-purchase behaviour or revenue "
|
| 610 |
+
"per customer over time."
|
| 611 |
+
),
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
# --------------------------------------------------------------------------- #
|
| 616 |
+
# Example I — combine two measures per entity (KM-703).
|
| 617 |
+
# "Which category has both the highest revenue and the highest average order
|
| 618 |
+
# quantity?" Shows: each measure is computed in its OWN grouped retrieve_data
|
| 619 |
+
# task (a "${t<id>}" placeholder resolves to a task's LAST output, so the two
|
| 620 |
+
# retrievals must be separate tasks), then analyze_merge aligns them on the
|
| 621 |
+
# shared entity alias. The merged table answers "both A and B" questions that
|
| 622 |
+
# a single query cannot express.
|
| 623 |
+
# --------------------------------------------------------------------------- #
|
| 624 |
+
|
| 625 |
+
_EXAMPLE_I = TaskList(
|
| 626 |
+
plan_id="example_i",
|
| 627 |
+
goal_restated=(
|
| 628 |
+
"Identify the product category with both the highest total revenue and the "
|
| 629 |
+
"highest average order quantity."
|
| 630 |
+
),
|
| 631 |
+
assumptions=[],
|
| 632 |
+
open_questions=[],
|
| 633 |
+
tasks=[
|
| 634 |
+
Task(
|
| 635 |
+
id="t1",
|
| 636 |
+
stage="data_understanding",
|
| 637 |
+
objective="Confirm the sales source exposes category, revenue, and quantity.",
|
| 638 |
+
tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
|
| 639 |
+
expected_output="source_shape",
|
| 640 |
+
success_criteria=(
|
| 641 |
+
"Produced the orders table schema; category, revenue, and quantity "
|
| 642 |
+
"columns are present."
|
| 643 |
+
),
|
| 644 |
+
depends_on=[],
|
| 645 |
+
estimated_cost="low",
|
| 646 |
+
),
|
| 647 |
+
Task(
|
| 648 |
+
id="t2",
|
| 649 |
+
stage="data_preparation",
|
| 650 |
+
objective="Total revenue per category.",
|
| 651 |
+
tool_calls=[
|
| 652 |
+
ToolCall(
|
| 653 |
+
tool="retrieve_data",
|
| 654 |
+
args={
|
| 655 |
+
"ir": {
|
| 656 |
+
"source_id": "src_sales",
|
| 657 |
+
"table_id": "t_orders",
|
| 658 |
+
"select": [
|
| 659 |
+
{"kind": "column", "column_id": "c_category", "alias": "category"},
|
| 660 |
+
{
|
| 661 |
+
"kind": "agg",
|
| 662 |
+
"fn": "sum",
|
| 663 |
+
"column_id": "c_revenue",
|
| 664 |
+
"alias": "total_revenue",
|
| 665 |
+
},
|
| 666 |
+
],
|
| 667 |
+
"group_by": ["c_category"],
|
| 668 |
+
}
|
| 669 |
+
},
|
| 670 |
+
)
|
| 671 |
+
],
|
| 672 |
+
expected_output="revenue_per_category",
|
| 673 |
+
success_criteria="Produced one total-revenue row per category.",
|
| 674 |
+
depends_on=["t1"],
|
| 675 |
+
estimated_cost="low",
|
| 676 |
+
),
|
| 677 |
+
Task(
|
| 678 |
+
id="t3",
|
| 679 |
+
stage="data_preparation",
|
| 680 |
+
objective="Average order quantity per category.",
|
| 681 |
+
tool_calls=[
|
| 682 |
+
ToolCall(
|
| 683 |
+
tool="retrieve_data",
|
| 684 |
+
args={
|
| 685 |
+
"ir": {
|
| 686 |
+
"source_id": "src_sales",
|
| 687 |
+
"table_id": "t_orders",
|
| 688 |
+
"select": [
|
| 689 |
+
{"kind": "column", "column_id": "c_category", "alias": "category"},
|
| 690 |
+
{
|
| 691 |
+
"kind": "agg",
|
| 692 |
+
"fn": "avg",
|
| 693 |
+
"column_id": "c_quantity",
|
| 694 |
+
"alias": "avg_quantity",
|
| 695 |
+
},
|
| 696 |
+
],
|
| 697 |
+
"group_by": ["c_category"],
|
| 698 |
+
}
|
| 699 |
+
},
|
| 700 |
+
)
|
| 701 |
+
],
|
| 702 |
+
expected_output="quantity_per_category",
|
| 703 |
+
success_criteria="Produced one average-quantity row per category.",
|
| 704 |
+
depends_on=["t1"],
|
| 705 |
+
estimated_cost="low",
|
| 706 |
+
),
|
| 707 |
+
Task(
|
| 708 |
+
id="t4",
|
| 709 |
+
stage="evaluation",
|
| 710 |
+
objective="Align both measures per category to find the category leading on both.",
|
| 711 |
+
tool_calls=[
|
| 712 |
+
ToolCall(
|
| 713 |
+
tool="analyze_merge",
|
| 714 |
+
args={
|
| 715 |
+
"data": "${t2}",
|
| 716 |
+
"data_right": "${t3}",
|
| 717 |
+
"on": ["category"],
|
| 718 |
+
},
|
| 719 |
+
)
|
| 720 |
+
],
|
| 721 |
+
expected_output="combined_measures",
|
| 722 |
+
success_criteria=(
|
| 723 |
+
"Produced one row per category carrying both total_revenue and "
|
| 724 |
+
"avg_quantity."
|
| 725 |
+
),
|
| 726 |
+
depends_on=["t2", "t3"],
|
| 727 |
+
estimated_cost="low",
|
| 728 |
+
),
|
| 729 |
+
],
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
|
| 733 |
EXAMPLES: list[tuple[str, TaskList]] = [
|
| 734 |
("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
|
| 735 |
("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
|
|
|
|
| 737 |
("What is the average and total order value per region?", _EXAMPLE_D),
|
| 738 |
("Total revenue for the East and West regions, counting orders of at least 100.", _EXAMPLE_E),
|
| 739 |
("Give me the summary statistics for order revenue and quantity.", _EXAMPLE_F),
|
| 740 |
+
("Which 3 product categories have the best revenue performance?", _EXAMPLE_G),
|
| 741 |
+
("What is our customer churn rate?", _EXAMPLE_H),
|
| 742 |
+
(
|
| 743 |
+
"Which product category has both the highest revenue and the highest average "
|
| 744 |
+
"order quantity?",
|
| 745 |
+
_EXAMPLE_I,
|
| 746 |
+
),
|
| 747 |
]
|
| 748 |
|
| 749 |
|
src/agents/planner/schemas.py
CHANGED
|
@@ -58,3 +58,18 @@ class TaskList(BaseModel):
|
|
| 58 |
assumptions: list[str] = Field(default_factory=list)
|
| 59 |
open_questions: list[str] = Field(default_factory=list)
|
| 60 |
tasks: list[Task] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
assumptions: list[str] = Field(default_factory=list)
|
| 59 |
open_questions: list[str] = Field(default_factory=list)
|
| 60 |
tasks: list[Task] = Field(default_factory=list)
|
| 61 |
+
# Infeasible sentinel (planner.md "When the catalog cannot answer"): set with
|
| 62 |
+
# an EMPTY `tasks` list when no catalog column plausibly holds the requested
|
| 63 |
+
# measure/entity. Explains what is missing and names the nearest available
|
| 64 |
+
# data. The coordinator renders it as an honest data-gap answer instead of
|
| 65 |
+
# running the pipeline — the alternative was the planner force-mapping
|
| 66 |
+
# unrelated columns (observed: `pa` aliased as "revenue").
|
| 67 |
+
infeasible_reason: str | None = Field(
|
| 68 |
+
None,
|
| 69 |
+
description=(
|
| 70 |
+
"Set ONLY when the question cannot be answered from the catalog: no "
|
| 71 |
+
"column plausibly holds the requested measure or entity. State what "
|
| 72 |
+
"is missing and the nearest data that IS available. Leave tasks "
|
| 73 |
+
"empty when set."
|
| 74 |
+
),
|
| 75 |
+
)
|
src/agents/planner/validator.py
CHANGED
|
@@ -61,6 +61,15 @@ class PlannerValidator:
|
|
| 61 |
) -> None:
|
| 62 |
tasks = task_list.tasks
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# Check 6 — plan non-empty and within the task cap.
|
| 65 |
if not tasks:
|
| 66 |
raise PlannerValidationError("plan is empty: at least one task is required")
|
|
@@ -211,24 +220,28 @@ class PlannerValidator:
|
|
| 211 |
requested columns. Resolving points at the referenced task's representative
|
| 212 |
output — its last tool call (matches TaskRunner's `outputs[-1]`).
|
| 213 |
"""
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
@staticmethod
|
| 234 |
def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
|
|
|
|
| 61 |
) -> None:
|
| 62 |
tasks = task_list.tasks
|
| 63 |
|
| 64 |
+
# Infeasible sentinel (planner.md "When the catalog cannot answer"): an
|
| 65 |
+
# empty plan carrying `infeasible_reason` is a VALID outcome — the
|
| 66 |
+
# coordinator renders it as an honest data-gap answer instead of the
|
| 67 |
+
# planner force-mapping the question onto unrelated columns. A non-empty
|
| 68 |
+
# plan keeps normal validation and the reason is ignored (a real plan
|
| 69 |
+
# wins over a hedge).
|
| 70 |
+
if task_list.infeasible_reason and not tasks:
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
# Check 6 — plan non-empty and within the task cap.
|
| 74 |
if not tasks:
|
| 75 |
raise PlannerValidationError("plan is empty: at least one task is required")
|
|
|
|
| 220 |
requested columns. Resolving points at the referenced task's representative
|
| 221 |
output — its last tool call (matches TaskRunner's `outputs[-1]`).
|
| 222 |
"""
|
| 223 |
+
# `data_right` is analyze_merge's second table input (KM-703) — same
|
| 224 |
+
# Pattern A handoff, so it gets the same guard.
|
| 225 |
+
for arg_name in ("data", "data_right"):
|
| 226 |
+
data_arg = call.args.get(arg_name)
|
| 227 |
+
if not isinstance(data_arg, str):
|
| 228 |
+
continue
|
| 229 |
+
match = PLACEHOLDER_RE.fullmatch(data_arg.strip())
|
| 230 |
+
if not match:
|
| 231 |
+
continue
|
| 232 |
+
ref_task = tasks_by_id.get(match.group(1))
|
| 233 |
+
if ref_task is None or not ref_task.tool_calls:
|
| 234 |
+
continue # a dangling placeholder is reported by the DAG check
|
| 235 |
+
ref_tool = ref_task.tool_calls[-1].tool
|
| 236 |
+
ref_spec = registry.get(ref_tool)
|
| 237 |
+
if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES:
|
| 238 |
+
raise PlannerValidationError(
|
| 239 |
+
f"task {task_id}: tool {call.tool!r} takes its {arg_name!r} from "
|
| 240 |
+
f"task {match.group(1)} ({ref_tool!r}, category "
|
| 241 |
+
f"{ref_spec.category!r}), which produces metadata/documents — not "
|
| 242 |
+
"analyzable data rows. Feed analyze_* from a data-producing tool "
|
| 243 |
+
"(e.g. retrieve_data)."
|
| 244 |
+
)
|
| 245 |
|
| 246 |
@staticmethod
|
| 247 |
def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
|
src/agents/refusals.py
CHANGED
|
@@ -56,6 +56,39 @@ _BLOCKED = {
|
|
| 56 |
}
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def out_of_scope_message(message: str) -> str:
|
| 60 |
"""Refusal for a benign but out-of-scope request (the `out_of_scope` intent)."""
|
| 61 |
return _OUT_OF_SCOPE["id" if _is_indonesian(message) else "en"]
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
|
| 59 |
+
# Data-gap: the planner judged the bound sources cannot answer the question
|
| 60 |
+
# (planner.md "When the catalog cannot answer"). Deterministic wrapper on
|
| 61 |
+
# purpose — the model that declined to plan is not re-asked to prose it up.
|
| 62 |
+
# Keyed on the pipeline's reply_language ("Indonesian"/"English"), not marker
|
| 63 |
+
# detection: the upstream language decision is authoritative here.
|
| 64 |
+
_DATA_GAP = {
|
| 65 |
+
"en": (
|
| 66 |
+
"I can't answer that from the data sources connected to this analysis. "
|
| 67 |
+
"{reason}You can bind a source that holds this data, or ask me what's "
|
| 68 |
+
"available (try /help or \"what data do I have?\")."
|
| 69 |
+
),
|
| 70 |
+
"id": (
|
| 71 |
+
"Saya tidak bisa menjawab itu dari sumber data yang terhubung ke "
|
| 72 |
+
"analisis ini. {reason}Anda bisa menambahkan sumber yang memuat data "
|
| 73 |
+
"tersebut, atau tanyakan data apa yang tersedia (coba /help atau "
|
| 74 |
+
"\"data apa yang saya punya?\")."
|
| 75 |
+
),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def data_gap_message(reason: str | None, reply_language: str | None = None) -> str:
|
| 80 |
+
"""Answer for an infeasible analysis: the bound sources lack the asked-for data.
|
| 81 |
+
|
| 82 |
+
`reason` is the planner's `infeasible_reason` (may be None/empty);
|
| 83 |
+
`reply_language` is the pipeline's detected language ("Indonesian"/"English").
|
| 84 |
+
"""
|
| 85 |
+
detail = (reason or "").strip()
|
| 86 |
+
if detail and not detail.endswith((".", "!", "?")):
|
| 87 |
+
detail += "."
|
| 88 |
+
lang = "id" if reply_language == "Indonesian" else "en"
|
| 89 |
+
return _DATA_GAP[lang].format(reason=f"{detail} " if detail else "")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def out_of_scope_message(message: str) -> str:
|
| 93 |
"""Refusal for a benign but out-of-scope request (the `out_of_scope` intent)."""
|
| 94 |
return _OUT_OF_SCOPE["id" if _is_indonesian(message) else "en"]
|
src/agents/report/generator.py
CHANGED
|
@@ -24,13 +24,17 @@ from langchain_openai import AzureChatOpenAI
|
|
| 24 |
|
| 25 |
from src.middlewares.logging import get_logger
|
| 26 |
|
|
|
|
| 27 |
from ..slow_path.schemas import AnalysisRecord, TaskSummary
|
| 28 |
from .errors import ReportError
|
| 29 |
from .readiness import has_successful_analysis
|
| 30 |
from .schemas import (
|
| 31 |
AnalysisReport,
|
| 32 |
AttributedNote,
|
|
|
|
|
|
|
| 33 |
DataSourceRef,
|
|
|
|
| 34 |
ProblemStatement,
|
| 35 |
ReportFinding,
|
| 36 |
ReportSummaryNarrative,
|
|
@@ -40,6 +44,15 @@ logger = get_logger("report_generator")
|
|
| 40 |
|
| 41 |
_FALLBACK_SUMMARY = "Automated summary unavailable — see the findings below."
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
# CRISP-DM phases in narrative order, with human labels for the method appendix.
|
| 44 |
_STAGE_LABELS: list[tuple[str, str]] = [
|
| 45 |
("data_understanding", "Data understanding"),
|
|
@@ -48,6 +61,13 @@ _STAGE_LABELS: list[tuple[str, str]] = [
|
|
| 48 |
("evaluation", "Evaluation"),
|
| 49 |
]
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
# Friendly labels for the catalog's internal source_type enum, shown in Data Sources.
|
| 52 |
_SOURCE_TYPE_LABELS: dict[str, str] = {
|
| 53 |
"schema": "Database",
|
|
@@ -113,16 +133,88 @@ def _collect_findings(records: list[AnalysisRecord]) -> list[ReportFinding]:
|
|
| 113 |
return out
|
| 114 |
|
| 115 |
|
| 116 |
-
def
|
| 117 |
-
#
|
| 118 |
-
#
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
for rec in records:
|
| 121 |
for text in getattr(rec, field):
|
| 122 |
-
|
| 123 |
-
if
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
def _collect_method_steps(records: list[AnalysisRecord]) -> list[TaskSummary]:
|
|
@@ -175,24 +267,72 @@ def _build_data_sources(
|
|
| 175 |
|
| 176 |
|
| 177 |
def _build_human_content(
|
| 178 |
-
ps: ProblemStatement,
|
|
|
|
|
|
|
|
|
|
| 179 |
) -> str:
|
|
|
|
|
|
|
| 180 |
sections = []
|
| 181 |
if ps.objective:
|
| 182 |
sections.append("# Objective\n" + ps.objective)
|
| 183 |
if ps.business_questions:
|
| 184 |
sections.append(
|
| 185 |
-
"# Business questions\n"
|
|
|
|
| 186 |
)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
if caveats:
|
| 192 |
sections.append("# Caveats\n" + "\n".join(f"- {c.text}" for c in caveats))
|
|
|
|
| 193 |
return "\n\n".join(sections)
|
| 194 |
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
# Inline code spans (one or more backticks). Content inside is already literal in
|
| 197 |
# Markdown/MDX, so escaping within them would only surface a visible backslash
|
| 198 |
# (e.g. `product\_id`). We keep code spans verbatim and escape only around them.
|
|
@@ -254,6 +394,17 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 254 |
if report.executive_summary:
|
| 255 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
if report.findings:
|
| 258 |
# Group findings by their originating analysis (record) so results from
|
| 259 |
# different questions read as separate analyses, not one flat, seemingly
|
|
@@ -275,6 +426,23 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 275 |
if grouped:
|
| 276 |
block.append(f"### {_mdx_escape(report.record_goals.get(rid) or 'Analysis')}")
|
| 277 |
block.extend(f"{i}. {_mdx_escape(f.text)}" for i, f in enumerate(group, 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
blocks.append("\n".join(block))
|
| 279 |
parts.append("\n\n".join(blocks))
|
| 280 |
|
|
@@ -296,26 +464,54 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 296 |
)
|
| 297 |
parts.append("\n".join(lines))
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
parts.append("\n".join(lines))
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
return "\n\n---\n\n".join(parts)
|
| 321 |
|
|
@@ -363,24 +559,36 @@ class ReportGenerator:
|
|
| 363 |
user_id: str | None = None,
|
| 364 |
problem_statement: ProblemStatement | None = None,
|
| 365 |
user_name: str | None = None,
|
|
|
|
| 366 |
) -> AnalysisReport:
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
| 369 |
# analysis step (the same set the report floor validates). Fully-failed runs
|
| 370 |
-
#
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
| 372 |
if not records:
|
| 373 |
raise ReportError(f"no analyses recorded for {analysis_id!r} yet")
|
| 374 |
|
| 375 |
ps = problem_statement or ProblemStatement()
|
|
|
|
|
|
|
|
|
|
| 376 |
findings = _collect_findings(records)
|
| 377 |
-
caveats = _collect_notes(records, "caveats")
|
| 378 |
-
open_questions = _collect_notes(records, "open_questions")
|
| 379 |
method_steps = _collect_method_steps(records)
|
| 380 |
data_sources = _build_data_sources(
|
| 381 |
records, await self._read_catalog(user_id, analysis_id)
|
| 382 |
)
|
| 383 |
-
executive_summary = await self._summarize(
|
|
|
|
|
|
|
| 384 |
|
| 385 |
report = AnalysisReport(
|
| 386 |
analysis_id=analysis_id,
|
|
@@ -392,9 +600,13 @@ class ReportGenerator:
|
|
| 392 |
record_ids=[r.record_id for r in records],
|
| 393 |
record_goals={r.record_id: r.goal_restated for r in records},
|
| 394 |
executive_summary=executive_summary,
|
|
|
|
| 395 |
findings=findings,
|
| 396 |
caveats=caveats,
|
| 397 |
open_questions=open_questions,
|
|
|
|
|
|
|
|
|
|
| 398 |
data_sources=data_sources,
|
| 399 |
method_steps=method_steps,
|
| 400 |
)
|
|
@@ -423,14 +635,20 @@ class ReportGenerator:
|
|
| 423 |
return None
|
| 424 |
|
| 425 |
async def _summarize(
|
| 426 |
-
self,
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
try:
|
| 430 |
narrative: ReportSummaryNarrative = await self._ensure_chain().ainvoke(
|
| 431 |
{"human_content": human_content}
|
| 432 |
)
|
| 433 |
-
return narrative.executive_summary
|
| 434 |
except Exception as exc: # D1: degrade, don't fail the whole report
|
| 435 |
-
logger.warning("report summary LLM failed; using fallback", error=
|
| 436 |
-
return _FALLBACK_SUMMARY
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
from src.middlewares.logging import get_logger
|
| 26 |
|
| 27 |
+
from ..language import detect_reply_language
|
| 28 |
from ..slow_path.schemas import AnalysisRecord, TaskSummary
|
| 29 |
from .errors import ReportError
|
| 30 |
from .readiness import has_successful_analysis
|
| 31 |
from .schemas import (
|
| 32 |
AnalysisReport,
|
| 33 |
AttributedNote,
|
| 34 |
+
BQAnswerDraft,
|
| 35 |
+
BusinessQuestionAnswer,
|
| 36 |
DataSourceRef,
|
| 37 |
+
EvidenceTable,
|
| 38 |
ProblemStatement,
|
| 39 |
ReportFinding,
|
| 40 |
ReportSummaryNarrative,
|
|
|
|
| 44 |
|
| 45 |
_FALLBACK_SUMMARY = "Automated summary unavailable — see the findings below."
|
| 46 |
|
| 47 |
+
# Caps keeping the deterministic sections readable on multi-record analyses.
|
| 48 |
+
_MAX_CAVEATS = 12
|
| 49 |
+
_MAX_OPEN_QUESTIONS = 10
|
| 50 |
+
_EVIDENCE_MAX_ROWS = 10
|
| 51 |
+
_EVIDENCE_MAX_TABLES = 3 # per record
|
| 52 |
+
# Wider tables are raw analysis *inputs* (e.g. a 19-column correlation pull), not
|
| 53 |
+
# presentable evidence — grouped/top-N/merge results are always narrow.
|
| 54 |
+
_EVIDENCE_MAX_COLS = 8
|
| 55 |
+
|
| 56 |
# CRISP-DM phases in narrative order, with human labels for the method appendix.
|
| 57 |
_STAGE_LABELS: list[tuple[str, str]] = [
|
| 58 |
("data_understanding", "Data understanding"),
|
|
|
|
| 61 |
("evaluation", "Evaluation"),
|
| 62 |
]
|
| 63 |
|
| 64 |
+
# Human labels for BusinessQuestionAnswer.status in the rendered markdown.
|
| 65 |
+
_BQ_STATUS_LABELS: dict[str, str] = {
|
| 66 |
+
"answered": "Answered",
|
| 67 |
+
"partial": "Partially answered",
|
| 68 |
+
"unanswered": "Unanswered",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
# Friendly labels for the catalog's internal source_type enum, shown in Data Sources.
|
| 72 |
_SOURCE_TYPE_LABELS: dict[str, str] = {
|
| 73 |
"schema": "Database",
|
|
|
|
| 133 |
return out
|
| 134 |
|
| 135 |
|
| 136 |
+
def _note_key(text: str) -> str:
|
| 137 |
+
# Dedupe key: collapse whitespace, drop trailing punctuation, casefold — so the
|
| 138 |
+
# Assembler's near-identical rephrasings ("Data is capped at 500 rows." vs
|
| 139 |
+
# "data is capped at 500 rows") merge into one note.
|
| 140 |
+
return " ".join(text.split()).rstrip(".!").casefold()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _collect_notes(records: list[AnalysisRecord], field: str, cap: int) -> list[AttributedNote]:
|
| 144 |
+
# Caveats / open_questions are deduped by normalized text; a merged note keeps
|
| 145 |
+
# the first phrasing seen and cites every record it came from (plural
|
| 146 |
+
# record_ids). Capped so a many-record analysis stays readable.
|
| 147 |
+
merged: dict[str, AttributedNote] = {}
|
| 148 |
for rec in records:
|
| 149 |
for text in getattr(rec, field):
|
| 150 |
+
key = _note_key(text)
|
| 151 |
+
if not key:
|
| 152 |
+
continue
|
| 153 |
+
note = merged.setdefault(key, AttributedNote(text=text))
|
| 154 |
+
if rec.record_id not in note.record_ids:
|
| 155 |
+
note.record_ids.append(rec.record_id)
|
| 156 |
+
return list(merged.values())[:cap]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _fmt_cell(value) -> str:
|
| 160 |
+
if value is None:
|
| 161 |
+
return "—"
|
| 162 |
+
if isinstance(value, float):
|
| 163 |
+
return f"{value:g}" # 1234.5 not 1234.5000000001; no trailing zeros
|
| 164 |
+
return str(value)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _collect_evidence(records: list[AnalysisRecord]) -> dict[str, list[EvidenceTable]]:
|
| 168 |
+
"""Copy small result tables out of each record's `results_snapshot` (INV-4).
|
| 169 |
+
|
| 170 |
+
Table-kind tool outputs only — the copy-paste-able slices (top-N rankings,
|
| 171 |
+
grouped aggregates, merges). `check_*` outputs are skipped (catalog metadata,
|
| 172 |
+
not evidence). Rows and tables-per-record are capped so a wide retrieval
|
| 173 |
+
can't balloon the report.
|
| 174 |
+
"""
|
| 175 |
+
out: dict[str, list[EvidenceTable]] = {}
|
| 176 |
+
for rec in records:
|
| 177 |
+
tables: list[EvidenceTable] = []
|
| 178 |
+
for result in rec.results_snapshot.values():
|
| 179 |
+
for output in result.outputs:
|
| 180 |
+
if len(tables) >= _EVIDENCE_MAX_TABLES:
|
| 181 |
+
break
|
| 182 |
+
if output.tool in ("check_data", "check_knowledge"):
|
| 183 |
+
continue
|
| 184 |
+
if output.kind != "table" or not output.columns or not output.rows:
|
| 185 |
+
continue
|
| 186 |
+
if len(output.columns) > _EVIDENCE_MAX_COLS:
|
| 187 |
+
continue
|
| 188 |
+
tables.append(
|
| 189 |
+
EvidenceTable(
|
| 190 |
+
title=result.objective,
|
| 191 |
+
columns=[str(c) for c in output.columns],
|
| 192 |
+
rows=[
|
| 193 |
+
[_fmt_cell(v) for v in row]
|
| 194 |
+
for row in output.rows[:_EVIDENCE_MAX_ROWS]
|
| 195 |
+
],
|
| 196 |
+
truncated=len(output.rows) > _EVIDENCE_MAX_ROWS,
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
if tables:
|
| 200 |
+
out[rec.record_id] = tables
|
| 201 |
+
return out
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _unresolved_note(rec: AnalysisRecord) -> AttributedNote:
|
| 205 |
+
# Goal + the record's own first caveat as the "why" — both Assembler-authored,
|
| 206 |
+
# nothing new is synthesized here.
|
| 207 |
+
text = rec.goal_restated or "Analysis run"
|
| 208 |
+
reason = next(iter(rec.caveats), None)
|
| 209 |
+
if reason:
|
| 210 |
+
text += f" — {reason}"
|
| 211 |
+
return AttributedNote(text=text, record_ids=[rec.record_id])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _excluded_note(rec: AnalysisRecord) -> AttributedNote:
|
| 215 |
+
return AttributedNote(
|
| 216 |
+
text=rec.goal_restated or rec.record_id, record_ids=[rec.record_id]
|
| 217 |
+
)
|
| 218 |
|
| 219 |
|
| 220 |
def _collect_method_steps(records: list[AnalysisRecord]) -> list[TaskSummary]:
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
def _build_human_content(
|
| 270 |
+
ps: ProblemStatement,
|
| 271 |
+
records: list[AnalysisRecord],
|
| 272 |
+
caveats: list[AttributedNote],
|
| 273 |
+
reply_language: str,
|
| 274 |
) -> str:
|
| 275 |
+
# Questions and analyses are NUMBERED so the model can reference them by index
|
| 276 |
+
# in `bq_answers` (question_index / analysis_indexes) — it never reproduces ids.
|
| 277 |
sections = []
|
| 278 |
if ps.objective:
|
| 279 |
sections.append("# Objective\n" + ps.objective)
|
| 280 |
if ps.business_questions:
|
| 281 |
sections.append(
|
| 282 |
+
"# Business questions\n"
|
| 283 |
+
+ "\n".join(f"{i}. {q}" for i, q in enumerate(ps.business_questions, 1))
|
| 284 |
)
|
| 285 |
+
lines = ["# Analyses (findings already finalized — synthesize, do not add numbers)"]
|
| 286 |
+
for i, rec in enumerate(records, 1):
|
| 287 |
+
lines.append(f"Analysis {i}: {rec.goal_restated}")
|
| 288 |
+
seen: set[str] = set()
|
| 289 |
+
for text in rec.findings:
|
| 290 |
+
if text in seen:
|
| 291 |
+
continue
|
| 292 |
+
seen.add(text)
|
| 293 |
+
lines.append(f"- {text}")
|
| 294 |
+
sections.append("\n".join(lines))
|
| 295 |
if caveats:
|
| 296 |
sections.append("# Caveats\n" + "\n".join(f"- {c.text}" for c in caveats))
|
| 297 |
+
sections.append("# Reply language\n" + reply_language)
|
| 298 |
return "\n\n".join(sections)
|
| 299 |
|
| 300 |
|
| 301 |
+
def _resolve_bq_answers(
|
| 302 |
+
drafts: list[BQAnswerDraft],
|
| 303 |
+
questions: list[str],
|
| 304 |
+
records: list[AnalysisRecord],
|
| 305 |
+
) -> list[BusinessQuestionAnswer]:
|
| 306 |
+
"""Map the LLM's index-based drafts onto real question text and record ids.
|
| 307 |
+
|
| 308 |
+
Every question gets a row (unanswered when the model skipped it);
|
| 309 |
+
out-of-range indexes are silently dropped.
|
| 310 |
+
"""
|
| 311 |
+
if not questions:
|
| 312 |
+
return []
|
| 313 |
+
by_index = {d.question_index: d for d in drafts}
|
| 314 |
+
out: list[BusinessQuestionAnswer] = []
|
| 315 |
+
for i, question in enumerate(questions, 1):
|
| 316 |
+
draft = by_index.get(i)
|
| 317 |
+
if draft is None:
|
| 318 |
+
out.append(BusinessQuestionAnswer(question=question))
|
| 319 |
+
continue
|
| 320 |
+
record_ids = [
|
| 321 |
+
records[j - 1].record_id
|
| 322 |
+
for j in draft.analysis_indexes
|
| 323 |
+
if 1 <= j <= len(records)
|
| 324 |
+
]
|
| 325 |
+
out.append(
|
| 326 |
+
BusinessQuestionAnswer(
|
| 327 |
+
question=question,
|
| 328 |
+
answer=draft.answer,
|
| 329 |
+
status=draft.status,
|
| 330 |
+
record_ids=record_ids,
|
| 331 |
+
)
|
| 332 |
+
)
|
| 333 |
+
return out
|
| 334 |
+
|
| 335 |
+
|
| 336 |
# Inline code spans (one or more backticks). Content inside is already literal in
|
| 337 |
# Markdown/MDX, so escaping within them would only surface a visible backslash
|
| 338 |
# (e.g. `product\_id`). We keep code spans verbatim and escape only around them.
|
|
|
|
| 394 |
if report.executive_summary:
|
| 395 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 396 |
|
| 397 |
+
if report.bq_answers:
|
| 398 |
+
lines = ["## Answers to Business Questions"]
|
| 399 |
+
for i, a in enumerate(report.bq_answers, 1):
|
| 400 |
+
label = _BQ_STATUS_LABELS.get(a.status, a.status)
|
| 401 |
+
entry = f"{i}. **{_mdx_escape(a.question)}** — *{label}*"
|
| 402 |
+
if a.answer:
|
| 403 |
+
# LLM prose (same authorship as the executive summary): not escaped.
|
| 404 |
+
entry += f"\n {a.answer}"
|
| 405 |
+
lines.append(entry)
|
| 406 |
+
parts.append("\n".join(lines))
|
| 407 |
+
|
| 408 |
if report.findings:
|
| 409 |
# Group findings by their originating analysis (record) so results from
|
| 410 |
# different questions read as separate analyses, not one flat, seemingly
|
|
|
|
| 426 |
if grouped:
|
| 427 |
block.append(f"### {_mdx_escape(report.record_goals.get(rid) or 'Analysis')}")
|
| 428 |
block.extend(f"{i}. {_mdx_escape(f.text)}" for i, f in enumerate(group, 1))
|
| 429 |
+
# Evidence tables (copied result slices) under the findings they back,
|
| 430 |
+
# so the numbers are copy-paste-ready next to the claims.
|
| 431 |
+
for tbl in report.evidence_tables.get(rid, []):
|
| 432 |
+
if not tbl.columns:
|
| 433 |
+
continue
|
| 434 |
+
block.append("") # blank line: terminate the list before the table
|
| 435 |
+
if tbl.title:
|
| 436 |
+
block.append(f"**{_mdx_escape(tbl.title)}**")
|
| 437 |
+
block.append("")
|
| 438 |
+
block.append("| " + " | ".join(_mdx_escape(c) for c in tbl.columns) + " |")
|
| 439 |
+
block.append("|" + "---|" * len(tbl.columns))
|
| 440 |
+
block.extend(
|
| 441 |
+
"| " + " | ".join(_mdx_escape(c) for c in row) + " |"
|
| 442 |
+
for row in tbl.rows
|
| 443 |
+
)
|
| 444 |
+
if tbl.truncated:
|
| 445 |
+
block.append(f"\n*(first {len(tbl.rows)} rows shown)*")
|
| 446 |
blocks.append("\n".join(block))
|
| 447 |
parts.append("\n\n".join(blocks))
|
| 448 |
|
|
|
|
| 464 |
)
|
| 465 |
parts.append("\n".join(lines))
|
| 466 |
|
| 467 |
+
# ## Notes & Limitations — dropped from the rendered report 2026-07-09 (team
|
| 468 |
+
# decision: compact report). caveats/open_questions still populate the
|
| 469 |
+
# AnalysisReport JSON body; only the markdown section is gone.
|
| 470 |
+
# if report.caveats or report.open_questions:
|
| 471 |
+
# lines = ["## Notes & Limitations"]
|
| 472 |
+
# for n in report.caveats:
|
| 473 |
+
# lines.append(f"- {_mdx_escape(n.text)}")
|
| 474 |
+
# for n in report.open_questions:
|
| 475 |
+
# lines.append(f"- Open: {_mdx_escape(n.text)}")
|
| 476 |
+
# parts.append("\n".join(lines))
|
| 477 |
+
|
| 478 |
+
# ## Attempted, Unresolved — dropped from the rendered report 2026-07-09 (team
|
| 479 |
+
# decision: compact report). Failed runs still populate `report.unresolved`
|
| 480 |
+
# (JSON body) and the /records curation list; only the markdown section is gone.
|
| 481 |
+
# if report.unresolved:
|
| 482 |
+
# lines = [
|
| 483 |
+
# "## Attempted, Unresolved",
|
| 484 |
+
# "*These analyses ran but produced no usable evidence;"
|
| 485 |
+
# " they are not reflected in the findings above.*",
|
| 486 |
+
# "",
|
| 487 |
+
# ]
|
| 488 |
+
# lines.extend(f"- {_mdx_escape(n.text)}" for n in report.unresolved)
|
| 489 |
+
# parts.append("\n".join(lines))
|
| 490 |
+
|
| 491 |
+
if report.excluded:
|
| 492 |
+
lines = [
|
| 493 |
+
"## Excluded Analyses",
|
| 494 |
+
"*Excluded from this report at generation time.*",
|
| 495 |
+
"",
|
| 496 |
+
]
|
| 497 |
+
lines.extend(f"- {_mdx_escape(n.text)}" for n in report.excluded)
|
| 498 |
parts.append("\n".join(lines))
|
| 499 |
|
| 500 |
+
# ## How This Was Analyzed — dropped from the rendered report 2026-07-09 (team
|
| 501 |
+
# decision: compact report). method_steps (and _STAGE_LABELS above) stay for the
|
| 502 |
+
# AnalysisReport JSON body; only the markdown section is gone.
|
| 503 |
+
# if report.method_steps:
|
| 504 |
+
# lines = ["## How This Was Analyzed"]
|
| 505 |
+
# for stage_key, label in _STAGE_LABELS:
|
| 506 |
+
# steps = [s for s in report.method_steps if s.stage == stage_key]
|
| 507 |
+
# if not steps:
|
| 508 |
+
# continue
|
| 509 |
+
# rendered = "; ".join(
|
| 510 |
+
# f"{', '.join(_mdx_escape(t) for t in s.tools_used) or '—'} ({s.status})"
|
| 511 |
+
# for s in steps
|
| 512 |
+
# )
|
| 513 |
+
# lines.append(f"**{label}** — {rendered}")
|
| 514 |
+
# parts.append("\n".join(lines))
|
| 515 |
|
| 516 |
return "\n\n---\n\n".join(parts)
|
| 517 |
|
|
|
|
| 559 |
user_id: str | None = None,
|
| 560 |
problem_statement: ProblemStatement | None = None,
|
| 561 |
user_name: str | None = None,
|
| 562 |
+
exclude_record_ids: list[str] | None = None,
|
| 563 |
) -> AnalysisReport:
|
| 564 |
+
all_records = await self._ensure_record_store().list_for_analysis(analysis_id)
|
| 565 |
+
excluded_ids = set(exclude_record_ids or [])
|
| 566 |
+
excluded = [r for r in all_records if r.record_id in excluded_ids]
|
| 567 |
+
kept = [r for r in all_records if r.record_id not in excluded_ids]
|
| 568 |
+
# The report body reflects only substantive runs — those with a successful
|
| 569 |
# analysis step (the same set the report floor validates). Fully-failed runs
|
| 570 |
+
# can't contradict the real findings, but they are not dropped silently
|
| 571 |
+
# either: they surface in the JSON `unresolved` list and the /records
|
| 572 |
+
# curation endpoint (the rendered markdown section was dropped 2026-07-09).
|
| 573 |
+
records = [r for r in kept if has_successful_analysis(r)]
|
| 574 |
+
unresolved_records = [r for r in kept if not has_successful_analysis(r)]
|
| 575 |
if not records:
|
| 576 |
raise ReportError(f"no analyses recorded for {analysis_id!r} yet")
|
| 577 |
|
| 578 |
ps = problem_statement or ProblemStatement()
|
| 579 |
+
reply_language = detect_reply_language(
|
| 580 |
+
None, goal_texts=[ps.objective, *ps.business_questions]
|
| 581 |
+
)
|
| 582 |
findings = _collect_findings(records)
|
| 583 |
+
caveats = _collect_notes(records, "caveats", _MAX_CAVEATS)
|
| 584 |
+
open_questions = _collect_notes(records, "open_questions", _MAX_OPEN_QUESTIONS)
|
| 585 |
method_steps = _collect_method_steps(records)
|
| 586 |
data_sources = _build_data_sources(
|
| 587 |
records, await self._read_catalog(user_id, analysis_id)
|
| 588 |
)
|
| 589 |
+
executive_summary, bq_answers = await self._summarize(
|
| 590 |
+
ps, records, caveats, reply_language
|
| 591 |
+
)
|
| 592 |
|
| 593 |
report = AnalysisReport(
|
| 594 |
analysis_id=analysis_id,
|
|
|
|
| 600 |
record_ids=[r.record_id for r in records],
|
| 601 |
record_goals={r.record_id: r.goal_restated for r in records},
|
| 602 |
executive_summary=executive_summary,
|
| 603 |
+
bq_answers=bq_answers,
|
| 604 |
findings=findings,
|
| 605 |
caveats=caveats,
|
| 606 |
open_questions=open_questions,
|
| 607 |
+
unresolved=[_unresolved_note(r) for r in unresolved_records],
|
| 608 |
+
excluded=[_excluded_note(r) for r in excluded],
|
| 609 |
+
evidence_tables=_collect_evidence(records),
|
| 610 |
data_sources=data_sources,
|
| 611 |
method_steps=method_steps,
|
| 612 |
)
|
|
|
|
| 635 |
return None
|
| 636 |
|
| 637 |
async def _summarize(
|
| 638 |
+
self,
|
| 639 |
+
ps: ProblemStatement,
|
| 640 |
+
records: list[AnalysisRecord],
|
| 641 |
+
caveats: list[AttributedNote],
|
| 642 |
+
reply_language: str,
|
| 643 |
+
) -> tuple[str, list[BusinessQuestionAnswer]]:
|
| 644 |
+
human_content = _build_human_content(ps, records, caveats, reply_language)
|
| 645 |
try:
|
| 646 |
narrative: ReportSummaryNarrative = await self._ensure_chain().ainvoke(
|
| 647 |
{"human_content": human_content}
|
| 648 |
)
|
|
|
|
| 649 |
except Exception as exc: # D1: degrade, don't fail the whole report
|
| 650 |
+
logger.warning("report summary LLM failed; using fallback", error=repr(exc))
|
| 651 |
+
return _FALLBACK_SUMMARY, []
|
| 652 |
+
return narrative.executive_summary, _resolve_bq_answers(
|
| 653 |
+
narrative.bq_answers, ps.business_questions, records
|
| 654 |
+
)
|
src/agents/report/schemas.py
CHANGED
|
@@ -14,12 +14,15 @@ See CHECKPOINT_PLAN_2026-06-17.md decision #8.
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
from datetime import datetime
|
|
|
|
| 17 |
from uuid import uuid4
|
| 18 |
|
| 19 |
from pydantic import BaseModel, Field
|
| 20 |
|
| 21 |
from ..slow_path.schemas import TaskSummary
|
| 22 |
|
|
|
|
|
|
|
| 23 |
|
| 24 |
class ProblemStatement(BaseModel):
|
| 25 |
"""The analysis goal, frozen into each report at generation time.
|
|
@@ -66,10 +69,53 @@ class AttributedNote(BaseModel):
|
|
| 66 |
record_ids: list[str] = Field(default_factory=list)
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
class ReportSummaryNarrative(BaseModel):
|
| 70 |
"""The ONLY LLM-authored part of the report (with_structured_output target)."""
|
| 71 |
|
| 72 |
executive_summary: str
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
class AnalysisReport(BaseModel):
|
|
@@ -88,10 +134,18 @@ class AnalysisReport(BaseModel):
|
|
| 88 |
record_goals: dict[str, str] = Field(default_factory=dict)
|
| 89 |
# LLM-authored.
|
| 90 |
executive_summary: str = ""
|
|
|
|
| 91 |
# Deterministic pass-through from records.
|
| 92 |
findings: list[ReportFinding] = Field(default_factory=list)
|
| 93 |
caveats: list[AttributedNote] = Field(default_factory=list)
|
| 94 |
open_questions: list[AttributedNote] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
data_sources: list[DataSourceRef] = Field(default_factory=list)
|
| 96 |
method_steps: list[TaskSummary] = Field(default_factory=list) # carries `stage`
|
| 97 |
rendered_markdown: str = ""
|
|
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
from datetime import datetime
|
| 17 |
+
from typing import Literal
|
| 18 |
from uuid import uuid4
|
| 19 |
|
| 20 |
from pydantic import BaseModel, Field
|
| 21 |
|
| 22 |
from ..slow_path.schemas import TaskSummary
|
| 23 |
|
| 24 |
+
BQStatus = Literal["answered", "partial", "unanswered"]
|
| 25 |
+
|
| 26 |
|
| 27 |
class ProblemStatement(BaseModel):
|
| 28 |
"""The analysis goal, frozen into each report at generation time.
|
|
|
|
| 69 |
record_ids: list[str] = Field(default_factory=list)
|
| 70 |
|
| 71 |
|
| 72 |
+
class BusinessQuestionAnswer(BaseModel):
|
| 73 |
+
"""A per-business-question answer, grounded only in the records' findings.
|
| 74 |
+
|
| 75 |
+
Drafted by the same single LLM call that authors the executive summary; the
|
| 76 |
+
question text and `record_ids` are resolved from the model's index-based
|
| 77 |
+
references by code, so the model never has to reproduce an id verbatim.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
question: str
|
| 81 |
+
answer: str = ""
|
| 82 |
+
status: BQStatus = "unanswered"
|
| 83 |
+
record_ids: list[str] = Field(default_factory=list) # records backing the answer
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class EvidenceTable(BaseModel):
|
| 87 |
+
"""A small result table copied verbatim from a record's `results_snapshot`.
|
| 88 |
+
|
| 89 |
+
Deterministic pass-through (INV-4): code stringifies the cells and caps the
|
| 90 |
+
rows; nothing here is LLM-authored. `truncated` marks that rows were dropped
|
| 91 |
+
by the cap.
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
title: str = "" # the producing task's objective
|
| 95 |
+
columns: list[str] = Field(default_factory=list)
|
| 96 |
+
rows: list[list[str]] = Field(default_factory=list)
|
| 97 |
+
truncated: bool = False
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class BQAnswerDraft(BaseModel):
|
| 101 |
+
"""One business-question answer as the LLM emits it — index-based references.
|
| 102 |
+
|
| 103 |
+
`question_index` / `analysis_indexes` are 1-based positions into the numbered
|
| 104 |
+
lists shown in the human message; the generator maps them back to real
|
| 105 |
+
question text and record ids (out-of-range indexes are dropped).
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
question_index: int
|
| 109 |
+
answer: str = ""
|
| 110 |
+
status: BQStatus = "unanswered"
|
| 111 |
+
analysis_indexes: list[int] = Field(default_factory=list)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
class ReportSummaryNarrative(BaseModel):
|
| 115 |
"""The ONLY LLM-authored part of the report (with_structured_output target)."""
|
| 116 |
|
| 117 |
executive_summary: str
|
| 118 |
+
bq_answers: list[BQAnswerDraft] = Field(default_factory=list)
|
| 119 |
|
| 120 |
|
| 121 |
class AnalysisReport(BaseModel):
|
|
|
|
| 134 |
record_goals: dict[str, str] = Field(default_factory=dict)
|
| 135 |
# LLM-authored.
|
| 136 |
executive_summary: str = ""
|
| 137 |
+
bq_answers: list[BusinessQuestionAnswer] = Field(default_factory=list)
|
| 138 |
# Deterministic pass-through from records.
|
| 139 |
findings: list[ReportFinding] = Field(default_factory=list)
|
| 140 |
caveats: list[AttributedNote] = Field(default_factory=list)
|
| 141 |
open_questions: list[AttributedNote] = Field(default_factory=list)
|
| 142 |
+
# Honesty sections: runs that produced no usable evidence (attempted but every
|
| 143 |
+
# analyze step failed) and records the report author excluded at generation.
|
| 144 |
+
# Both are outside `record_ids`/`record_goals` — they contribute no findings.
|
| 145 |
+
unresolved: list[AttributedNote] = Field(default_factory=list)
|
| 146 |
+
excluded: list[AttributedNote] = Field(default_factory=list)
|
| 147 |
+
# record_id -> small result tables copied from that record's results_snapshot.
|
| 148 |
+
evidence_tables: dict[str, list[EvidenceTable]] = Field(default_factory=dict)
|
| 149 |
data_sources: list[DataSourceRef] = Field(default_factory=list)
|
| 150 |
method_steps: list[TaskSummary] = Field(default_factory=list) # carries `stage`
|
| 151 |
rendered_markdown: str = ""
|
src/agents/slow_path/coordinator.py
CHANGED
|
@@ -11,13 +11,16 @@ See AGENT_ARCHITECTURE_CONTEXT_new.md §5.2 / §6.1.
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from collections.abc import Awaitable, Callable
|
|
|
|
| 14 |
|
| 15 |
from ...catalog.models import Catalog
|
| 16 |
from ..planner.contracts import BusinessContext, ToolRegistry
|
| 17 |
from ..planner.inputs import Constraints
|
|
|
|
| 18 |
from ..planner.service import PlannerService
|
|
|
|
| 19 |
from .assembler import Assembler
|
| 20 |
-
from .schemas import AssembledOutput
|
| 21 |
from .task_runner import TaskRunner
|
| 22 |
|
| 23 |
|
|
@@ -54,6 +57,13 @@ class SlowPathCoordinator:
|
|
| 54 |
task_list = await self._planner.plan(
|
| 55 |
context, catalog, self._registry, query, constraints, **plan_kw
|
| 56 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
if progress:
|
| 58 |
await progress(f"Running {len(task_list.tasks)} analysis steps…")
|
| 59 |
run_state = await self._task_runner.run(
|
|
@@ -65,3 +75,25 @@ class SlowPathCoordinator:
|
|
| 65 |
return await self._assembler.assemble(
|
| 66 |
run_state, context, question=query, reply_language=reply_language, **asm_kw
|
| 67 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from collections.abc import Awaitable, Callable
|
| 14 |
+
from datetime import UTC, datetime
|
| 15 |
|
| 16 |
from ...catalog.models import Catalog
|
| 17 |
from ..planner.contracts import BusinessContext, ToolRegistry
|
| 18 |
from ..planner.inputs import Constraints
|
| 19 |
+
from ..planner.schemas import TaskList
|
| 20 |
from ..planner.service import PlannerService
|
| 21 |
+
from ..refusals import data_gap_message
|
| 22 |
from .assembler import Assembler
|
| 23 |
+
from .schemas import AnalysisRecord, AssembledOutput
|
| 24 |
from .task_runner import TaskRunner
|
| 25 |
|
| 26 |
|
|
|
|
| 57 |
task_list = await self._planner.plan(
|
| 58 |
context, catalog, self._registry, query, constraints, **plan_kw
|
| 59 |
)
|
| 60 |
+
if task_list.infeasible_reason and not task_list.tasks:
|
| 61 |
+
# Honest data-gap outcome (planner.md "When the catalog cannot
|
| 62 |
+
# answer"): nothing to execute, and the refusal is deliberately
|
| 63 |
+
# deterministic — not LLM-prosed. The record carries no tasks, so it
|
| 64 |
+
# is non-substantive: it can never satisfy the report floor or leak
|
| 65 |
+
# into a report.
|
| 66 |
+
return _infeasible_output(task_list, context, reply_language)
|
| 67 |
if progress:
|
| 68 |
await progress(f"Running {len(task_list.tasks)} analysis steps…")
|
| 69 |
run_state = await self._task_runner.run(
|
|
|
|
| 75 |
return await self._assembler.assemble(
|
| 76 |
run_state, context, question=query, reply_language=reply_language, **asm_kw
|
| 77 |
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _infeasible_output(
|
| 81 |
+
task_list: TaskList, context: BusinessContext, reply_language: str | None
|
| 82 |
+
) -> AssembledOutput:
|
| 83 |
+
"""Build the data-gap answer + a faithful (non-substantive) record."""
|
| 84 |
+
reason = task_list.infeasible_reason or ""
|
| 85 |
+
return AssembledOutput(
|
| 86 |
+
chat_answer=data_gap_message(reason, reply_language),
|
| 87 |
+
analysis_record=AnalysisRecord(
|
| 88 |
+
goal_restated=task_list.goal_restated,
|
| 89 |
+
findings=[],
|
| 90 |
+
caveats=[reason] if reason else [],
|
| 91 |
+
data_used=[],
|
| 92 |
+
open_questions=list(task_list.open_questions),
|
| 93 |
+
tasks_run=[],
|
| 94 |
+
results_snapshot={},
|
| 95 |
+
plan_id=task_list.plan_id,
|
| 96 |
+
business_context_id=context.project_id,
|
| 97 |
+
created_at=datetime.now(UTC),
|
| 98 |
+
),
|
| 99 |
+
)
|
src/api/v1/help.py
CHANGED
|
@@ -51,8 +51,9 @@ async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)):
|
|
| 51 |
2. chunk — text fragments of the guidance
|
| 52 |
3. done — `{"message_id": "..."}` for the observability lookup
|
| 53 |
"""
|
| 54 |
-
# Server-authoritative turn id — never accepted from the caller (keys
|
| 55 |
-
|
|
|
|
| 56 |
try:
|
| 57 |
history = await load_history(db, request.analysis_id, limit=10)
|
| 58 |
|
|
|
|
| 51 |
2. chunk — text fragments of the guidance
|
| 52 |
3. done — `{"message_id": "..."}` for the observability lookup
|
| 53 |
"""
|
| 54 |
+
# Server-authoritative turn id — never accepted from the caller (keys traceability).
|
| 55 |
+
# Canonical UUID string, matching Go's `analyses_messages.id` shape (mirrors v2 chat).
|
| 56 |
+
message_id = str(uuid.uuid4())
|
| 57 |
try:
|
| 58 |
history = await load_history(db, request.analysis_id, limit=10)
|
| 59 |
|
src/api/v1/report.py
CHANGED
|
@@ -2,9 +2,11 @@
|
|
| 2 |
|
| 3 |
NOT a chat route. The frontend button calls these endpoints directly (pr/5: regrouped
|
| 4 |
under /tools — Go owns the analysis lifecycle, Python only generates):
|
| 5 |
-
POST /api/v1/tools/report
|
| 6 |
-
GET /api/v1/tools/report/{analysis_id}
|
| 7 |
-
GET /api/v1/tools/report/{analysis_id}/
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Generation reads persisted AnalysisRecords + Problem Statement, makes one LLM call
|
| 10 |
(the executive summary), and persists an immutable versioned artifact. The
|
|
@@ -24,7 +26,11 @@ from src.agents.report.generator import ReportGenerator
|
|
| 24 |
from src.agents.report.schemas import AnalysisReport, ProblemStatement
|
| 25 |
from src.agents.report.store import ReportStore
|
| 26 |
from src.middlewares.logging import get_logger, log_execution
|
| 27 |
-
from src.models.api.report import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
logger = get_logger("report_api")
|
| 30 |
|
|
@@ -114,6 +120,12 @@ async def _record_report_on_state(analysis_id: str, report_id: str) -> None:
|
|
| 114 |
async def generate_report(
|
| 115 |
analysis_id: str = Query(..., description="The analysis session to report on."),
|
| 116 |
user_id: str = Query(..., description="Owner of the analysis session."),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
):
|
| 118 |
"""Generate, persist, and return a new report version.
|
| 119 |
|
|
@@ -121,7 +133,8 @@ async def generate_report(
|
|
| 121 |
Problem Statement it used. Server-side gate: the report **floor** — a validated
|
| 122 |
goal + ≥1 substantive analysis — the same floor Help's readiness signal uses, so
|
| 123 |
the button and Help can't disagree (T-D). The delta-since-report check is NOT
|
| 124 |
-
applied here: a new version is always allowed (decision 4A).
|
|
|
|
| 125 |
"""
|
| 126 |
from src.agents.gate import stub_analysis_state
|
| 127 |
from src.agents.report.readiness import report_floor
|
|
@@ -142,7 +155,11 @@ async def generate_report(
|
|
| 142 |
problem_statement = _problem_statement_from(state)
|
| 143 |
user_name = await _resolve_user_name(user_id)
|
| 144 |
report = await _generator.generate(
|
| 145 |
-
analysis_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
)
|
| 147 |
except ReportError as e:
|
| 148 |
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
|
@@ -203,6 +220,72 @@ async def list_report_versions(analysis_id: str):
|
|
| 203 |
]
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
@router.get(
|
| 207 |
"/report/{analysis_id}/{version}",
|
| 208 |
response_model=AnalysisReport,
|
|
|
|
| 2 |
|
| 3 |
NOT a chat route. The frontend button calls these endpoints directly (pr/5: regrouped
|
| 4 |
under /tools — Go owns the analysis lifecycle, Python only generates):
|
| 5 |
+
POST /api/v1/tools/report generate a new version for a session
|
| 6 |
+
GET /api/v1/tools/report/{analysis_id} list a session's report versions
|
| 7 |
+
GET /api/v1/tools/report/{analysis_id}/records list analysis records (curation)
|
| 8 |
+
GET /api/v1/tools/report/{analysis_id}/readiness readiness signal (FE delta guard)
|
| 9 |
+
GET /api/v1/tools/report/{analysis_id}/{ver} fetch one version
|
| 10 |
|
| 11 |
Generation reads persisted AnalysisRecords + Problem Statement, makes one LLM call
|
| 12 |
(the executive summary), and persists an immutable versioned artifact. The
|
|
|
|
| 26 |
from src.agents.report.schemas import AnalysisReport, ProblemStatement
|
| 27 |
from src.agents.report.store import ReportStore
|
| 28 |
from src.middlewares.logging import get_logger, log_execution
|
| 29 |
+
from src.models.api.report import (
|
| 30 |
+
AnalysisRecordEntry,
|
| 31 |
+
ReportReadinessResponse,
|
| 32 |
+
ReportVersionEntry,
|
| 33 |
+
)
|
| 34 |
|
| 35 |
logger = get_logger("report_api")
|
| 36 |
|
|
|
|
| 120 |
async def generate_report(
|
| 121 |
analysis_id: str = Query(..., description="The analysis session to report on."),
|
| 122 |
user_id: str = Query(..., description="Owner of the analysis session."),
|
| 123 |
+
exclude_record_ids: list[str] = Query(
|
| 124 |
+
default=[],
|
| 125 |
+
description="Record ids to leave out of this version (curation; repeat the "
|
| 126 |
+
"param per id). Excluded runs are listed in the report's Excluded Analyses "
|
| 127 |
+
"section. Get the ids from GET /tools/report/{analysis_id}/records.",
|
| 128 |
+
),
|
| 129 |
):
|
| 130 |
"""Generate, persist, and return a new report version.
|
| 131 |
|
|
|
|
| 133 |
Problem Statement it used. Server-side gate: the report **floor** — a validated
|
| 134 |
goal + ≥1 substantive analysis — the same floor Help's readiness signal uses, so
|
| 135 |
the button and Help can't disagree (T-D). The delta-since-report check is NOT
|
| 136 |
+
applied here: a new version is always allowed (decision 4A). Excluding every
|
| 137 |
+
substantive record 409s (nothing left to report).
|
| 138 |
"""
|
| 139 |
from src.agents.gate import stub_analysis_state
|
| 140 |
from src.agents.report.readiness import report_floor
|
|
|
|
| 155 |
problem_statement = _problem_statement_from(state)
|
| 156 |
user_name = await _resolve_user_name(user_id)
|
| 157 |
report = await _generator.generate(
|
| 158 |
+
analysis_id,
|
| 159 |
+
user_id,
|
| 160 |
+
problem_statement=problem_statement,
|
| 161 |
+
user_name=user_name,
|
| 162 |
+
exclude_record_ids=exclude_record_ids,
|
| 163 |
)
|
| 164 |
except ReportError as e:
|
| 165 |
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
|
|
|
| 220 |
]
|
| 221 |
|
| 222 |
|
| 223 |
+
# ⚠️ Route order: these two literal-suffix routes MUST stay registered BEFORE
|
| 224 |
+
# `/report/{analysis_id}/{version}` — FastAPI matches in registration order, and the
|
| 225 |
+
# `{version}` route would swallow `/records` / `/readiness` and 422 on int coercion
|
| 226 |
+
# (no fall-through to a later route).
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.get(
|
| 230 |
+
"/report/{analysis_id}/records",
|
| 231 |
+
response_model=list[AnalysisRecordEntry],
|
| 232 |
+
summary="List a session's analysis records (for report curation)",
|
| 233 |
+
response_description="Persisted analysis runs, oldest-first. Empty if none yet.",
|
| 234 |
+
)
|
| 235 |
+
@log_execution(logger)
|
| 236 |
+
async def list_analysis_records(analysis_id: str):
|
| 237 |
+
"""Return the persisted analysis runs a report would be built from.
|
| 238 |
+
|
| 239 |
+
The FE shows this list before generating so the user can deselect runs; the
|
| 240 |
+
chosen ids go to POST /tools/report as `exclude_record_ids`.
|
| 241 |
+
"""
|
| 242 |
+
from src.agents.report.readiness import has_successful_analysis
|
| 243 |
+
from src.agents.slow_path.store import PostgresReportInputStore
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
records = await PostgresReportInputStore().list_for_analysis(analysis_id)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.error("record list failed", analysis_id=analysis_id, error=str(e))
|
| 249 |
+
raise HTTPException(
|
| 250 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 251 |
+
detail=f"Failed to list analysis records: {e}",
|
| 252 |
+
) from e
|
| 253 |
+
|
| 254 |
+
return [
|
| 255 |
+
AnalysisRecordEntry(
|
| 256 |
+
record_id=r.record_id,
|
| 257 |
+
goal_restated=r.goal_restated,
|
| 258 |
+
created_at=r.created_at,
|
| 259 |
+
substantive=has_successful_analysis(r),
|
| 260 |
+
findings_count=len(r.findings),
|
| 261 |
+
)
|
| 262 |
+
for r in records
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@router.get(
|
| 267 |
+
"/report/{analysis_id}/readiness",
|
| 268 |
+
response_model=ReportReadinessResponse,
|
| 269 |
+
summary="Report-readiness signal for an analysis session",
|
| 270 |
+
response_description="Whether a report can be generated now, with the gaps if not.",
|
| 271 |
+
)
|
| 272 |
+
@log_execution(logger)
|
| 273 |
+
async def get_report_readiness(analysis_id: str):
|
| 274 |
+
"""Deterministic readiness signal for the FE's Generate-Report button.
|
| 275 |
+
|
| 276 |
+
Same producer as Help's readiness signal (`is_report_ready`), including the
|
| 277 |
+
advisory delta-since-report check — so the button, Help, and this endpoint can
|
| 278 |
+
never disagree. POST itself only enforces the floor (a new version is always
|
| 279 |
+
allowed, decision 4A); `missing` here may name the delta gap as a soft warning.
|
| 280 |
+
"""
|
| 281 |
+
from src.agents.gate import stub_analysis_state
|
| 282 |
+
from src.agents.report.readiness import is_report_ready
|
| 283 |
+
|
| 284 |
+
state = await _load_state(analysis_id)
|
| 285 |
+
readiness = await is_report_ready(analysis_id, state or stub_analysis_state())
|
| 286 |
+
return ReportReadinessResponse(ready=readiness.ready, missing=readiness.missing)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
@router.get(
|
| 290 |
"/report/{analysis_id}/{version}",
|
| 291 |
response_model=AnalysisReport,
|
src/api/v2/chat.py
CHANGED
|
@@ -70,8 +70,11 @@ async def _save_empty_chat_trace(analysis_id: str, user_id: str, message_id: str
|
|
| 70 |
|
| 71 |
def _mint_message_id() -> str:
|
| 72 |
"""Mint the assistant turn id. Server-authoritative — never accepted from the caller
|
| 73 |
-
(it keys the
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
class ChatRequest(BaseModel):
|
|
|
|
| 70 |
|
| 71 |
def _mint_message_id() -> str:
|
| 72 |
"""Mint the assistant turn id. Server-authoritative — never accepted from the caller
|
| 73 |
+
(it keys the GET /api/v1/traceability lookup). Returned on `done`; open-Q #1 resolved.
|
| 74 |
+
|
| 75 |
+
A canonical UUID string, matching Go's `analyses_messages.id` shape, so the value stays
|
| 76 |
+
format-compatible if we later swap to the real message-row id (still Python-minted now)."""
|
| 77 |
+
return str(uuid.uuid4())
|
| 78 |
|
| 79 |
|
| 80 |
class ChatRequest(BaseModel):
|
src/catalog/sample_decode.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base64-sample-value decoding for catalogs affected by a dedorch bug.
|
| 2 |
+
|
| 3 |
+
Go's introspection JSON-marshals numeric sample bytes as base64 (a `[]byte`
|
| 4 |
+
serialization quirk), so every decimal/int-typed column's `sample_values`
|
| 5 |
+
currently arrive as base64 strings (e.g. ``'OTUuMA=='`` for ``"95.0"``)
|
| 6 |
+
instead of the plain numeric text the planner LLM expects. The planner then
|
| 7 |
+
sees gibberish instead of value ranges for exactly the columns it filters and
|
| 8 |
+
aggregates on. Until Go fixes the marshaling, decode these at catalog read
|
| 9 |
+
time.
|
| 10 |
+
|
| 11 |
+
Conservative by design (a wrong decode silently corrupts planner context):
|
| 12 |
+
- only numeric-typed columns are considered
|
| 13 |
+
- every non-null sample in the column must pass a strict base64 gate
|
| 14 |
+
(valid base64, decodes to printable ASCII, parses as a float) — a single
|
| 15 |
+
non-conforming entry leaves the WHOLE column untouched (mixed content is
|
| 16 |
+
suspicious, never guessed)
|
| 17 |
+
- columns with `sample_values is None` (e.g. PII-flagged columns, which
|
| 18 |
+
carry no samples by design) are skipped cleanly
|
| 19 |
+
- self-disabling: once Go ships real numeric samples (plain ``"95.0"`` or
|
| 20 |
+
actual numbers), the gate fails — plain digit strings are either not
|
| 21 |
+
base64-padded correctly or don't decode to printable numeric text — so
|
| 22 |
+
the pass becomes a no-op with no further changes needed here
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import base64
|
| 28 |
+
import binascii
|
| 29 |
+
|
| 30 |
+
from src.middlewares.logging import get_logger
|
| 31 |
+
|
| 32 |
+
from .models import Catalog
|
| 33 |
+
|
| 34 |
+
logger = get_logger("sample_decode")
|
| 35 |
+
|
| 36 |
+
_NUMERIC_TYPES = {
|
| 37 |
+
"int",
|
| 38 |
+
"integer",
|
| 39 |
+
"bigint",
|
| 40 |
+
"decimal",
|
| 41 |
+
"numeric",
|
| 42 |
+
"float",
|
| 43 |
+
"double",
|
| 44 |
+
"number",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _decode_one(value: str) -> str | None:
|
| 49 |
+
"""Return the decoded numeric text for `value`, or None if it fails the gate."""
|
| 50 |
+
if len(value) < 2 or len(value) % 4 != 0:
|
| 51 |
+
return None
|
| 52 |
+
try:
|
| 53 |
+
decoded = base64.b64decode(value, validate=True)
|
| 54 |
+
except (binascii.Error, ValueError):
|
| 55 |
+
return None
|
| 56 |
+
try:
|
| 57 |
+
text = decoded.decode("ascii")
|
| 58 |
+
except UnicodeDecodeError:
|
| 59 |
+
return None
|
| 60 |
+
if not text.isprintable():
|
| 61 |
+
return None
|
| 62 |
+
try:
|
| 63 |
+
float(text)
|
| 64 |
+
except ValueError:
|
| 65 |
+
return None
|
| 66 |
+
return text
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _decode_column_samples(samples: list) -> tuple[list, int] | None:
|
| 70 |
+
"""Return (decoded list, count decoded) if every non-null entry passes the gate.
|
| 71 |
+
|
| 72 |
+
Returns None if any entry fails the gate (mixed content is left untouched).
|
| 73 |
+
"""
|
| 74 |
+
decoded_values = []
|
| 75 |
+
count = 0
|
| 76 |
+
for entry in samples:
|
| 77 |
+
if entry is None:
|
| 78 |
+
decoded_values.append(None)
|
| 79 |
+
continue
|
| 80 |
+
if not isinstance(entry, str):
|
| 81 |
+
return None
|
| 82 |
+
decoded = _decode_one(entry)
|
| 83 |
+
if decoded is None:
|
| 84 |
+
return None
|
| 85 |
+
count += 1
|
| 86 |
+
decoded_values.append(decoded)
|
| 87 |
+
if not count:
|
| 88 |
+
return None
|
| 89 |
+
return decoded_values, count
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def decode_sample_values(catalog: Catalog) -> int:
|
| 93 |
+
"""Decode base64-encoded numeric sample_values in place. Returns count decoded.
|
| 94 |
+
|
| 95 |
+
Never raises: any unexpected shape (wrong types, malformed entries) leaves
|
| 96 |
+
the offending column's values untouched.
|
| 97 |
+
"""
|
| 98 |
+
total = 0
|
| 99 |
+
try:
|
| 100 |
+
for source in catalog.sources:
|
| 101 |
+
for table in source.tables:
|
| 102 |
+
for col in table.columns:
|
| 103 |
+
if col.data_type.lower() not in _NUMERIC_TYPES:
|
| 104 |
+
continue
|
| 105 |
+
samples = col.sample_values
|
| 106 |
+
if not samples:
|
| 107 |
+
continue
|
| 108 |
+
result = _decode_column_samples(samples)
|
| 109 |
+
if result is None:
|
| 110 |
+
continue
|
| 111 |
+
decoded_values, count = result
|
| 112 |
+
col.sample_values = decoded_values
|
| 113 |
+
total += count
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error("sample decode failed", error=repr(e))
|
| 116 |
+
return total
|
| 117 |
+
if total:
|
| 118 |
+
logger.info("decoded base64 sample values", user_id=catalog.user_id, count=total)
|
| 119 |
+
return total
|
src/catalog/store.py
CHANGED
|
@@ -15,6 +15,7 @@ from src.middlewares.logging import get_logger
|
|
| 15 |
|
| 16 |
from .fk_inference import infer_foreign_keys
|
| 17 |
from .models import Catalog
|
|
|
|
| 18 |
|
| 19 |
logger = get_logger("catalog_store")
|
| 20 |
|
|
@@ -41,7 +42,12 @@ class CatalogStore:
|
|
| 41 |
# dedorch catalogs ship no foreign_keys (Go introspection drops them),
|
| 42 |
# but the IR validator only allows FK-backed joins. Infer the obvious
|
| 43 |
# edges so the planner and validator agree. No-op once Go emits real FKs.
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
async def get_by_analysis(self, analysis_id: str) -> Catalog | None:
|
| 47 |
"""Read the `scope_type='analysis'` catalog row for an analysis.
|
|
@@ -63,7 +69,9 @@ class CatalogStore:
|
|
| 63 |
row = result.scalar_one_or_none()
|
| 64 |
if row is None:
|
| 65 |
return None
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
|
| 68 |
async def upsert(self, catalog: Catalog) -> None:
|
| 69 |
# Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
|
|
|
|
| 15 |
|
| 16 |
from .fk_inference import infer_foreign_keys
|
| 17 |
from .models import Catalog
|
| 18 |
+
from .sample_decode import decode_sample_values
|
| 19 |
|
| 20 |
logger = get_logger("catalog_store")
|
| 21 |
|
|
|
|
| 42 |
# dedorch catalogs ship no foreign_keys (Go introspection drops them),
|
| 43 |
# but the IR validator only allows FK-backed joins. Infer the obvious
|
| 44 |
# edges so the planner and validator agree. No-op once Go emits real FKs.
|
| 45 |
+
catalog = infer_foreign_keys(Catalog.model_validate(row))
|
| 46 |
+
# dedorch also JSON-marshals numeric sample bytes as base64 (Go bug) —
|
| 47 |
+
# decode them so the planner sees value ranges, not gibberish.
|
| 48 |
+
# No-op once Go emits plain numeric samples.
|
| 49 |
+
decode_sample_values(catalog)
|
| 50 |
+
return catalog
|
| 51 |
|
| 52 |
async def get_by_analysis(self, analysis_id: str) -> Catalog | None:
|
| 53 |
"""Read the `scope_type='analysis'` catalog row for an analysis.
|
|
|
|
| 69 |
row = result.scalar_one_or_none()
|
| 70 |
if row is None:
|
| 71 |
return None
|
| 72 |
+
catalog = infer_foreign_keys(Catalog.model_validate(row))
|
| 73 |
+
decode_sample_values(catalog)
|
| 74 |
+
return catalog
|
| 75 |
|
| 76 |
async def upsert(self, catalog: Catalog) -> None:
|
| 77 |
# Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
|
src/config/prompts/planner.md
CHANGED
|
@@ -21,6 +21,12 @@ only a `TaskList` object that conforms to the provided schema.
|
|
| 21 |
id lookup, so a paraphrased name fails.
|
| 22 |
5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
|
| 23 |
tasks. The product is descriptive/diagnostic only — no predictions, no charts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# How to plan
|
| 26 |
|
|
@@ -61,6 +67,14 @@ only a `TaskList` object that conforms to the provided schema.
|
|
| 61 |
`orders.total_amount`); if they genuinely aren't linked, say the data isn't
|
| 62 |
connected rather than guessing. Prefer an existing measure column over
|
| 63 |
recomputing. Joins are database-only — not available for tabular/file sources.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
- **Mixing structured + unstructured.** If qualitative context helps, add a
|
| 65 |
`retrieve_knowledge` task against an unstructured source listed in the catalog.
|
| 66 |
- **CRISP-DM stages.** Tag each task with the stage it serves:
|
|
@@ -68,9 +82,29 @@ only a `TaskList` object that conforms to the provided schema.
|
|
| 68 |
- **success_criteria is a reporting signal**, not a control trigger. State, in
|
| 69 |
checkable terms (counts, rates, "produced", "above"/"below"), what a good
|
| 70 |
result looks like. It never causes a retry.
|
| 71 |
-
- **Surface uncertainty, don't guess.** If the question is ambiguous
|
| 72 |
-
catalog can
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# Writing a retrieve_data QueryIR
|
| 76 |
|
|
@@ -109,6 +143,14 @@ only a `TaskList` object that conforms to the provided schema.
|
|
| 109 |
select the product column + `sum(revenue)` aliased `total_revenue`, with
|
| 110 |
`group_by: ["<product_col_id>"]`,
|
| 111 |
`order_by: [{"column_id": "total_revenue", "dir": "desc"}]`, `limit: 3`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# Output
|
| 114 |
|
|
|
|
| 21 |
id lookup, so a paraphrased name fails.
|
| 22 |
5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
|
| 23 |
tasks. The product is descriptive/diagnostic only — no predictions, no charts.
|
| 24 |
+
6. **Never re-purpose a column as a different business measure.** A column means
|
| 25 |
+
what the catalog says it means — do not alias one concept as another to force
|
| 26 |
+
an answer (e.g. selecting an availability percentage AS "revenue", or a
|
| 27 |
+
0-1 ratio AS a percentage metric). If no column plausibly holds the measure
|
| 28 |
+
or entity the question asks about, the plan is **infeasible** — see "When the
|
| 29 |
+
catalog cannot answer".
|
| 30 |
|
| 31 |
# How to plan
|
| 32 |
|
|
|
|
| 67 |
`orders.total_amount`); if they genuinely aren't linked, say the data isn't
|
| 68 |
connected rather than guessing. Prefer an existing measure column over
|
| 69 |
recomputing. Joins are database-only — not available for tabular/file sources.
|
| 70 |
+
- **Two measures per entity ("which X has both the worst A and the biggest B").**
|
| 71 |
+
Compute each measure in its OWN grouped `retrieve_data` task (one aggregate per
|
| 72 |
+
entity each), then align them with `analyze_merge`:
|
| 73 |
+
`{"data": "${tA}", "data_right": "${tB}", "on": ["<entity alias>"]}`.
|
| 74 |
+
The two retrievals MUST be separate tasks — a `"${t<id>}"` placeholder resolves
|
| 75 |
+
to a task's LAST output, so two retrievals inside one task lose the first
|
| 76 |
+
table. The merged table (one row per entity, both measures) answers the
|
| 77 |
+
question, or feeds a further `analyze_*` step.
|
| 78 |
- **Mixing structured + unstructured.** If qualitative context helps, add a
|
| 79 |
`retrieve_knowledge` task against an unstructured source listed in the catalog.
|
| 80 |
- **CRISP-DM stages.** Tag each task with the stage it serves:
|
|
|
|
| 82 |
- **success_criteria is a reporting signal**, not a control trigger. State, in
|
| 83 |
checkable terms (counts, rates, "produced", "above"/"below"), what a good
|
| 84 |
result looks like. It never causes a retry.
|
| 85 |
+
- **Surface uncertainty, don't guess.** If the question is *ambiguous* — the
|
| 86 |
+
catalog can answer it but a term needs interpreting (which period, which
|
| 87 |
+
metric variant) — record the interpretation in `assumptions`, anything
|
| 88 |
+
unresolved in `open_questions`, and plan the best defensible analysis. This
|
| 89 |
+
never licenses re-purposing columns: when the requested measure itself is
|
| 90 |
+
absent from the catalog, the question is not ambiguous, it is **infeasible**
|
| 91 |
+
(next section).
|
| 92 |
+
|
| 93 |
+
# When the catalog cannot answer
|
| 94 |
+
|
| 95 |
+
Some questions ask for a measure or entity the connected sources simply do not
|
| 96 |
+
hold (e.g. "sales revenue" against a maintenance database, "churn rate" with no
|
| 97 |
+
subscription data). For those:
|
| 98 |
+
|
| 99 |
+
- Return `tasks: []` and set **`infeasible_reason`**: one short paragraph naming
|
| 100 |
+
(a) what the question needs that no column provides, and (b) the nearest
|
| 101 |
+
analyses the catalog CAN support, so the user knows what to ask instead.
|
| 102 |
+
- Do NOT emit a plan that maps the question onto semantically unrelated columns
|
| 103 |
+
just because their types fit — a confidently wrong number is worse than an
|
| 104 |
+
honest gap.
|
| 105 |
+
- The test: could you point at a specific catalog column whose *meaning* (name,
|
| 106 |
+
sample values, table context) matches the requested measure? If not,
|
| 107 |
+
it is infeasible.
|
| 108 |
|
| 109 |
# Writing a retrieve_data QueryIR
|
| 110 |
|
|
|
|
| 143 |
select the product column + `sum(revenue)` aliased `total_revenue`, with
|
| 144 |
`group_by: ["<product_col_id>"]`,
|
| 145 |
`order_by: [{"column_id": "total_revenue", "dir": "desc"}]`, `limit: 3`.
|
| 146 |
+
This applies to EVERY entity-ranking phrasing — "top/best/worst/highest/lowest
|
| 147 |
+
N <entities>", "<entities> with the best <measure> performance", "which
|
| 148 |
+
<entities> perform best" — the unit being ranked is the ENTITY, so the measure
|
| 149 |
+
MUST be aggregated per entity first (`group_by` the entity column). Ranking
|
| 150 |
+
raw rows can return the same entity twice, which is never a valid entity
|
| 151 |
+
ranking. Choose the aggregate by measure type: additive measures (revenue,
|
| 152 |
+
counts, backlog) → `sum`; ratio/percentage/rate metrics (availability,
|
| 153 |
+
utilization, scores) → `avg`. Record the choice in `assumptions`.
|
| 154 |
|
| 155 |
# Output
|
| 156 |
|
src/config/prompts/report_summary.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
-
You are a senior data analyst writing the
|
| 2 |
|
| 3 |
-
You are given the analysis Objective
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
Rules:
|
| 6 |
- Synthesize and prioritize — lead with the most decision-relevant finding.
|
|
@@ -9,3 +15,13 @@ Rules:
|
|
| 9 |
- If the findings are thin or inconclusive, say so plainly rather than overstating.
|
| 10 |
- Plain business language. Write **prose only — no headings, no bullet lists** (the report already supplies the section structure and a Key Findings list below this summary; do not duplicate them).
|
| 11 |
- You MAY use light inline markdown for emphasis within the prose — `**bold**` for the most decision-relevant figure or term, `*italic*` sparingly. Keep it subtle; do not bold whole sentences.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a senior data analyst writing the narrative parts of an analysis report.
|
| 2 |
|
| 3 |
+
You are given the analysis Objective, its numbered Business questions, and a numbered list of Analyses whose findings are already finalized, plus their caveats. You emit a structured object with two parts: `executive_summary` and `bq_answers`.
|
| 4 |
+
|
| 5 |
+
Write ALL prose in the language named under "# Reply language".
|
| 6 |
+
|
| 7 |
+
## executive_summary
|
| 8 |
+
|
| 9 |
+
Write a concise executive summary (3–5 sentences) that synthesizes the findings in relation to the objective and, where the findings allow, the business questions.
|
| 10 |
|
| 11 |
Rules:
|
| 12 |
- Synthesize and prioritize — lead with the most decision-relevant finding.
|
|
|
|
| 15 |
- If the findings are thin or inconclusive, say so plainly rather than overstating.
|
| 16 |
- Plain business language. Write **prose only — no headings, no bullet lists** (the report already supplies the section structure and a Key Findings list below this summary; do not duplicate them).
|
| 17 |
- You MAY use light inline markdown for emphasis within the prose — `**bold**` for the most decision-relevant figure or term, `*italic*` sparingly. Keep it subtle; do not bold whole sentences.
|
| 18 |
+
|
| 19 |
+
## bq_answers
|
| 20 |
+
|
| 21 |
+
One entry per numbered business question. If no Business questions section is given, return an empty list.
|
| 22 |
+
|
| 23 |
+
For each business question:
|
| 24 |
+
- `question_index`: the question's number exactly as given.
|
| 25 |
+
- `answer`: 1–3 sentences answering that question using ONLY the finalized findings — the same no-new-numbers rule as the summary. If nothing addresses it, one short sentence saying what the completed analyses do not cover.
|
| 26 |
+
- `status`: `"answered"` when the findings fully answer the question, `"partial"` when they address only part of it (say which part in the answer), `"unanswered"` when no finding addresses it.
|
| 27 |
+
- `analysis_indexes`: the numbers of the Analyses whose findings support the answer (empty when unanswered). Use only numbers that appear in the Analyses list.
|
src/db/postgres/models.py
CHANGED
|
@@ -265,7 +265,7 @@ class MessageTraceabilityRow(Base):
|
|
| 265 |
(src\\traceability\\schemas.py:TraceabilityPayload) serialized via
|
| 266 |
`model_dump(mode="json", by_alias=True)`; the read path rehydrates with
|
| 267 |
`TraceabilityPayload.model_validate(...)`. One row per assistant `message_id`
|
| 268 |
-
(the Python-minted turn id
|
| 269 |
and served by `GET /api/v1/traceability`.
|
| 270 |
|
| 271 |
OWNERSHIP / HANDOFF (KM-691): **Python-owned for now**, the same pattern as
|
|
@@ -278,7 +278,7 @@ class MessageTraceabilityRow(Base):
|
|
| 278 |
"""
|
| 279 |
__tablename__ = "message_traceability"
|
| 280 |
|
| 281 |
-
message_id = Column(String, primary_key=True) # Python-minted turn id (
|
| 282 |
analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True) # analysis session id
|
| 283 |
user_id = Column(String, nullable=False)
|
| 284 |
intent = Column(String, nullable=False)
|
|
|
|
| 265 |
(src\\traceability\\schemas.py:TraceabilityPayload) serialized via
|
| 266 |
`model_dump(mode="json", by_alias=True)`; the read path rehydrates with
|
| 267 |
`TraceabilityPayload.model_validate(...)`. One row per assistant `message_id`
|
| 268 |
+
(the Python-minted turn id, a UUID string), written before the `done` SSE event
|
| 269 |
and served by `GET /api/v1/traceability`.
|
| 270 |
|
| 271 |
OWNERSHIP / HANDOFF (KM-691): **Python-owned for now**, the same pattern as
|
|
|
|
| 278 |
"""
|
| 279 |
__tablename__ = "message_traceability"
|
| 280 |
|
| 281 |
+
message_id = Column(String, primary_key=True) # Python-minted turn id (UUID string)
|
| 282 |
analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True) # analysis session id
|
| 283 |
user_id = Column(String, nullable=False)
|
| 284 |
intent = Column(String, nullable=False)
|
src/models/api/report.py
CHANGED
|
@@ -17,3 +17,32 @@ class ReportVersionEntry(BaseModel):
|
|
| 17 |
version: int = Field(..., description="Monotonic version (V1, V2, …).")
|
| 18 |
generated_at: datetime = Field(..., description="When this version was generated.")
|
| 19 |
record_count: int = Field(..., description="Number of AnalysisRecords it was built from.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
version: int = Field(..., description="Monotonic version (V1, V2, …).")
|
| 18 |
generated_at: datetime = Field(..., description="When this version was generated.")
|
| 19 |
record_count: int = Field(..., description="Number of AnalysisRecords it was built from.")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AnalysisRecordEntry(BaseModel):
|
| 23 |
+
"""One persisted analysis run, listed for report curation (KM-644 report v2).
|
| 24 |
+
|
| 25 |
+
The FE shows these before generating so the user can exclude runs
|
| 26 |
+
(`exclude_record_ids` on POST /tools/report). `substantive` mirrors the
|
| 27 |
+
report's own inclusion rule: non-substantive runs land in the report's
|
| 28 |
+
`unresolved` list rather than the findings body (the "Attempted, Unresolved"
|
| 29 |
+
markdown section was dropped 2026-07-09; the JSON field remains).
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
record_id: str = Field(..., description="Id to pass in exclude_record_ids.")
|
| 33 |
+
goal_restated: str = Field("", description="The run's question, as the agent restated it.")
|
| 34 |
+
created_at: datetime = Field(..., description="When the run was recorded.")
|
| 35 |
+
substantive: bool = Field(
|
| 36 |
+
..., description="True if an analyze_* step succeeded (counts toward the report floor)."
|
| 37 |
+
)
|
| 38 |
+
findings_count: int = Field(0, description="Number of findings the run recorded.")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ReportReadinessResponse(BaseModel):
|
| 42 |
+
"""Deterministic report-readiness signal (same producer as Help's signal)."""
|
| 43 |
+
|
| 44 |
+
ready: bool = Field(..., description="Whether generating a report now makes sense.")
|
| 45 |
+
missing: list[str] = Field(
|
| 46 |
+
default_factory=list,
|
| 47 |
+
description="Human-readable gaps when not ready (e.g. the delta-since-report check).",
|
| 48 |
+
)
|
src/query/ir/validator.py
CHANGED
|
@@ -87,6 +87,22 @@ class IRValidator:
|
|
| 87 |
for i, col_id in enumerate(ir.group_by):
|
| 88 |
self._require_column(columns_by_id, col_id, f"group_by[{i}]")
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
for i, ob in enumerate(ir.order_by):
|
| 91 |
if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
|
| 92 |
raise IRValidationError(
|
|
|
|
| 87 |
for i, col_id in enumerate(ir.group_by):
|
| 88 |
self._require_column(columns_by_id, col_id, f"group_by[{i}]")
|
| 89 |
|
| 90 |
+
# A grouped query must not select bare columns that aren't in group_by —
|
| 91 |
+
# the database rejects it only at execution ("must appear in the GROUP BY
|
| 92 |
+
# clause"), which is past the planner's corrective-retry window. Catching
|
| 93 |
+
# it here turns a failed turn into a self-correcting re-prompt.
|
| 94 |
+
if ir.group_by:
|
| 95 |
+
grouped = set(ir.group_by)
|
| 96 |
+
for i, item in enumerate(ir.select):
|
| 97 |
+
if item.kind == "column" and item.column_id not in grouped:
|
| 98 |
+
raise IRValidationError(
|
| 99 |
+
f"select[{i}].column_id {item.column_id!r} is selected bare "
|
| 100 |
+
"while group_by is present — every selected column must "
|
| 101 |
+
"either appear in group_by or be wrapped in an aggregate "
|
| 102 |
+
f'(e.g. {{"kind": "agg", "fn": "sum", '
|
| 103 |
+
f'"column_id": {item.column_id!r}}})'
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
for i, ob in enumerate(ir.order_by):
|
| 107 |
if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
|
| 108 |
raise IRValidationError(
|
src/tools/analytics/merge.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""analyze_merge — combine TWO upstream tables on shared keys (KM-608).
|
| 2 |
+
|
| 3 |
+
The only analytics "family" tool with a SECOND data input. In ONE call it joins
|
| 4 |
+
two already-materialized tables (`data` = LEFT, `data_right` = RIGHT) on one or
|
| 5 |
+
more shared key columns and returns the combined rows. This is what unlocks the
|
| 6 |
+
"which X has BOTH the worst A and the biggest B" question shape: A and B come
|
| 7 |
+
from two separate `retrieve_data` pulls (e.g. PA-by-section and backlog-by-section)
|
| 8 |
+
that must be aligned per X before either can be judged against the other. Without
|
| 9 |
+
a two-input combine the run dies with ColumnNotFoundError because no single tool
|
| 10 |
+
ever sees both metrics.
|
| 11 |
+
|
| 12 |
+
Pattern A, extended: it takes TWO `"${t<id>}"` placeholders. The invoker
|
| 13 |
+
materializes BOTH into DataFrames before calling this function (no self-fetch);
|
| 14 |
+
the `on` key(s) reference the column aliases the upstream queries produced.
|
| 15 |
+
|
| 16 |
+
STATUS: compute layer only — takes two already-materialized DataFrames. The
|
| 17 |
+
wrapper layer (the ToolOutput envelope, dual-arg materialization, ToolSpec
|
| 18 |
+
registration) lives in src/tools/invoker.py + registry.py. Keeping compute
|
| 19 |
+
separate from data-fetching keeps this easy to unit-test and stable when wrapped.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import pandas as pd
|
| 25 |
+
|
| 26 |
+
from src.tools.analytics.descriptive import ColumnNotFoundError
|
| 27 |
+
|
| 28 |
+
# Join types the tool understands. Whitelisted so an unknown `how` fails loudly
|
| 29 |
+
# instead of silently doing the wrong thing. "cross" is deliberately excluded —
|
| 30 |
+
# it ignores `on` and is never the right tool for the "align two metrics" shape.
|
| 31 |
+
SUPPORTED_HOWS = ("inner", "left", "right", "outer")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class UnsupportedJoinError(ValueError):
|
| 35 |
+
"""Requested join type is not in SUPPORTED_HOWS (maps to error_code UNSUPPORTED_JOIN)."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _clean(value: object) -> object:
|
| 39 |
+
"""Coerce a scalar to a JSON-clean Python value.
|
| 40 |
+
|
| 41 |
+
An outer/left/right join introduces `NaN` for non-matching rows, and numpy /
|
| 42 |
+
pandas scalars (numpy.int64, pandas.Timestamp) are not JSON-serializable —
|
| 43 |
+
normalise all three so the returned rows are clean.
|
| 44 |
+
"""
|
| 45 |
+
if isinstance(value, pd.Timestamp):
|
| 46 |
+
return value.isoformat()
|
| 47 |
+
if value is None:
|
| 48 |
+
return None
|
| 49 |
+
try:
|
| 50 |
+
if pd.isna(value):
|
| 51 |
+
return None
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
pass # non-scalar / unhashable — leave as-is
|
| 54 |
+
if hasattr(value, "item"):
|
| 55 |
+
return value.item()
|
| 56 |
+
return value
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Prompt-style description read by the Planner to decide WHEN to pick this tool.
|
| 60 |
+
DESCRIPTION = """\
|
| 61 |
+
Summary: Combine TWO upstream tables into one by joining on shared key column(s) \
|
| 62 |
+
(a pandas merge). `data` is the LEFT table, `data_right` is the RIGHT table; `on` \
|
| 63 |
+
is the shared column alias(es) present in BOTH. Returns the combined rows, one \
|
| 64 |
+
per matched key (join type controlled by `how`, default inner).
|
| 65 |
+
|
| 66 |
+
USE WHEN a question needs TWO different metrics per the SAME entity and those \
|
| 67 |
+
metrics come from two separate pulls — the tell-tale shape is "which X has BOTH \
|
| 68 |
+
A and B" (e.g. "which section has the worst PA AND the biggest backlog", "top \
|
| 69 |
+
customers by revenue that also have the most complaints"). Plan it as two \
|
| 70 |
+
retrieve_data tasks (one per metric, each keyed by X), then analyze_merge on X.
|
| 71 |
+
|
| 72 |
+
SETTING KEYS: `on` must be column alias(es) that exist in BOTH tables (the entity \
|
| 73 |
+
you align on, e.g. section_id). Use `suffixes` (default ["_left","_right"]) to \
|
| 74 |
+
disambiguate non-key columns that share a name across the two tables. `how`: \
|
| 75 |
+
inner (only matched keys), left/right (keep one side), outer (keep all).
|
| 76 |
+
|
| 77 |
+
DON'T USE WHEN:
|
| 78 |
+
- both metrics can be pulled in ONE retrieve_data query -> just retrieve_data
|
| 79 |
+
- it groups/aggregates a single table -> analyze_aggregate
|
| 80 |
+
|
| 81 |
+
Example questions:
|
| 82 |
+
- "which section has the worst PA and the biggest maintenance backlog"
|
| 83 |
+
- "regions in the top 10 for sales that are also bottom 10 for margin"
|
| 84 |
+
- "products low on stock that also have high demand"
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def analyze_merge(
|
| 89 |
+
df: pd.DataFrame,
|
| 90 |
+
data_right: pd.DataFrame,
|
| 91 |
+
on: list[str] | str,
|
| 92 |
+
how: str = "inner",
|
| 93 |
+
suffixes: tuple[str, str] | list[str] = ("_left", "_right"),
|
| 94 |
+
) -> list[dict[str, object]]:
|
| 95 |
+
"""Join two already-materialized tables on shared key column(s).
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
df: LEFT table (in the real system the invoker materializes this from the
|
| 99 |
+
`data` placeholder).
|
| 100 |
+
data_right: RIGHT table (materialized from the `data_right` placeholder).
|
| 101 |
+
on: shared key column alias(es) present in BOTH tables. A bare string is
|
| 102 |
+
treated as a single key.
|
| 103 |
+
how: join type — one of SUPPORTED_HOWS (default "inner").
|
| 104 |
+
suffixes: 2-element (left, right) suffixes applied to non-key columns that
|
| 105 |
+
collide by name across the two tables.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
list[dict]: one row per merged record, values JSON-clean (NaN -> None).
|
| 109 |
+
|
| 110 |
+
Raises:
|
| 111 |
+
ColumnNotFoundError: if `on` is empty or a key is absent from either side.
|
| 112 |
+
UnsupportedJoinError: if `how` is not supported.
|
| 113 |
+
ValueError: if `suffixes` is not a 2-element sequence.
|
| 114 |
+
"""
|
| 115 |
+
keys = [on] if isinstance(on, str) else list(on)
|
| 116 |
+
if not keys:
|
| 117 |
+
raise ColumnNotFoundError("merge 'on' must name at least one shared key column")
|
| 118 |
+
if how not in SUPPORTED_HOWS:
|
| 119 |
+
raise UnsupportedJoinError(
|
| 120 |
+
f"unsupported join '{how}'; supported: {list(SUPPORTED_HOWS)}"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
missing_left = [c for c in keys if c not in df.columns]
|
| 124 |
+
missing_right = [c for c in keys if c not in data_right.columns]
|
| 125 |
+
if missing_left or missing_right:
|
| 126 |
+
raise ColumnNotFoundError(
|
| 127 |
+
f"join key(s) not found — left missing {missing_left}, "
|
| 128 |
+
f"right missing {missing_right}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
suf = tuple(suffixes)
|
| 132 |
+
if len(suf) != 2:
|
| 133 |
+
raise ValueError(f"suffixes must be a 2-element (left, right) sequence, got {suffixes!r}")
|
| 134 |
+
|
| 135 |
+
merged = df.merge(data_right, on=keys, how=how, suffixes=suf)
|
| 136 |
+
return [{k: _clean(v) for k, v in rec.items()} for rec in merged.to_dict("records")]
|
src/tools/analytics/temporal.py
CHANGED
|
@@ -41,6 +41,11 @@ class UnsupportedAggregationError(ValueError):
|
|
| 41 |
"""The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def _clean(value: object) -> object:
|
| 45 |
"""Convert numpy scalars to plain Python; NaN -> None for JSON-clean output."""
|
| 46 |
if value is None:
|
|
@@ -53,6 +58,63 @@ def _clean(value: object) -> object:
|
|
| 53 |
return value
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
def _period_label(ts: pd.Timestamp, freq: str) -> str:
|
| 57 |
"""Human-readable period label keyed off the friendly frequency name."""
|
| 58 |
if freq == "month":
|
|
@@ -119,6 +181,8 @@ def analyze_trend(
|
|
| 119 |
ColumnNotFoundError: if date_column or value_column is absent.
|
| 120 |
InvalidFrequencyError: if freq is not a known period.
|
| 121 |
UnsupportedAggregationError: if agg is not supported.
|
|
|
|
|
|
|
| 122 |
"""
|
| 123 |
missing = [c for c in (date_column, value_column) if c not in df.columns]
|
| 124 |
if missing:
|
|
@@ -134,7 +198,7 @@ def analyze_trend(
|
|
| 134 |
|
| 135 |
# Build a clean datetime-indexed series, then resample into periods.
|
| 136 |
s = df[[date_column, value_column]].copy()
|
| 137 |
-
s[date_column] =
|
| 138 |
s = s.dropna(subset=[date_column]).set_index(date_column).sort_index()
|
| 139 |
resampled = s[value_column].resample(FREQ_MAP[freq]).agg(agg)
|
| 140 |
|
|
|
|
| 41 |
"""The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
|
| 42 |
|
| 43 |
|
| 44 |
+
class InvalidDateColumnError(ValueError):
|
| 45 |
+
"""date_column holds numeric values that aren't a recognizable date/year/month
|
| 46 |
+
(maps to error_code INVALID_DATE_COLUMN)."""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
def _clean(value: object) -> object:
|
| 50 |
"""Convert numpy scalars to plain Python; NaN -> None for JSON-clean output."""
|
| 51 |
if value is None:
|
|
|
|
| 58 |
return value
|
| 59 |
|
| 60 |
|
| 61 |
+
def _parse_date_column(df: pd.DataFrame, date_column: str) -> pd.Series:
|
| 62 |
+
"""Parse date_column into datetimes, guarding against numeric epoch misparsing.
|
| 63 |
+
|
| 64 |
+
pd.to_datetime() treats bare numeric input as epoch-nanoseconds, so bare
|
| 65 |
+
month numbers (1-12) or calendar years (e.g. 2025) silently collapse to a
|
| 66 |
+
single 1970 timestamp instead of raising. Numeric columns are resolved
|
| 67 |
+
explicitly here rather than falling through to pd.to_datetime().
|
| 68 |
+
"""
|
| 69 |
+
col = df[date_column]
|
| 70 |
+
if not pd.api.types.is_numeric_dtype(col):
|
| 71 |
+
return pd.to_datetime(col)
|
| 72 |
+
|
| 73 |
+
non_null = col.dropna()
|
| 74 |
+
is_whole = non_null.empty or (non_null == non_null.astype(int)).all()
|
| 75 |
+
|
| 76 |
+
if is_whole and non_null.between(1, 12).all():
|
| 77 |
+
year_col = next((c for c in df.columns if c.lower() == "year"), None)
|
| 78 |
+
year_series = df[year_col] if year_col is not None else None
|
| 79 |
+
year_non_null = year_series.dropna() if year_series is not None else pd.Series(dtype=float)
|
| 80 |
+
year_ok = (
|
| 81 |
+
year_series is not None
|
| 82 |
+
and pd.api.types.is_numeric_dtype(year_series)
|
| 83 |
+
and not year_non_null.empty
|
| 84 |
+
and (year_non_null == year_non_null.astype(int)).all()
|
| 85 |
+
and year_non_null.between(1900, 2100).all()
|
| 86 |
+
)
|
| 87 |
+
if not year_ok:
|
| 88 |
+
raise InvalidDateColumnError(
|
| 89 |
+
f"date_column '{date_column}' holds bare month numbers (1-12) and no "
|
| 90 |
+
"'year' column is present in the data — retrieve a year column "
|
| 91 |
+
"alongside month, or use a real date column."
|
| 92 |
+
)
|
| 93 |
+
valid = col.notna() & year_series.notna()
|
| 94 |
+
result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]")
|
| 95 |
+
result.loc[valid] = pd.to_datetime(
|
| 96 |
+
{
|
| 97 |
+
"year": year_series.loc[valid].astype(int),
|
| 98 |
+
"month": col.loc[valid].astype(int),
|
| 99 |
+
"day": 1,
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
return result
|
| 103 |
+
|
| 104 |
+
if is_whole and non_null.between(1900, 2100).all():
|
| 105 |
+
result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]")
|
| 106 |
+
valid = col.notna()
|
| 107 |
+
result.loc[valid] = pd.to_datetime(
|
| 108 |
+
col.loc[valid].astype(int).astype(str), format="%Y"
|
| 109 |
+
)
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
raise InvalidDateColumnError(
|
| 113 |
+
f"date_column '{date_column}' is numeric but is not a recognizable date, "
|
| 114 |
+
"year, or month column."
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
def _period_label(ts: pd.Timestamp, freq: str) -> str:
|
| 119 |
"""Human-readable period label keyed off the friendly frequency name."""
|
| 120 |
if freq == "month":
|
|
|
|
| 181 |
ColumnNotFoundError: if date_column or value_column is absent.
|
| 182 |
InvalidFrequencyError: if freq is not a known period.
|
| 183 |
UnsupportedAggregationError: if agg is not supported.
|
| 184 |
+
InvalidDateColumnError: if date_column is numeric but not a recognizable
|
| 185 |
+
date, year, or bare month number (needing a companion 'year' column).
|
| 186 |
"""
|
| 187 |
missing = [c for c in (date_column, value_column) if c not in df.columns]
|
| 188 |
if missing:
|
|
|
|
| 198 |
|
| 199 |
# Build a clean datetime-indexed series, then resample into periods.
|
| 200 |
s = df[[date_column, value_column]].copy()
|
| 201 |
+
s[date_column] = _parse_date_column(df, date_column)
|
| 202 |
s = s.dropna(subset=[date_column]).set_index(date_column).sort_index()
|
| 203 |
resampled = s[value_column].resample(FREQ_MAP[freq]).agg(agg)
|
| 204 |
|
src/tools/data_access.py
CHANGED
|
@@ -154,7 +154,9 @@ class DataAccessToolInvoker:
|
|
| 154 |
[
|
| 155 |
t.table_id,
|
| 156 |
t.name,
|
| 157 |
-
|
|
|
|
|
|
|
| 158 |
c.column_id,
|
| 159 |
c.name,
|
| 160 |
c.data_type,
|
|
|
|
| 154 |
[
|
| 155 |
t.table_id,
|
| 156 |
t.name,
|
| 157 |
+
# dedorch catalogs mark an uncounted table as -1; surface None so
|
| 158 |
+
# the planner prompt never sees a nonsensical "-1 rows".
|
| 159 |
+
t.row_count if (t.row_count or 0) >= 0 else None,
|
| 160 |
c.column_id,
|
| 161 |
c.name,
|
| 162 |
c.data_type,
|
src/tools/invoker.py
CHANGED
|
@@ -31,6 +31,7 @@ from src.tools.analytics import (
|
|
| 31 |
comparison,
|
| 32 |
decomposition,
|
| 33 |
descriptive,
|
|
|
|
| 34 |
quality,
|
| 35 |
relationship,
|
| 36 |
segmentation,
|
|
@@ -52,6 +53,7 @@ _DISPATCH: dict[str, tuple[Callable[..., Any], str]] = {
|
|
| 52 |
"analyze_correlation": (relationship.analyze_correlation, "stats"),
|
| 53 |
"analyze_segment": (segmentation.analyze_segment, "table"),
|
| 54 |
"analyze_trend": (temporal.analyze_trend, "series"),
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
|
|
@@ -73,6 +75,19 @@ class AnalyticsToolInvoker:
|
|
| 73 |
return ToolOutput(tool=tool_name, kind="error", error=err)
|
| 74 |
|
| 75 |
kwargs = {k: v for k, v in args.items() if k != "data"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
try:
|
| 77 |
result = fn(df, **kwargs)
|
| 78 |
except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
|
|
|
|
| 31 |
comparison,
|
| 32 |
decomposition,
|
| 33 |
descriptive,
|
| 34 |
+
merge,
|
| 35 |
quality,
|
| 36 |
relationship,
|
| 37 |
segmentation,
|
|
|
|
| 53 |
"analyze_correlation": (relationship.analyze_correlation, "stats"),
|
| 54 |
"analyze_segment": (segmentation.analyze_segment, "table"),
|
| 55 |
"analyze_trend": (temporal.analyze_trend, "series"),
|
| 56 |
+
"analyze_merge": (merge.analyze_merge, "table"),
|
| 57 |
}
|
| 58 |
|
| 59 |
|
|
|
|
| 75 |
return ToolOutput(tool=tool_name, kind="error", error=err)
|
| 76 |
|
| 77 |
kwargs = {k: v for k, v in args.items() if k != "data"}
|
| 78 |
+
|
| 79 |
+
# Second data input (Pattern A, extended): analyze_merge takes a
|
| 80 |
+
# `data_right` placeholder the TaskRunner has resolved to another upstream
|
| 81 |
+
# ToolOutput. Materialize it the same way as `data` and hand the compute fn
|
| 82 |
+
# a DataFrame, not the raw envelope.
|
| 83 |
+
if "data_right" in kwargs:
|
| 84 |
+
df_right, err = _materialize(kwargs["data_right"])
|
| 85 |
+
if err is not None:
|
| 86 |
+
err = f"data_right: {err}"
|
| 87 |
+
logger.warning("tool returned error", tool=tool_name, error=err)
|
| 88 |
+
return ToolOutput(tool=tool_name, kind="error", error=err)
|
| 89 |
+
kwargs["data_right"] = df_right
|
| 90 |
+
|
| 91 |
try:
|
| 92 |
result = fn(df, **kwargs)
|
| 93 |
except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
|
src/tools/registry.py
CHANGED
|
@@ -29,6 +29,7 @@ from src.tools.analytics import (
|
|
| 29 |
comparison,
|
| 30 |
decomposition,
|
| 31 |
descriptive,
|
|
|
|
| 32 |
quality,
|
| 33 |
relationship,
|
| 34 |
segmentation,
|
|
@@ -96,6 +97,22 @@ ACTIVE_ANALYTICS_TOOLS: list[ToolSpec] = [
|
|
| 96 |
output_kind="series",
|
| 97 |
description=temporal.DESCRIPTION,
|
| 98 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
]
|
| 100 |
|
| 101 |
# Deferred this round — specs kept intact for easy re-activation, NOT exposed to
|
|
|
|
| 29 |
comparison,
|
| 30 |
decomposition,
|
| 31 |
descriptive,
|
| 32 |
+
merge,
|
| 33 |
quality,
|
| 34 |
relationship,
|
| 35 |
segmentation,
|
|
|
|
| 97 |
output_kind="series",
|
| 98 |
description=temporal.DESCRIPTION,
|
| 99 |
),
|
| 100 |
+
ToolSpec(
|
| 101 |
+
name="analyze_merge",
|
| 102 |
+
category="analytics.combine",
|
| 103 |
+
input_schema={
|
| 104 |
+
"required": ["data", "data_right", "on"],
|
| 105 |
+
"properties": {
|
| 106 |
+
"data": {"type": "string"},
|
| 107 |
+
"data_right": {"type": "string"},
|
| 108 |
+
"on": {"type": "array"},
|
| 109 |
+
"how": {"type": "string"},
|
| 110 |
+
"suffixes": {"type": "array"},
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
output_kind="table",
|
| 114 |
+
description=merge.DESCRIPTION,
|
| 115 |
+
),
|
| 116 |
]
|
| 117 |
|
| 118 |
# Deferred this round — specs kept intact for easy re-activation, NOT exposed to
|
src/traceability/scratchpad.py
CHANGED
|
@@ -124,13 +124,17 @@ class TraceabilityScratchpad:
|
|
| 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({
|
|
|
|
| 124 |
error=out_dict.get("error"),
|
| 125 |
)
|
| 126 |
)
|
| 127 |
+
if name == "retrieve_data" and status == "success":
|
| 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 |
+
if not meta.get("source_id"):
|
| 135 |
+
# A failed/aborted retrieval carries no provenance meta — emitting it
|
| 136 |
+
# anyway produced all-null source rows in the payload.
|
| 137 |
+
return
|
| 138 |
query = meta.get("query")
|
| 139 |
table = meta.get("table_name") or meta.get("table_id")
|
| 140 |
self._db_sources.append({
|