[KM-715] report: embed charts as plotly EDA fences; render_chart counts toward the floor
Browse filesFE fence hook verified by the lead, so the deferred embedding lands as sketched
in SPINE_V2_PLAN's decision log: _collect_charts copies dataeyond.chart.v1
envelopes verbatim (INV-4, <=3/record) from results_snapshot into
AnalysisReport.charts; _render_markdown emits them in the reserved ## EDA
section as ```plotly fences (content = the FULL envelope, pretty-printed — the
FE hook's verified shape; bare {data,layout} does not render).
has_successful_analysis now counts a successful render_chart, so a chart-only
session is substantive and satisfies the report floor (deliberate extension,
Rifqi-approved). Verified live: report v4 on analysis 7be50846 embeds 3 charts,
FE-rendered OK. Suite 381 passed, 7 skipped (+5 chart-embed tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -13,6 +13,7 @@ Chain construction mirrors `agents/slow_path/assembler.py`.
|
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
|
|
| 16 |
import re
|
| 17 |
from datetime import UTC, datetime
|
| 18 |
from pathlib import Path
|
|
@@ -201,6 +202,33 @@ def _collect_evidence(records: list[AnalysisRecord]) -> dict[str, list[EvidenceT
|
|
| 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.
|
|
@@ -446,8 +474,26 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 446 |
blocks.append("\n".join(block))
|
| 447 |
parts.append("\n\n".join(blocks))
|
| 448 |
|
| 449 |
-
# ## EDA —
|
| 450 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
if report.data_sources:
|
| 453 |
lines = ["## Data Sources", "| source | type | detail |", "|---|---|---|"]
|
|
@@ -607,6 +653,7 @@ class ReportGenerator:
|
|
| 607 |
unresolved=[_unresolved_note(r) for r in unresolved_records],
|
| 608 |
excluded=[_excluded_note(r) for r in excluded],
|
| 609 |
evidence_tables=_collect_evidence(records),
|
|
|
|
| 610 |
data_sources=data_sources,
|
| 611 |
method_steps=method_steps,
|
| 612 |
)
|
|
|
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
+
import json
|
| 17 |
import re
|
| 18 |
from datetime import UTC, datetime
|
| 19 |
from pathlib import Path
|
|
|
|
| 202 |
return out
|
| 203 |
|
| 204 |
|
| 205 |
+
_CHARTS_MAX_PER_RECORD = 3 # mirrors _EVIDENCE_MAX_TABLES
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _collect_charts(records: list[AnalysisRecord]) -> dict[str, list[dict]]:
|
| 209 |
+
"""Copy chart envelopes out of each record's `results_snapshot` (INV-4).
|
| 210 |
+
|
| 211 |
+
`render_chart` outputs only — the full `dataeyond.chart.v1` envelope, verbatim
|
| 212 |
+
(the snapshot trimmer never touches chart-kind outputs, so the spec is whole).
|
| 213 |
+
Rendered as ```plotly fenced blocks in the markdown EDA section.
|
| 214 |
+
"""
|
| 215 |
+
out: dict[str, list[dict]] = {}
|
| 216 |
+
for rec in records:
|
| 217 |
+
charts: list[dict] = []
|
| 218 |
+
for result in rec.results_snapshot.values():
|
| 219 |
+
for output in result.outputs:
|
| 220 |
+
if len(charts) >= _CHARTS_MAX_PER_RECORD:
|
| 221 |
+
break
|
| 222 |
+
if output.kind != "chart" or not isinstance(output.value, dict):
|
| 223 |
+
continue
|
| 224 |
+
if not isinstance(output.value.get("plotly"), dict):
|
| 225 |
+
continue
|
| 226 |
+
charts.append(output.value)
|
| 227 |
+
if charts:
|
| 228 |
+
out[rec.record_id] = charts
|
| 229 |
+
return out
|
| 230 |
+
|
| 231 |
+
|
| 232 |
def _unresolved_note(rec: AnalysisRecord) -> AttributedNote:
|
| 233 |
# Goal + the record's own first caveat as the "why" — both Assembler-authored,
|
| 234 |
# nothing new is synthesized here.
|
|
|
|
| 474 |
blocks.append("\n".join(block))
|
| 475 |
parts.append("\n\n".join(blocks))
|
| 476 |
|
| 477 |
+
# ## EDA — charts (W2, 2026-07-14). Each chart a `render_chart` task produced
|
| 478 |
+
# is emitted as a ```plotly fenced block the FE's fence hook renders with
|
| 479 |
+
# plotly.js. Fence content is the FULL dataeyond.chart.v1 envelope, verbatim
|
| 480 |
+
# (INV-4) and pretty-printed — the shape the FE hook parses (verified against
|
| 481 |
+
# the FE 2026-07-14; it reads spec.plotly internally). Section omitted when no
|
| 482 |
+
# record produced a chart.
|
| 483 |
+
if report.charts:
|
| 484 |
+
lines = ["## EDA"]
|
| 485 |
+
ordered_rids = [rid for rid in report.record_ids if rid in report.charts]
|
| 486 |
+
ordered_rids += [rid for rid in report.charts if rid not in ordered_rids]
|
| 487 |
+
for rid in ordered_rids:
|
| 488 |
+
for envelope in report.charts[rid]:
|
| 489 |
+
caption = envelope.get("title") or report.record_goals.get(rid) or "Chart"
|
| 490 |
+
lines.append(f"**{_mdx_escape(str(caption))}**")
|
| 491 |
+
lines.append("")
|
| 492 |
+
lines.append("```plotly")
|
| 493 |
+
lines.append(json.dumps(envelope, ensure_ascii=False, indent=2))
|
| 494 |
+
lines.append("```")
|
| 495 |
+
lines.append("")
|
| 496 |
+
parts.append("\n".join(lines).rstrip())
|
| 497 |
|
| 498 |
if report.data_sources:
|
| 499 |
lines = ["## Data Sources", "| source | type | detail |", "|---|---|---|"]
|
|
|
|
| 653 |
unresolved=[_unresolved_note(r) for r in unresolved_records],
|
| 654 |
excluded=[_excluded_note(r) for r in excluded],
|
| 655 |
evidence_tables=_collect_evidence(records),
|
| 656 |
+
charts=_collect_charts(records),
|
| 657 |
data_sources=data_sources,
|
| 658 |
method_steps=method_steps,
|
| 659 |
)
|
|
@@ -72,15 +72,18 @@ def _is_newer(a: datetime, b: datetime) -> bool:
|
|
| 72 |
|
| 73 |
|
| 74 |
def has_successful_analysis(record) -> bool:
|
| 75 |
-
"""True if the record has at least one *
|
| 76 |
|
| 77 |
A failed run still writes findings (narrating the failure) and its data-access
|
| 78 |
tasks (check_/retrieve_) succeed, so we can't key on findings or on "any task
|
| 79 |
-
succeeded".
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
"""
|
| 82 |
return any(
|
| 83 |
-
t.status == "success"
|
|
|
|
| 84 |
for t in record.tasks_run
|
| 85 |
)
|
| 86 |
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def has_successful_analysis(record) -> bool:
|
| 75 |
+
"""True if the record has at least one *result-producing* task that succeeded.
|
| 76 |
|
| 77 |
A failed run still writes findings (narrating the failure) and its data-access
|
| 78 |
tasks (check_/retrieve_) succeed, so we can't key on findings or on "any task
|
| 79 |
+
succeeded". A completed analysis tool (analyze_*) — or, since W2 charts
|
| 80 |
+
(2026-07-14), a completed `render_chart`, whose viz-tail upstream necessarily
|
| 81 |
+
computed the numbers being charted — is the real "we produced a result"
|
| 82 |
+
signal. A chart-only session therefore satisfies the report floor.
|
| 83 |
"""
|
| 84 |
return any(
|
| 85 |
+
t.status == "success"
|
| 86 |
+
and any(tool.startswith("analyze") or tool == "render_chart" for tool in t.tools_used)
|
| 87 |
for t in record.tasks_run
|
| 88 |
)
|
| 89 |
|
|
@@ -146,6 +146,10 @@ class AnalysisReport(BaseModel):
|
|
| 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 = ""
|
|
|
|
| 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 |
+
# record_id -> dataeyond.chart.v1 envelopes, copied verbatim from that record's
|
| 150 |
+
# results_snapshot (W2 charts, 2026-07-14). The markdown renders each as a
|
| 151 |
+
# ```plotly fenced block in the EDA section; the FE's fence hook draws it.
|
| 152 |
+
charts: dict[str, list[dict]] = Field(default_factory=dict)
|
| 153 |
data_sources: list[DataSourceRef] = Field(default_factory=list)
|
| 154 |
method_steps: list[TaskSummary] = Field(default_factory=list) # carries `stage`
|
| 155 |
rendered_markdown: str = ""
|