Rifqi Hafizuddin Claude Fable 5 commited on
Commit
89db8be
·
1 Parent(s): b8d1576

[KM-715] report: embed charts as plotly EDA fences; render_chart counts toward the floor

Browse files

FE 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>

src/agents/report/generator.py CHANGED
@@ -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 — reserved for future exploratory visuals; charts wrapped as MDX
450
- # components will render here. Emitted only when it has content; omitted today.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  )
src/agents/report/readiness.py CHANGED
@@ -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 *analysis* 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". An analysis tool (analyze_*) completing is the real "we produced a
80
- result" signal.
 
 
81
  """
82
  return any(
83
- t.status == "success" and any(tool.startswith("analyze") for tool in t.tools_used)
 
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
 
src/agents/report/schemas.py CHANGED
@@ -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 = ""