Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit ·
029b488
1
Parent(s): aacd4d1
/fix report: group findings by analysis, dedup, code-span-safe MDX escaping
Browse files- Key Findings now grouped under each analysis's restated question so
results from different questions don't read as one contradictory list
- dedup exact-duplicate findings within a record
- _mdx_escape leaves inline code spans verbatim (`product_id` no longer
renders as `product\_id`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/report/generator.py +60 -16
- src/agents/report/schemas.py +4 -0
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 |
from datetime import UTC, datetime
|
| 17 |
from pathlib import Path
|
| 18 |
|
|
@@ -98,12 +99,18 @@ def _get_default_chain() -> Runnable:
|
|
| 98 |
|
| 99 |
|
| 100 |
def _collect_findings(records: list[AnalysisRecord]) -> list[ReportFinding]:
|
| 101 |
-
#
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
def _collect_notes(records: list[AnalysisRecord], field: str) -> list[AttributedNote]:
|
|
@@ -190,21 +197,39 @@ def _build_human_content(
|
|
| 190 |
return "\n\n".join(sections)
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def _mdx_escape(text: str) -> str:
|
| 194 |
"""Escape Markdown/MDX syntax characters in a dynamic value.
|
| 195 |
|
| 196 |
Applied only to values authored outside the renderer (LLM findings/caveats,
|
| 197 |
user objective/questions, catalog source & table names, tool names) so that
|
| 198 |
identifiers like ``XL_S_129`` don't italicize and stray ``<``/``{`` don't break
|
| 199 |
-
MDX compilation.
|
| 200 |
-
|
|
|
|
| 201 |
"""
|
| 202 |
if not text:
|
| 203 |
return text
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
|
| 210 |
def _render_markdown(report: AnalysisReport) -> str:
|
|
@@ -234,10 +259,28 @@ def _render_markdown(report: AnalysisReport) -> str:
|
|
| 234 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 235 |
|
| 236 |
if report.findings:
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
# ## EDA — reserved for future exploratory visuals; charts wrapped as MDX
|
| 243 |
# components will render here. Emitted only when it has content; omitted today.
|
|
@@ -354,6 +397,7 @@ class ReportGenerator:
|
|
| 354 |
generated_at=datetime.now(UTC),
|
| 355 |
problem_statement=ps,
|
| 356 |
record_ids=[r.record_id for r in records],
|
|
|
|
| 357 |
executive_summary=executive_summary,
|
| 358 |
findings=findings,
|
| 359 |
caveats=caveats,
|
|
|
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
+
import re
|
| 17 |
from datetime import UTC, datetime
|
| 18 |
from pathlib import Path
|
| 19 |
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def _collect_findings(records: list[AnalysisRecord]) -> list[ReportFinding]:
|
| 102 |
+
# Each finding traces to its record. Deduped *within* a record (an Assembler run
|
| 103 |
+
# occasionally repeats a line); kept across records so the grouped render can show
|
| 104 |
+
# each analysis's own findings under its own question.
|
| 105 |
+
out: list[ReportFinding] = []
|
| 106 |
+
for rec in records:
|
| 107 |
+
seen: set[str] = set()
|
| 108 |
+
for text in rec.findings:
|
| 109 |
+
if text in seen:
|
| 110 |
+
continue
|
| 111 |
+
seen.add(text)
|
| 112 |
+
out.append(ReportFinding(text=text, record_ids=[rec.record_id]))
|
| 113 |
+
return out
|
| 114 |
|
| 115 |
|
| 116 |
def _collect_notes(records: list[AnalysisRecord], field: str) -> list[AttributedNote]:
|
|
|
|
| 197 |
return "\n\n".join(sections)
|
| 198 |
|
| 199 |
|
| 200 |
+
# Inline code spans (one or more backticks). Content inside is already literal in
|
| 201 |
+
# Markdown/MDX, so escaping within them would only surface a visible backslash
|
| 202 |
+
# (e.g. `product\_id`). We keep code spans verbatim and escape only around them.
|
| 203 |
+
_CODE_SPAN_RE = re.compile(r"`+[^`]*`+")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
def _mdx_escape(text: str) -> str:
|
| 207 |
"""Escape Markdown/MDX syntax characters in a dynamic value.
|
| 208 |
|
| 209 |
Applied only to values authored outside the renderer (LLM findings/caveats,
|
| 210 |
user objective/questions, catalog source & table names, tool names) so that
|
| 211 |
identifiers like ``XL_S_129`` don't italicize and stray ``<``/``{`` don't break
|
| 212 |
+
MDX compilation. Content inside inline code spans is left verbatim (already
|
| 213 |
+
literal). NOT applied to the executive summary (its prompt allows light inline
|
| 214 |
+
emphasis) nor to the structural markdown the renderer emits itself.
|
| 215 |
"""
|
| 216 |
if not text:
|
| 217 |
return text
|
| 218 |
+
|
| 219 |
+
def _esc(s: str) -> str:
|
| 220 |
+
s = s.replace("\\", "\\\\")
|
| 221 |
+
for ch in ("<", "{", "|", "_", "*"):
|
| 222 |
+
s = s.replace(ch, "\\" + ch)
|
| 223 |
+
return s
|
| 224 |
+
|
| 225 |
+
out: list[str] = []
|
| 226 |
+
pos = 0
|
| 227 |
+
for m in _CODE_SPAN_RE.finditer(text):
|
| 228 |
+
out.append(_esc(text[pos : m.start()]))
|
| 229 |
+
out.append(m.group(0)) # keep the code span verbatim
|
| 230 |
+
pos = m.end()
|
| 231 |
+
out.append(_esc(text[pos:]))
|
| 232 |
+
return "".join(out)
|
| 233 |
|
| 234 |
|
| 235 |
def _render_markdown(report: AnalysisReport) -> str:
|
|
|
|
| 259 |
parts.append("## Executive Summary\n" + report.executive_summary)
|
| 260 |
|
| 261 |
if report.findings:
|
| 262 |
+
# Group findings by their originating analysis (record) so results from
|
| 263 |
+
# different questions read as separate analyses, not one flat, seemingly
|
| 264 |
+
# contradictory list. Subheadings (the restated question) appear only when
|
| 265 |
+
# more than one analysis contributed.
|
| 266 |
+
by_record: dict[str, list[ReportFinding]] = {}
|
| 267 |
+
for f in report.findings:
|
| 268 |
+
by_record.setdefault(f.record_ids[0] if f.record_ids else "", []).append(f)
|
| 269 |
+
ordered = [rid for rid in report.record_goals if rid in by_record]
|
| 270 |
+
ordered += [rid for rid in by_record if rid not in ordered]
|
| 271 |
+
grouped = sum(1 for rid in ordered if by_record.get(rid)) > 1
|
| 272 |
+
|
| 273 |
+
blocks = ["## Key Findings"]
|
| 274 |
+
for rid in ordered:
|
| 275 |
+
group = by_record.get(rid)
|
| 276 |
+
if not group:
|
| 277 |
+
continue
|
| 278 |
+
block: list[str] = []
|
| 279 |
+
if grouped:
|
| 280 |
+
block.append(f"### {_mdx_escape(report.record_goals.get(rid) or 'Analysis')}")
|
| 281 |
+
block.extend(f"{i}. {_mdx_escape(f.text)}" for i, f in enumerate(group, 1))
|
| 282 |
+
blocks.append("\n".join(block))
|
| 283 |
+
parts.append("\n\n".join(blocks))
|
| 284 |
|
| 285 |
# ## EDA — reserved for future exploratory visuals; charts wrapped as MDX
|
| 286 |
# components will render here. Emitted only when it has content; omitted today.
|
|
|
|
| 397 |
generated_at=datetime.now(UTC),
|
| 398 |
problem_statement=ps,
|
| 399 |
record_ids=[r.record_id for r in records],
|
| 400 |
+
record_goals={r.record_id: r.goal_restated for r in records},
|
| 401 |
executive_summary=executive_summary,
|
| 402 |
findings=findings,
|
| 403 |
caveats=caveats,
|
src/agents/report/schemas.py
CHANGED
|
@@ -82,6 +82,10 @@ class AnalysisReport(BaseModel):
|
|
| 82 |
# Frozen snapshots.
|
| 83 |
problem_statement: ProblemStatement = Field(default_factory=ProblemStatement)
|
| 84 |
record_ids: list[str] = Field(default_factory=list) # records used (snapshot)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# LLM-authored.
|
| 86 |
executive_summary: str = ""
|
| 87 |
# Deterministic pass-through from records.
|
|
|
|
| 82 |
# Frozen snapshots.
|
| 83 |
problem_statement: ProblemStatement = Field(default_factory=ProblemStatement)
|
| 84 |
record_ids: list[str] = Field(default_factory=list) # records used (snapshot)
|
| 85 |
+
# record_id -> restated question, in record order. Lets the renderer group Key
|
| 86 |
+
# Findings per analysis so results from different questions aren't a flat list
|
| 87 |
+
# that reads as contradictory. Rebuilt-empty on dedorch read-back (markdown only).
|
| 88 |
+
record_goals: dict[str, str] = Field(default_factory=dict)
|
| 89 |
# LLM-authored.
|
| 90 |
executive_summary: str = ""
|
| 91 |
# Deterministic pass-through from records.
|