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` | No | Assistant answer id for traceability correlation. If omitted, Python returns one in `done`. |
86
  | `message` | Yes | User message text. |
87
 
88
  Response: `text/event-stream`.
@@ -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
- data_arg = call.args.get("data")
215
- if not isinstance(data_arg, str):
216
- return
217
- match = PLACEHOLDER_RE.fullmatch(data_arg.strip())
218
- if not match:
219
- return
220
- ref_task = tasks_by_id.get(match.group(1))
221
- if ref_task is None or not ref_task.tool_calls:
222
- return # a dangling placeholder is reported by the DAG check
223
- ref_tool = ref_task.tool_calls[-1].tool
224
- ref_spec = registry.get(ref_tool)
225
- if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES:
226
- raise PlannerValidationError(
227
- f"task {task_id}: tool {call.tool!r} takes its 'data' from task "
228
- f"{match.group(1)} ({ref_tool!r}, category {ref_spec.category!r}), "
229
- "which produces metadata/documents — not analyzable data rows. Feed "
230
- "analyze_* from a data-producing tool (e.g. retrieve_data)."
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 _collect_notes(records: list[AnalysisRecord], field: str) -> list[AttributedNote]:
117
- # Caveats / open_questions are deduped by text; a merged note cites every
118
- # record it came from (plural record_ids).
119
- merged: dict[str, list[str]] = {}
 
 
 
 
 
 
 
 
120
  for rec in records:
121
  for text in getattr(rec, field):
122
- ids = merged.setdefault(text, [])
123
- if rec.record_id not in ids:
124
- ids.append(rec.record_id)
125
- return [AttributedNote(text=text, record_ids=ids) for text, ids in merged.items()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, findings: list[ReportFinding], caveats: list[AttributedNote]
 
 
 
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" + "\n".join(f"- {q}" for q in ps.business_questions)
 
186
  )
187
- sections.append(
188
- "# Findings (already finalized — synthesize, do not add numbers)\n"
189
- + "\n".join(f"- {f.text}" for f in findings)
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
- if report.caveats or report.open_questions:
300
- lines = ["## Notes & Limitations"]
301
- for n in report.caveats:
302
- lines.append(f"- {_mdx_escape(n.text)}")
303
- for n in report.open_questions:
304
- lines.append(f"- Open: {_mdx_escape(n.text)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  parts.append("\n".join(lines))
306
 
307
- if report.method_steps:
308
- lines = ["## How This Was Analyzed"]
309
- for stage_key, label in _STAGE_LABELS:
310
- steps = [s for s in report.method_steps if s.stage == stage_key]
311
- if not steps:
312
- continue
313
- rendered = "; ".join(
314
- f"{', '.join(_mdx_escape(t) for t in s.tools_used) or '—'} ({s.status})"
315
- for s in steps
316
- )
317
- lines.append(f"**{label}** — {rendered}")
318
- parts.append("\n".join(lines))
 
 
 
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
- records = await self._ensure_record_store().list_for_analysis(analysis_id)
368
- # The report reflects only substantive runs — those with a successful
 
 
 
369
  # analysis step (the same set the report floor validates). Fully-failed runs
370
- # are dropped so their failure narration can't contradict the real findings.
371
- records = [r for r in records if has_successful_analysis(r)]
 
 
 
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(ps, findings, caveats)
 
 
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, ps: ProblemStatement, findings: list[ReportFinding], caveats: list[AttributedNote]
427
- ) -> str:
428
- human_content = _build_human_content(ps, findings, caveats)
 
 
 
 
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=str(exc))
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 /observability).
55
- message_id = f"msg_{uuid.uuid4().hex[:12]}"
 
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 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}/{ver} fetch one version
 
 
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 ReportVersionEntry
 
 
 
 
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, user_id, problem_statement=problem_statement, user_name=user_name
 
 
 
 
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 future /observability lookup). Returned on `done`; open-Q #1 resolved."""
74
- return f"msg_{uuid.uuid4().hex[:12]}"
 
 
 
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
- return infer_foreign_keys(Catalog.model_validate(row))
 
 
 
 
 
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
- return infer_foreign_keys(Catalog.model_validate(row))
 
 
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 or the
72
- catalog can't fully answer it, record it in `open_questions` and plan the best
73
- defensible analysis anyway. Record interpretation choices in `assumptions`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 **executive summary** of an analysis report.
2
 
3
- You are given the analysis Objective and its Business questions, plus a list of already-finalized findings and caveats drawn from completed analyses. Write a concise executive summary (3–5 sentences) that synthesizes those findings in relation to the objective and, where the findings allow, the business questions.
 
 
 
 
 
 
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 "msg_<12hex>"), 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,7 +278,7 @@ class MessageTraceabilityRow(Base):
278
  """
279
  __tablename__ = "message_traceability"
280
 
281
- message_id = Column(String, primary_key=True) # Python-minted turn id ("msg_<12hex>")
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] = pd.to_datetime(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
- t.row_count,
 
 
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({