Rifqi Hafizuddin Claude Fable 5 commited on
Commit
4ceb058
Β·
1 Parent(s): a029a84

[KM-644] report v2: BQ answers, unresolved/excluded sections, evidence tables + curation/readiness endpoints

Browse files
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
 
@@ -218,11 +220,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 +255,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 +293,23 @@ Response `201`:
275
  "record_ids": ["rec_b2"]
276
  }
277
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  "data_sources": [
279
  {
280
  "source_id": "src_sales_db",
@@ -315,6 +350,13 @@ Response `409`:
315
  }
316
  ```
317
 
 
 
 
 
 
 
 
318
  Precondition:
319
 
320
  - Reports require at least one completed analysis record for the session.
@@ -345,6 +387,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
 
 
220
  | --- | --- | --- |
221
  | `analysis_id` | Yes | Analysis identifier. |
222
  | `user_id` | Yes | User identifier. |
223
+ | `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`. |
224
 
225
  Example:
226
 
227
  ```text
228
  POST /api/v1/tools/report?analysis_id=an_42&user_id=u_1a2b3c
229
+ POST /api/v1/tools/report?analysis_id=an_42&user_id=u_1a2b3c&exclude_record_ids=rec_a1&exclude_record_ids=rec_c3
230
  ```
231
 
232
  Status codes:
 
255
  },
256
  "record_ids": ["rec_a1", "rec_b2"],
257
  "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.",
258
+ "bq_answers": [
259
+ {
260
+ "question": "Which regions contribute most to total revenue?",
261
+ "answer": "The Central region leads with 38% of total revenue.",
262
+ "status": "answered",
263
+ "record_ids": ["rec_a1"]
264
+ },
265
+ {
266
+ "question": "Did any region decline quarter-over-quarter?",
267
+ "answer": "Yes β€” the West region fell 12% QoQ.",
268
+ "status": "answered",
269
+ "record_ids": ["rec_b2"]
270
+ }
271
+ ],
272
  "findings": [
273
  {
274
  "text": "Central region contributed 38% of total revenue, the largest share.",
 
293
  "record_ids": ["rec_b2"]
294
  }
295
  ],
296
+ "unresolved": [
297
+ {
298
+ "text": "Correlate churn with tenure β€” churn column not found in the source.",
299
+ "record_ids": ["rec_d4"]
300
+ }
301
+ ],
302
+ "excluded": [],
303
+ "evidence_tables": {
304
+ "rec_a1": [
305
+ {
306
+ "title": "Aggregate revenue by region",
307
+ "columns": ["region", "total_revenue"],
308
+ "rows": [["Central", "18321"], ["West", "9954"]],
309
+ "truncated": false
310
+ }
311
+ ]
312
+ },
313
  "data_sources": [
314
  {
315
  "source_id": "src_sales_db",
 
350
  }
351
  ```
352
 
353
+ Report v2 fields (added 2026-07-09; all default-empty, so older stored reports read back unchanged):
354
+
355
+ - `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).
356
+ - `unresolved` β€” runs that were attempted but produced no usable evidence (every `analyze_*` step failed). Not part of the findings body.
357
+ - `excluded` β€” runs the caller excluded via `exclude_record_ids`.
358
+ - `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`.
359
+
360
  Precondition:
361
 
362
  - Reports require at least one completed analysis record for the session.
 
387
 
388
  If no reports exist, returns `[]`.
389
 
390
+ ### `GET /api/v1/tools/report/{analysis_id}/records` (added 2026-07-09)
391
+
392
+ 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`.
393
+
394
+ Response `200`:
395
+
396
+ ```json
397
+ [
398
+ {
399
+ "record_id": "rec_a1",
400
+ "goal_restated": "Rank regions by total revenue",
401
+ "created_at": "2026-06-30T08:55:02Z",
402
+ "substantive": true,
403
+ "findings_count": 2
404
+ },
405
+ {
406
+ "record_id": "rec_d4",
407
+ "goal_restated": "Correlate churn with tenure",
408
+ "created_at": "2026-06-30T09:01:47Z",
409
+ "substantive": false,
410
+ "findings_count": 1
411
+ }
412
+ ]
413
+ ```
414
+
415
+ `substantive: false` means no `analyze_*` step succeeded β€” that run would land under "Attempted, Unresolved" rather than the findings body. If no runs exist, returns `[]`.
416
+
417
+ ### `GET /api/v1/tools/report/{analysis_id}/readiness` (added 2026-07-09)
418
+
419
+ 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.
420
+
421
+ Response `200`:
422
+
423
+ ```json
424
+ {
425
+ "ready": false,
426
+ "missing": ["a new analysis since the last report"]
427
+ }
428
+ ```
429
+
430
+ 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.
431
+
432
  ### `GET /api/v1/tools/report/{analysis_id}/{version}`
433
 
434
  Returns one report version. Shape is the same as the `201` response from `POST /api/v1/tools/report`.
DEV_PLAN.md CHANGED
@@ -62,9 +62,9 @@ base64-mangled from Go. Fix tasks (same status legend as Β§0):
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 | ⬜ | flagged out of this sprint; request brief sent by Rifqi |
66
- | Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia | ⬜ | next up; adds one LLM call (button-triggered); new prompt β†’ eval per Β§7B |
67
- | Q9 | Record-curation endpoint (`GET …/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi ↔ FE | ⬜ | contract addition β†’ API_CONTRACT_BE_PYTHON.md |
68
 
69
  ## 1. The direction change (locked decisions from 2026-06-24)
70
 
 
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
 
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. **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. **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. **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/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
 
@@ -304,6 +472,25 @@ def _render_markdown(report: AnalysisReport) -> str:
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:
@@ -363,24 +550,35 @@ 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 +590,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 +625,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
 
 
472
  lines.append(f"- Open: {_mdx_escape(n.text)}")
473
  parts.append("\n".join(lines))
474
 
475
+ if report.unresolved:
476
+ lines = [
477
+ "## Attempted, Unresolved",
478
+ "*These analyses ran but produced no usable evidence;"
479
+ " they are not reflected in the findings above.*",
480
+ "",
481
+ ]
482
+ lines.extend(f"- {_mdx_escape(n.text)}" for n in report.unresolved)
483
+ parts.append("\n".join(lines))
484
+
485
+ if report.excluded:
486
+ lines = [
487
+ "## Excluded Analyses",
488
+ "*Excluded from this report at generation time.*",
489
+ "",
490
+ ]
491
+ lines.extend(f"- {_mdx_escape(n.text)}" for n in report.excluded)
492
+ parts.append("\n".join(lines))
493
+
494
  if report.method_steps:
495
  lines = ["## How This Was Analyzed"]
496
  for stage_key, label in _STAGE_LABELS:
 
550
  user_id: str | None = None,
551
  problem_statement: ProblemStatement | None = None,
552
  user_name: str | None = None,
553
+ exclude_record_ids: list[str] | None = None,
554
  ) -> AnalysisReport:
555
+ all_records = await self._ensure_record_store().list_for_analysis(analysis_id)
556
+ excluded_ids = set(exclude_record_ids or [])
557
+ excluded = [r for r in all_records if r.record_id in excluded_ids]
558
+ kept = [r for r in all_records if r.record_id not in excluded_ids]
559
+ # The report body reflects only substantive runs β€” those with a successful
560
  # analysis step (the same set the report floor validates). Fully-failed runs
561
+ # can't contradict the real findings, but they are no longer dropped
562
+ # silently: they surface under "Attempted, Unresolved".
563
+ records = [r for r in kept if has_successful_analysis(r)]
564
+ unresolved_records = [r for r in kept if not has_successful_analysis(r)]
565
  if not records:
566
  raise ReportError(f"no analyses recorded for {analysis_id!r} yet")
567
 
568
  ps = problem_statement or ProblemStatement()
569
+ reply_language = detect_reply_language(
570
+ None, goal_texts=[ps.objective, *ps.business_questions]
571
+ )
572
  findings = _collect_findings(records)
573
+ caveats = _collect_notes(records, "caveats", _MAX_CAVEATS)
574
+ open_questions = _collect_notes(records, "open_questions", _MAX_OPEN_QUESTIONS)
575
  method_steps = _collect_method_steps(records)
576
  data_sources = _build_data_sources(
577
  records, await self._read_catalog(user_id, analysis_id)
578
  )
579
+ executive_summary, bq_answers = await self._summarize(
580
+ ps, records, caveats, reply_language
581
+ )
582
 
583
  report = AnalysisReport(
584
  analysis_id=analysis_id,
 
590
  record_ids=[r.record_id for r in records],
591
  record_goals={r.record_id: r.goal_restated for r in records},
592
  executive_summary=executive_summary,
593
+ bq_answers=bq_answers,
594
  findings=findings,
595
  caveats=caveats,
596
  open_questions=open_questions,
597
+ unresolved=[_unresolved_note(r) for r in unresolved_records],
598
+ excluded=[_excluded_note(r) for r in excluded],
599
+ evidence_tables=_collect_evidence(records),
600
  data_sources=data_sources,
601
  method_steps=method_steps,
602
  )
 
625
  return None
626
 
627
  async def _summarize(
628
+ self,
629
+ ps: ProblemStatement,
630
+ records: list[AnalysisRecord],
631
+ caveats: list[AttributedNote],
632
+ reply_language: str,
633
+ ) -> tuple[str, list[BusinessQuestionAnswer]]:
634
+ human_content = _build_human_content(ps, records, caveats, reply_language)
635
  try:
636
  narrative: ReportSummaryNarrative = await self._ensure_chain().ainvoke(
637
  {"human_content": human_content}
638
  )
 
639
  except Exception as exc: # D1: degrade, don't fail the whole report
640
+ logger.warning("report summary LLM failed; using fallback", error=repr(exc))
641
+ return _FALLBACK_SUMMARY, []
642
+ return narrative.executive_summary, _resolve_bq_answers(
643
+ narrative.bq_answers, ps.business_questions, records
644
+ )
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/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/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/models/api/report.py CHANGED
@@ -17,3 +17,31 @@ 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 would land under
28
+ "Attempted, Unresolved" rather than the findings body.
29
+ """
30
+
31
+ record_id: str = Field(..., description="Id to pass in exclude_record_ids.")
32
+ goal_restated: str = Field("", description="The run's question, as the agent restated it.")
33
+ created_at: datetime = Field(..., description="When the run was recorded.")
34
+ substantive: bool = Field(
35
+ ..., description="True if an analyze_* step succeeded (counts toward the report floor)."
36
+ )
37
+ findings_count: int = Field(0, description="Number of findings the run recorded.")
38
+
39
+
40
+ class ReportReadinessResponse(BaseModel):
41
+ """Deterministic report-readiness signal (same producer as Help's signal)."""
42
+
43
+ ready: bool = Field(..., description="Whether generating a report now makes sense.")
44
+ missing: list[str] = Field(
45
+ default_factory=list,
46
+ description="Human-readable gaps when not ready (e.g. the delta-since-report check).",
47
+ )