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 +84 -0
- DEV_PLAN.md +3 -3
- REPO_STATUS.md +1 -1
- src/agents/report/generator.py +235 -27
- src/agents/report/schemas.py +54 -0
- src/api/v1/report.py +89 -6
- src/config/prompts/report_summary.md +18 -2
- src/models/api/report.py +28 -0
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 |
|
| 66 |
-
| Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia |
|
| 67 |
-
| Q9 | Record-curation endpoint (`GET β¦/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi β FE |
|
| 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
|
| 117 |
-
#
|
| 118 |
-
#
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
for rec in records:
|
| 121 |
for text in getattr(rec, field):
|
| 122 |
-
|
| 123 |
-
if
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
def _collect_method_steps(records: list[AnalysisRecord]) -> list[TaskSummary]:
|
|
@@ -175,24 +267,72 @@ def _build_data_sources(
|
|
| 175 |
|
| 176 |
|
| 177 |
def _build_human_content(
|
| 178 |
-
ps: ProblemStatement,
|
|
|
|
|
|
|
|
|
|
| 179 |
) -> str:
|
|
|
|
|
|
|
| 180 |
sections = []
|
| 181 |
if ps.objective:
|
| 182 |
sections.append("# Objective\n" + ps.objective)
|
| 183 |
if ps.business_questions:
|
| 184 |
sections.append(
|
| 185 |
-
"# Business questions\n"
|
|
|
|
| 186 |
)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
if caveats:
|
| 192 |
sections.append("# Caveats\n" + "\n".join(f"- {c.text}" for c in caveats))
|
|
|
|
| 193 |
return "\n\n".join(sections)
|
| 194 |
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
# Inline code spans (one or more backticks). Content inside is already literal in
|
| 197 |
# Markdown/MDX, so escaping within them would only surface a visible backslash
|
| 198 |
# (e.g. `product\_id`). We keep code spans verbatim and escape only around them.
|
|
@@ -254,6 +394,17 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 254 |
if report.executive_summary:
|
| 255 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
if report.findings:
|
| 258 |
# Group findings by their originating analysis (record) so results from
|
| 259 |
# different questions read as separate analyses, not one flat, seemingly
|
|
@@ -275,6 +426,23 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 275 |
if grouped:
|
| 276 |
block.append(f"### {_mdx_escape(report.record_goals.get(rid) or 'Analysis')}")
|
| 277 |
block.extend(f"{i}. {_mdx_escape(f.text)}" for i, f in enumerate(group, 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
blocks.append("\n".join(block))
|
| 279 |
parts.append("\n\n".join(blocks))
|
| 280 |
|
|
@@ -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 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
| 369 |
# analysis step (the same set the report floor validates). Fully-failed runs
|
| 370 |
-
#
|
| 371 |
-
|
|
|
|
|
|
|
| 372 |
if not records:
|
| 373 |
raise ReportError(f"no analyses recorded for {analysis_id!r} yet")
|
| 374 |
|
| 375 |
ps = problem_statement or ProblemStatement()
|
|
|
|
|
|
|
|
|
|
| 376 |
findings = _collect_findings(records)
|
| 377 |
-
caveats = _collect_notes(records, "caveats")
|
| 378 |
-
open_questions = _collect_notes(records, "open_questions")
|
| 379 |
method_steps = _collect_method_steps(records)
|
| 380 |
data_sources = _build_data_sources(
|
| 381 |
records, await self._read_catalog(user_id, analysis_id)
|
| 382 |
)
|
| 383 |
-
executive_summary = await self._summarize(
|
|
|
|
|
|
|
| 384 |
|
| 385 |
report = AnalysisReport(
|
| 386 |
analysis_id=analysis_id,
|
|
@@ -392,9 +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,
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
try:
|
| 430 |
narrative: ReportSummaryNarrative = await self._ensure_chain().ainvoke(
|
| 431 |
{"human_content": human_content}
|
| 432 |
)
|
| 433 |
-
return narrative.executive_summary
|
| 434 |
except Exception as exc: # D1: degrade, don't fail the whole report
|
| 435 |
-
logger.warning("report summary LLM failed; using fallback", error=
|
| 436 |
-
return _FALLBACK_SUMMARY
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
from src.middlewares.logging import get_logger
|
| 26 |
|
| 27 |
+
from ..language import detect_reply_language
|
| 28 |
from ..slow_path.schemas import AnalysisRecord, TaskSummary
|
| 29 |
from .errors import ReportError
|
| 30 |
from .readiness import has_successful_analysis
|
| 31 |
from .schemas import (
|
| 32 |
AnalysisReport,
|
| 33 |
AttributedNote,
|
| 34 |
+
BQAnswerDraft,
|
| 35 |
+
BusinessQuestionAnswer,
|
| 36 |
DataSourceRef,
|
| 37 |
+
EvidenceTable,
|
| 38 |
ProblemStatement,
|
| 39 |
ReportFinding,
|
| 40 |
ReportSummaryNarrative,
|
|
|
|
| 44 |
|
| 45 |
_FALLBACK_SUMMARY = "Automated summary unavailable β see the findings below."
|
| 46 |
|
| 47 |
+
# Caps keeping the deterministic sections readable on multi-record analyses.
|
| 48 |
+
_MAX_CAVEATS = 12
|
| 49 |
+
_MAX_OPEN_QUESTIONS = 10
|
| 50 |
+
_EVIDENCE_MAX_ROWS = 10
|
| 51 |
+
_EVIDENCE_MAX_TABLES = 3 # per record
|
| 52 |
+
# Wider tables are raw analysis *inputs* (e.g. a 19-column correlation pull), not
|
| 53 |
+
# presentable evidence β grouped/top-N/merge results are always narrow.
|
| 54 |
+
_EVIDENCE_MAX_COLS = 8
|
| 55 |
+
|
| 56 |
# CRISP-DM phases in narrative order, with human labels for the method appendix.
|
| 57 |
_STAGE_LABELS: list[tuple[str, str]] = [
|
| 58 |
("data_understanding", "Data understanding"),
|
|
|
|
| 61 |
("evaluation", "Evaluation"),
|
| 62 |
]
|
| 63 |
|
| 64 |
+
# Human labels for BusinessQuestionAnswer.status in the rendered markdown.
|
| 65 |
+
_BQ_STATUS_LABELS: dict[str, str] = {
|
| 66 |
+
"answered": "Answered",
|
| 67 |
+
"partial": "Partially answered",
|
| 68 |
+
"unanswered": "Unanswered",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
# Friendly labels for the catalog's internal source_type enum, shown in Data Sources.
|
| 72 |
_SOURCE_TYPE_LABELS: dict[str, str] = {
|
| 73 |
"schema": "Database",
|
|
|
|
| 133 |
return out
|
| 134 |
|
| 135 |
|
| 136 |
+
def _note_key(text: str) -> str:
|
| 137 |
+
# Dedupe key: collapse whitespace, drop trailing punctuation, casefold β so the
|
| 138 |
+
# Assembler's near-identical rephrasings ("Data is capped at 500 rows." vs
|
| 139 |
+
# "data is capped at 500 rows") merge into one note.
|
| 140 |
+
return " ".join(text.split()).rstrip(".!").casefold()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _collect_notes(records: list[AnalysisRecord], field: str, cap: int) -> list[AttributedNote]:
|
| 144 |
+
# Caveats / open_questions are deduped by normalized text; a merged note keeps
|
| 145 |
+
# the first phrasing seen and cites every record it came from (plural
|
| 146 |
+
# record_ids). Capped so a many-record analysis stays readable.
|
| 147 |
+
merged: dict[str, AttributedNote] = {}
|
| 148 |
for rec in records:
|
| 149 |
for text in getattr(rec, field):
|
| 150 |
+
key = _note_key(text)
|
| 151 |
+
if not key:
|
| 152 |
+
continue
|
| 153 |
+
note = merged.setdefault(key, AttributedNote(text=text))
|
| 154 |
+
if rec.record_id not in note.record_ids:
|
| 155 |
+
note.record_ids.append(rec.record_id)
|
| 156 |
+
return list(merged.values())[:cap]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _fmt_cell(value) -> str:
|
| 160 |
+
if value is None:
|
| 161 |
+
return "β"
|
| 162 |
+
if isinstance(value, float):
|
| 163 |
+
return f"{value:g}" # 1234.5 not 1234.5000000001; no trailing zeros
|
| 164 |
+
return str(value)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _collect_evidence(records: list[AnalysisRecord]) -> dict[str, list[EvidenceTable]]:
|
| 168 |
+
"""Copy small result tables out of each record's `results_snapshot` (INV-4).
|
| 169 |
+
|
| 170 |
+
Table-kind tool outputs only β the copy-paste-able slices (top-N rankings,
|
| 171 |
+
grouped aggregates, merges). `check_*` outputs are skipped (catalog metadata,
|
| 172 |
+
not evidence). Rows and tables-per-record are capped so a wide retrieval
|
| 173 |
+
can't balloon the report.
|
| 174 |
+
"""
|
| 175 |
+
out: dict[str, list[EvidenceTable]] = {}
|
| 176 |
+
for rec in records:
|
| 177 |
+
tables: list[EvidenceTable] = []
|
| 178 |
+
for result in rec.results_snapshot.values():
|
| 179 |
+
for output in result.outputs:
|
| 180 |
+
if len(tables) >= _EVIDENCE_MAX_TABLES:
|
| 181 |
+
break
|
| 182 |
+
if output.tool in ("check_data", "check_knowledge"):
|
| 183 |
+
continue
|
| 184 |
+
if output.kind != "table" or not output.columns or not output.rows:
|
| 185 |
+
continue
|
| 186 |
+
if len(output.columns) > _EVIDENCE_MAX_COLS:
|
| 187 |
+
continue
|
| 188 |
+
tables.append(
|
| 189 |
+
EvidenceTable(
|
| 190 |
+
title=result.objective,
|
| 191 |
+
columns=[str(c) for c in output.columns],
|
| 192 |
+
rows=[
|
| 193 |
+
[_fmt_cell(v) for v in row]
|
| 194 |
+
for row in output.rows[:_EVIDENCE_MAX_ROWS]
|
| 195 |
+
],
|
| 196 |
+
truncated=len(output.rows) > _EVIDENCE_MAX_ROWS,
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
if tables:
|
| 200 |
+
out[rec.record_id] = tables
|
| 201 |
+
return out
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _unresolved_note(rec: AnalysisRecord) -> AttributedNote:
|
| 205 |
+
# Goal + the record's own first caveat as the "why" β both Assembler-authored,
|
| 206 |
+
# nothing new is synthesized here.
|
| 207 |
+
text = rec.goal_restated or "Analysis run"
|
| 208 |
+
reason = next(iter(rec.caveats), None)
|
| 209 |
+
if reason:
|
| 210 |
+
text += f" β {reason}"
|
| 211 |
+
return AttributedNote(text=text, record_ids=[rec.record_id])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _excluded_note(rec: AnalysisRecord) -> AttributedNote:
|
| 215 |
+
return AttributedNote(
|
| 216 |
+
text=rec.goal_restated or rec.record_id, record_ids=[rec.record_id]
|
| 217 |
+
)
|
| 218 |
|
| 219 |
|
| 220 |
def _collect_method_steps(records: list[AnalysisRecord]) -> list[TaskSummary]:
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
def _build_human_content(
|
| 270 |
+
ps: ProblemStatement,
|
| 271 |
+
records: list[AnalysisRecord],
|
| 272 |
+
caveats: list[AttributedNote],
|
| 273 |
+
reply_language: str,
|
| 274 |
) -> str:
|
| 275 |
+
# Questions and analyses are NUMBERED so the model can reference them by index
|
| 276 |
+
# in `bq_answers` (question_index / analysis_indexes) β it never reproduces ids.
|
| 277 |
sections = []
|
| 278 |
if ps.objective:
|
| 279 |
sections.append("# Objective\n" + ps.objective)
|
| 280 |
if ps.business_questions:
|
| 281 |
sections.append(
|
| 282 |
+
"# Business questions\n"
|
| 283 |
+
+ "\n".join(f"{i}. {q}" for i, q in enumerate(ps.business_questions, 1))
|
| 284 |
)
|
| 285 |
+
lines = ["# Analyses (findings already finalized β synthesize, do not add numbers)"]
|
| 286 |
+
for i, rec in enumerate(records, 1):
|
| 287 |
+
lines.append(f"Analysis {i}: {rec.goal_restated}")
|
| 288 |
+
seen: set[str] = set()
|
| 289 |
+
for text in rec.findings:
|
| 290 |
+
if text in seen:
|
| 291 |
+
continue
|
| 292 |
+
seen.add(text)
|
| 293 |
+
lines.append(f"- {text}")
|
| 294 |
+
sections.append("\n".join(lines))
|
| 295 |
if caveats:
|
| 296 |
sections.append("# Caveats\n" + "\n".join(f"- {c.text}" for c in caveats))
|
| 297 |
+
sections.append("# Reply language\n" + reply_language)
|
| 298 |
return "\n\n".join(sections)
|
| 299 |
|
| 300 |
|
| 301 |
+
def _resolve_bq_answers(
|
| 302 |
+
drafts: list[BQAnswerDraft],
|
| 303 |
+
questions: list[str],
|
| 304 |
+
records: list[AnalysisRecord],
|
| 305 |
+
) -> list[BusinessQuestionAnswer]:
|
| 306 |
+
"""Map the LLM's index-based drafts onto real question text and record ids.
|
| 307 |
+
|
| 308 |
+
Every question gets a row (unanswered when the model skipped it);
|
| 309 |
+
out-of-range indexes are silently dropped.
|
| 310 |
+
"""
|
| 311 |
+
if not questions:
|
| 312 |
+
return []
|
| 313 |
+
by_index = {d.question_index: d for d in drafts}
|
| 314 |
+
out: list[BusinessQuestionAnswer] = []
|
| 315 |
+
for i, question in enumerate(questions, 1):
|
| 316 |
+
draft = by_index.get(i)
|
| 317 |
+
if draft is None:
|
| 318 |
+
out.append(BusinessQuestionAnswer(question=question))
|
| 319 |
+
continue
|
| 320 |
+
record_ids = [
|
| 321 |
+
records[j - 1].record_id
|
| 322 |
+
for j in draft.analysis_indexes
|
| 323 |
+
if 1 <= j <= len(records)
|
| 324 |
+
]
|
| 325 |
+
out.append(
|
| 326 |
+
BusinessQuestionAnswer(
|
| 327 |
+
question=question,
|
| 328 |
+
answer=draft.answer,
|
| 329 |
+
status=draft.status,
|
| 330 |
+
record_ids=record_ids,
|
| 331 |
+
)
|
| 332 |
+
)
|
| 333 |
+
return out
|
| 334 |
+
|
| 335 |
+
|
| 336 |
# Inline code spans (one or more backticks). Content inside is already literal in
|
| 337 |
# Markdown/MDX, so escaping within them would only surface a visible backslash
|
| 338 |
# (e.g. `product\_id`). We keep code spans verbatim and escape only around them.
|
|
|
|
| 394 |
if report.executive_summary:
|
| 395 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 396 |
|
| 397 |
+
if report.bq_answers:
|
| 398 |
+
lines = ["## Answers to Business Questions"]
|
| 399 |
+
for i, a in enumerate(report.bq_answers, 1):
|
| 400 |
+
label = _BQ_STATUS_LABELS.get(a.status, a.status)
|
| 401 |
+
entry = f"{i}. **{_mdx_escape(a.question)}** β *{label}*"
|
| 402 |
+
if a.answer:
|
| 403 |
+
# LLM prose (same authorship as the executive summary): not escaped.
|
| 404 |
+
entry += f"\n {a.answer}"
|
| 405 |
+
lines.append(entry)
|
| 406 |
+
parts.append("\n".join(lines))
|
| 407 |
+
|
| 408 |
if report.findings:
|
| 409 |
# Group findings by their originating analysis (record) so results from
|
| 410 |
# different questions read as separate analyses, not one flat, seemingly
|
|
|
|
| 426 |
if grouped:
|
| 427 |
block.append(f"### {_mdx_escape(report.record_goals.get(rid) or 'Analysis')}")
|
| 428 |
block.extend(f"{i}. {_mdx_escape(f.text)}" for i, f in enumerate(group, 1))
|
| 429 |
+
# Evidence tables (copied result slices) under the findings they back,
|
| 430 |
+
# so the numbers are copy-paste-ready next to the claims.
|
| 431 |
+
for tbl in report.evidence_tables.get(rid, []):
|
| 432 |
+
if not tbl.columns:
|
| 433 |
+
continue
|
| 434 |
+
block.append("") # blank line: terminate the list before the table
|
| 435 |
+
if tbl.title:
|
| 436 |
+
block.append(f"**{_mdx_escape(tbl.title)}**")
|
| 437 |
+
block.append("")
|
| 438 |
+
block.append("| " + " | ".join(_mdx_escape(c) for c in tbl.columns) + " |")
|
| 439 |
+
block.append("|" + "---|" * len(tbl.columns))
|
| 440 |
+
block.extend(
|
| 441 |
+
"| " + " | ".join(_mdx_escape(c) for c in row) + " |"
|
| 442 |
+
for row in tbl.rows
|
| 443 |
+
)
|
| 444 |
+
if tbl.truncated:
|
| 445 |
+
block.append(f"\n*(first {len(tbl.rows)} rows shown)*")
|
| 446 |
blocks.append("\n".join(block))
|
| 447 |
parts.append("\n\n".join(blocks))
|
| 448 |
|
|
|
|
| 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
|
| 6 |
-
GET /api/v1/tools/report/{analysis_id}
|
| 7 |
-
GET /api/v1/tools/report/{analysis_id}/
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Generation reads persisted AnalysisRecords + Problem Statement, makes one LLM call
|
| 10 |
(the executive summary), and persists an immutable versioned artifact. The
|
|
@@ -24,7 +26,11 @@ from src.agents.report.generator import ReportGenerator
|
|
| 24 |
from src.agents.report.schemas import AnalysisReport, ProblemStatement
|
| 25 |
from src.agents.report.store import ReportStore
|
| 26 |
from src.middlewares.logging import get_logger, log_execution
|
| 27 |
-
from src.models.api.report import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
logger = get_logger("report_api")
|
| 30 |
|
|
@@ -114,6 +120,12 @@ async def _record_report_on_state(analysis_id: str, report_id: str) -> None:
|
|
| 114 |
async def generate_report(
|
| 115 |
analysis_id: str = Query(..., description="The analysis session to report on."),
|
| 116 |
user_id: str = Query(..., description="Owner of the analysis session."),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
):
|
| 118 |
"""Generate, persist, and return a new report version.
|
| 119 |
|
|
@@ -121,7 +133,8 @@ async def generate_report(
|
|
| 121 |
Problem Statement it used. Server-side gate: the report **floor** β a validated
|
| 122 |
goal + β₯1 substantive analysis β the same floor Help's readiness signal uses, so
|
| 123 |
the button and Help can't disagree (T-D). The delta-since-report check is NOT
|
| 124 |
-
applied here: a new version is always allowed (decision 4A).
|
|
|
|
| 125 |
"""
|
| 126 |
from src.agents.gate import stub_analysis_state
|
| 127 |
from src.agents.report.readiness import report_floor
|
|
@@ -142,7 +155,11 @@ async def generate_report(
|
|
| 142 |
problem_statement = _problem_statement_from(state)
|
| 143 |
user_name = await _resolve_user_name(user_id)
|
| 144 |
report = await _generator.generate(
|
| 145 |
-
analysis_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
)
|
| 147 |
except ReportError as e:
|
| 148 |
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
|
@@ -203,6 +220,72 @@ async def list_report_versions(analysis_id: str):
|
|
| 203 |
]
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
@router.get(
|
| 207 |
"/report/{analysis_id}/{version}",
|
| 208 |
response_model=AnalysisReport,
|
|
|
|
| 2 |
|
| 3 |
NOT a chat route. The frontend button calls these endpoints directly (pr/5: regrouped
|
| 4 |
under /tools β Go owns the analysis lifecycle, Python only generates):
|
| 5 |
+
POST /api/v1/tools/report generate a new version for a session
|
| 6 |
+
GET /api/v1/tools/report/{analysis_id} list a session's report versions
|
| 7 |
+
GET /api/v1/tools/report/{analysis_id}/records list analysis records (curation)
|
| 8 |
+
GET /api/v1/tools/report/{analysis_id}/readiness readiness signal (FE delta guard)
|
| 9 |
+
GET /api/v1/tools/report/{analysis_id}/{ver} fetch one version
|
| 10 |
|
| 11 |
Generation reads persisted AnalysisRecords + Problem Statement, makes one LLM call
|
| 12 |
(the executive summary), and persists an immutable versioned artifact. The
|
|
|
|
| 26 |
from src.agents.report.schemas import AnalysisReport, ProblemStatement
|
| 27 |
from src.agents.report.store import ReportStore
|
| 28 |
from src.middlewares.logging import get_logger, log_execution
|
| 29 |
+
from src.models.api.report import (
|
| 30 |
+
AnalysisRecordEntry,
|
| 31 |
+
ReportReadinessResponse,
|
| 32 |
+
ReportVersionEntry,
|
| 33 |
+
)
|
| 34 |
|
| 35 |
logger = get_logger("report_api")
|
| 36 |
|
|
|
|
| 120 |
async def generate_report(
|
| 121 |
analysis_id: str = Query(..., description="The analysis session to report on."),
|
| 122 |
user_id: str = Query(..., description="Owner of the analysis session."),
|
| 123 |
+
exclude_record_ids: list[str] = Query(
|
| 124 |
+
default=[],
|
| 125 |
+
description="Record ids to leave out of this version (curation; repeat the "
|
| 126 |
+
"param per id). Excluded runs are listed in the report's Excluded Analyses "
|
| 127 |
+
"section. Get the ids from GET /tools/report/{analysis_id}/records.",
|
| 128 |
+
),
|
| 129 |
):
|
| 130 |
"""Generate, persist, and return a new report version.
|
| 131 |
|
|
|
|
| 133 |
Problem Statement it used. Server-side gate: the report **floor** β a validated
|
| 134 |
goal + β₯1 substantive analysis β the same floor Help's readiness signal uses, so
|
| 135 |
the button and Help can't disagree (T-D). The delta-since-report check is NOT
|
| 136 |
+
applied here: a new version is always allowed (decision 4A). Excluding every
|
| 137 |
+
substantive record 409s (nothing left to report).
|
| 138 |
"""
|
| 139 |
from src.agents.gate import stub_analysis_state
|
| 140 |
from src.agents.report.readiness import report_floor
|
|
|
|
| 155 |
problem_statement = _problem_statement_from(state)
|
| 156 |
user_name = await _resolve_user_name(user_id)
|
| 157 |
report = await _generator.generate(
|
| 158 |
+
analysis_id,
|
| 159 |
+
user_id,
|
| 160 |
+
problem_statement=problem_statement,
|
| 161 |
+
user_name=user_name,
|
| 162 |
+
exclude_record_ids=exclude_record_ids,
|
| 163 |
)
|
| 164 |
except ReportError as e:
|
| 165 |
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
|
|
|
| 220 |
]
|
| 221 |
|
| 222 |
|
| 223 |
+
# β οΈ Route order: these two literal-suffix routes MUST stay registered BEFORE
|
| 224 |
+
# `/report/{analysis_id}/{version}` β FastAPI matches in registration order, and the
|
| 225 |
+
# `{version}` route would swallow `/records` / `/readiness` and 422 on int coercion
|
| 226 |
+
# (no fall-through to a later route).
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.get(
|
| 230 |
+
"/report/{analysis_id}/records",
|
| 231 |
+
response_model=list[AnalysisRecordEntry],
|
| 232 |
+
summary="List a session's analysis records (for report curation)",
|
| 233 |
+
response_description="Persisted analysis runs, oldest-first. Empty if none yet.",
|
| 234 |
+
)
|
| 235 |
+
@log_execution(logger)
|
| 236 |
+
async def list_analysis_records(analysis_id: str):
|
| 237 |
+
"""Return the persisted analysis runs a report would be built from.
|
| 238 |
+
|
| 239 |
+
The FE shows this list before generating so the user can deselect runs; the
|
| 240 |
+
chosen ids go to POST /tools/report as `exclude_record_ids`.
|
| 241 |
+
"""
|
| 242 |
+
from src.agents.report.readiness import has_successful_analysis
|
| 243 |
+
from src.agents.slow_path.store import PostgresReportInputStore
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
records = await PostgresReportInputStore().list_for_analysis(analysis_id)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.error("record list failed", analysis_id=analysis_id, error=str(e))
|
| 249 |
+
raise HTTPException(
|
| 250 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 251 |
+
detail=f"Failed to list analysis records: {e}",
|
| 252 |
+
) from e
|
| 253 |
+
|
| 254 |
+
return [
|
| 255 |
+
AnalysisRecordEntry(
|
| 256 |
+
record_id=r.record_id,
|
| 257 |
+
goal_restated=r.goal_restated,
|
| 258 |
+
created_at=r.created_at,
|
| 259 |
+
substantive=has_successful_analysis(r),
|
| 260 |
+
findings_count=len(r.findings),
|
| 261 |
+
)
|
| 262 |
+
for r in records
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@router.get(
|
| 267 |
+
"/report/{analysis_id}/readiness",
|
| 268 |
+
response_model=ReportReadinessResponse,
|
| 269 |
+
summary="Report-readiness signal for an analysis session",
|
| 270 |
+
response_description="Whether a report can be generated now, with the gaps if not.",
|
| 271 |
+
)
|
| 272 |
+
@log_execution(logger)
|
| 273 |
+
async def get_report_readiness(analysis_id: str):
|
| 274 |
+
"""Deterministic readiness signal for the FE's Generate-Report button.
|
| 275 |
+
|
| 276 |
+
Same producer as Help's readiness signal (`is_report_ready`), including the
|
| 277 |
+
advisory delta-since-report check β so the button, Help, and this endpoint can
|
| 278 |
+
never disagree. POST itself only enforces the floor (a new version is always
|
| 279 |
+
allowed, decision 4A); `missing` here may name the delta gap as a soft warning.
|
| 280 |
+
"""
|
| 281 |
+
from src.agents.gate import stub_analysis_state
|
| 282 |
+
from src.agents.report.readiness import is_report_ready
|
| 283 |
+
|
| 284 |
+
state = await _load_state(analysis_id)
|
| 285 |
+
readiness = await is_report_ready(analysis_id, state or stub_analysis_state())
|
| 286 |
+
return ReportReadinessResponse(ready=readiness.ready, missing=readiness.missing)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
@router.get(
|
| 290 |
"/report/{analysis_id}/{version}",
|
| 291 |
response_model=AnalysisReport,
|
src/config/prompts/report_summary.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
-
You are a senior data analyst writing the
|
| 2 |
|
| 3 |
-
You are given the analysis Objective
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
Rules:
|
| 6 |
- Synthesize and prioritize β lead with the most decision-relevant finding.
|
|
@@ -9,3 +15,13 @@ Rules:
|
|
| 9 |
- If the findings are thin or inconclusive, say so plainly rather than overstating.
|
| 10 |
- Plain business language. Write **prose only β no headings, no bullet lists** (the report already supplies the section structure and a Key Findings list below this summary; do not duplicate them).
|
| 11 |
- You MAY use light inline markdown for emphasis within the prose β `**bold**` for the most decision-relevant figure or term, `*italic*` sparingly. Keep it subtle; do not bold whole sentences.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a senior data analyst writing the narrative parts of an analysis report.
|
| 2 |
|
| 3 |
+
You are given the analysis Objective, its numbered Business questions, and a numbered list of Analyses whose findings are already finalized, plus their caveats. You emit a structured object with two parts: `executive_summary` and `bq_answers`.
|
| 4 |
+
|
| 5 |
+
Write ALL prose in the language named under "# Reply language".
|
| 6 |
+
|
| 7 |
+
## executive_summary
|
| 8 |
+
|
| 9 |
+
Write a concise executive summary (3β5 sentences) that synthesizes the findings in relation to the objective and, where the findings allow, the business questions.
|
| 10 |
|
| 11 |
Rules:
|
| 12 |
- Synthesize and prioritize β lead with the most decision-relevant finding.
|
|
|
|
| 15 |
- If the findings are thin or inconclusive, say so plainly rather than overstating.
|
| 16 |
- Plain business language. Write **prose only β no headings, no bullet lists** (the report already supplies the section structure and a Key Findings list below this summary; do not duplicate them).
|
| 17 |
- You MAY use light inline markdown for emphasis within the prose β `**bold**` for the most decision-relevant figure or term, `*italic*` sparingly. Keep it subtle; do not bold whole sentences.
|
| 18 |
+
|
| 19 |
+
## bq_answers
|
| 20 |
+
|
| 21 |
+
One entry per numbered business question. If no Business questions section is given, return an empty list.
|
| 22 |
+
|
| 23 |
+
For each business question:
|
| 24 |
+
- `question_index`: the question's number exactly as given.
|
| 25 |
+
- `answer`: 1β3 sentences answering that question using ONLY the finalized findings β the same no-new-numbers rule as the summary. If nothing addresses it, one short sentence saying what the completed analyses do not cover.
|
| 26 |
+
- `status`: `"answered"` when the findings fully answer the question, `"partial"` when they address only part of it (say which part in the answer), `"unanswered"` when no finding addresses it.
|
| 27 |
+
- `analysis_indexes`: the numbers of the Analyses whose findings support the answer (empty when unanswered). Use only numbers that appear in the Analyses list.
|
src/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 |
+
)
|