Coverage for src/ai_jury/report.py: 100%

320 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:18 +0000

1"""Render the jury run into a single markdown report.""" 

2 

3from __future__ import annotations 

4 

5from . import classification as _classification 

6from .adapters import AgentResult 

7from .findings import SEVERITY_ORDER, Finding, flatten_inline 

8 

9 

10def _block(title: str, body: str) -> str: 

11 return f"### {title}\n\n{body.strip() or '_(no output)_'}\n" 

12 

13 

14def _fail_status(r: AgentResult) -> str: 

15 """Failed-agent status line with a concise typed-error-code prefix.""" 

16 prefix = f"[{r.error_code}] " if getattr(r, "error_code", None) else "" 

17 # The error snippet quotes the agent CLI's stderr (attacker-influenced) and 

18 # is posted to the PR, so flatten it like every other untrusted field so it 

19 # can't forge a heading/fence in the comment (audit 2026-06-13 r4). 

20 return f"⚠️ {prefix}{flatten_inline(r.error)}" 

21 

22 

23def _finding_line(f: Finding) -> str: 

24 loc = flatten_inline(f.file) or "?" 

25 if f.line is not None: 

26 loc = f"{loc}:{f.line}" 

27 claim = flatten_inline(f.claim) 

28 return f"- [{f.severity}] {loc}{claim} ({f.confidence}, by {f.reviewer})" 

29 

30 

31_BUCKET_LABELS = { 

32 "consensus": "Consensus (all reviewers)", 

33 "majority": "Majority", 

34 "single_reviewer": "Single reviewer", 

35 "disputed": "Disputed (needs human decision)", 

36 "rejected": "Rejected (unsupported by verifier)", 

37} 

38_BUCKET_ORDER = ["consensus", "majority", "single_reviewer", "disputed", "rejected"] 

39 

40_STATUS_LABELS = { 

41 "verified": "verified", 

42 "unsupported": "unsupported", 

43 "needs_human_decision": "needs human decision", 

44} 

45 

46 

47def _group_line(g) -> str: 

48 f = g.representative 

49 loc = flatten_inline(f.file) or "?" 

50 if f.line is not None: 

51 loc = f"{loc}:{f.line}" 

52 reviewers = ", ".join(g.reviewers) if g.reviewers else "(unknown)" 

53 

54 # Attacker-influenced fields (claim/evidence/fix) are flattened to one line 

55 # so they cannot forge a heading or open a code fence in the posted report 

56 # (audit 2026-06-13 r3). 

57 parts = [f"- [{g.severity}] {loc}{flatten_inline(f.claim)} (reviewers: {reviewers})"] 

58 

59 # Surface the reviewer's supporting evidence — the "why" behind the claim — 

60 # so the verdict is auditable, not just asserted (issue: evidence surfacing). 

61 if getattr(f, "evidence", ""): 

62 parts.append(f"\n - _evidence:_ {flatten_inline(f.evidence)}") 

63 

64 status = getattr(g, "status", "") 

65 if status: 

66 reasoning = flatten_inline(getattr(g, "status_reasoning", "")) 

67 if reasoning: 

68 parts.append( 

69 f"\n - _verification:_ {_STATUS_LABELS.get(status, status)}{reasoning}" 

70 ) 

71 else: 

72 parts.append(f"\n - _verification:_ {_STATUS_LABELS.get(status, status)}") 

73 

74 if f.suggested_fix: 

75 parts.append(f"\n - _fix:_ {flatten_inline(f.suggested_fix)}") 

76 

77 return "".join(parts) if len(parts) > 1 else parts[0] 

78 

79 

80def _metadata_block(metadata: dict) -> list[str]: 

81 """Render the deterministic run-metadata section. 

82 

83 Intentionally omits non-deterministic fields (e.g. ``generated_at``) so the 

84 Markdown report stays stable for snapshot tests. Per-agent durations are 

85 deterministic under mock (0s) and scrubbed by the golden test's duration 

86 normalizer otherwise. Wall-clock is labelled a cost proxy, not a dollar cost. 

87 """ 

88 lines = ["## Run metadata\n"] 

89 lines.append(f"- rounds executed: {metadata['rounds_executed']}") 

90 # Adaptive-rounds explanation (issue #40): only shown when the orchestrator 

91 # recorded a reason, so a plain fixed-N run stays unchanged. 

92 if metadata.get("from_cache"): 

93 lines.append("- ♻️ served from local cache (not re-computed)") 

94 stop_reason = metadata.get("stop_reason") 

95 if stop_reason: 

96 # flatten metadata strings too (defense-in-depth, audit r6/L): these are 

97 # config/internal-controlled today, but keeping them single-line means a 

98 # name/reason can never break the table or forge structure if a future 

99 # source carries agent/diff text. 

100 lines.append(f"- rounds decision: {flatten_inline(stop_reason)}") 

101 lines.append(f"- verify: {'on' if metadata['verify_enabled'] else 'off'}") 

102 lines.append(f"- context mode: {metadata['context_mode']}") 

103 # Partial-result signals (issue #30): only rendered when relevant so a 

104 # complete, unbudgeted run is unaffected. 

105 if metadata.get("budget_exhausted"): 

106 lines.append("- ⚠️ run budget exhausted: some phases were skipped") 

107 skipped = metadata.get("skipped") or [] 

108 if skipped: 

109 # bolt: CPython optimization — list comprehension inside join avoids generator overhead 

110 names = ", ".join( 

111 [f"{flatten_inline(s['name'])} ({flatten_inline(s['reason'])})" for s in skipped] 

112 ) 

113 lines.append(f"- skipped agents (never ran): {names}") 

114 retried = metadata.get("retried") or [] 

115 if retried: 

116 lines.append(f"- retried agents: {', '.join(retried)}") 

117 total = metadata["total_wall_clock_s"] 

118 lines.append(f"- total wall-clock (cost proxy, not $): {total:.0f}s") 

119 lines.append("") 

120 lines.append("| agent | vendor | status | duration |") 

121 lines.append("| --- | --- | --- | --- |") 

122 for a in metadata["agents"]: 

123 code = a.get("error_code") 

124 status = a["status"] if not code else f"{a['status']} ({code})" 

125 # Note a retried agent inline; attempts == 1 leaves the row unchanged. 

126 attempts = a.get("attempts", 1) 

127 if attempts and attempts > 1: 

128 status += f", {attempts} attempts" 

129 lines.append( 

130 f"| {flatten_inline(a['name'])} | {flatten_inline(a['vendor'])} " 

131 f"| {status} | {a['duration_s']:.0f}s |" 

132 ) 

133 lines.append("") 

134 lines.append( 

135 "_Wall-clock seconds are an approximate cost proxy (no token counts are " 

136 "available from the CLIs), not a dollar cost._\n" 

137 ) 

138 return lines 

139 

140 

141def _classification_block(classification: dict) -> list[str]: 

142 """Render the compact PR-level classification summary. 

143 

144 Deterministic: ``classification`` is produced by the pure 

145 :mod:`ai_jury.classification` module, so the rendered section is 

146 stable for a deterministic run (and golden-tested under mock). 

147 """ 

148 return [ 

149 "## Classification\n", 

150 _classification.summary_line(classification), 

151 "", 

152 ] 

153 

154 

155def _consensus_block(groups) -> list[str]: 

156 lines = ["## Consensus\n"] 

157 by_bucket: dict[str, list] = {b: [] for b in _BUCKET_ORDER} 

158 for g in groups: 

159 by_bucket.setdefault(g.bucket, []).append(g) 

160 for bucket in _BUCKET_ORDER: 

161 bg = by_bucket.get(bucket) or [] 

162 if not bg: 

163 continue 

164 lines.append(f"### {_BUCKET_LABELS.get(bucket, bucket)}\n") 

165 for g in bg: 

166 lines.append(_group_line(g)) 

167 lines.append("") 

168 return lines 

169 

170 

171def _vote_block(vote) -> list[str]: 

172 """Render the panel-vote verdict + tally + per-reviewer ballots (issue #220). 

173 

174 Vocabulary-agnostic: the tally renders whatever stances the vote carries 

175 (code: REQUEST CHANGES/COMMENT/APPROVE; issue: NEEDS-INFO/UNCLEAR/READY). 

176 """ 

177 lines = ["## Verdict — panel vote\n"] 

178 # bolt: CPython optimization — list comprehension avoids generator expression overhead 

179 tally = " · ".join([f"{n} {label.lower()}" for label, n in vote.tally.items()]) 

180 lines.append(f"**{vote.verdict}** — {tally}\n") 

181 for b in vote.ballots: 

182 lines.append(f"- `{b.reviewer}`: **{b.vote}** ({b.reason})") 

183 lines.append("") 

184 return lines 

185 

186 

187def _verdict_headline(synthesis, vote) -> str | None: 

188 """One-line verdict for the report's TL;DR callout (pure, deterministic). 

189 

190 Prefers the panel vote's verdict when voting; otherwise lifts the opening 

191 ``## Verdict`` line out of the chair's synthesis prose — both the code and 

192 issue synthesis prompts mandate a ``## Verdict\\n<LABEL> — <one sentence>`` 

193 first section, so the lift is reliable. The verdict sentence may wrap across 

194 lines; they are joined into one. Returns ``None`` when neither source is 

195 available (failed/absent synthesis, deviating output) so the caller simply 

196 omits the callout — it is purely additive, never replacing a section. 

197 """ 

198 if vote is not None and getattr(vote, "verdict", None): 

199 return vote.verdict 

200 if synthesis is None or not getattr(synthesis, "ok", False): 

201 return None 

202 rows = (synthesis.output or "").splitlines() 

203 for i, row in enumerate(rows): 

204 if row.strip().lower().lstrip("#").strip() == "verdict": 

205 collected: list[str] = [] 

206 for nxt in rows[i + 1 :]: 

207 if nxt.strip().startswith("#"): 

208 break 

209 if not nxt.strip(): 

210 if collected: 

211 break 

212 continue 

213 collected.append(nxt.strip()) 

214 return " ".join(collected) or None 

215 return None 

216 

217 

218def render( 

219 reviews: list[AgentResult], 

220 debate: list[AgentResult], 

221 synthesis: AgentResult | None, 

222 *, 

223 chair: str, 

224 findings: list[Finding] | None = None, 

225 warnings: list[str] | None = None, 

226 groups: list | None = None, 

227 verify: AgentResult | None = None, 

228 context_mode: str | None = None, 

229 redact_secrets: bool | None = None, 

230 redaction_count: int = 0, 

231 metadata: dict | None = None, 

232 classification: dict | None = None, 

233 review_scope: str | None = None, 

234 vote=None, 

235) -> str: 

236 findings = findings or [] 

237 warnings = warnings or [] 

238 groups = groups or [] 

239 lines: list[str] = [] 

240 lines.append("# 🏛️ AI Jury\n") 

241 

242 # TL;DR callout (issue: scannable headline): hoist the verdict to the very 

243 # top so the outcome is the first thing a reader sees, before the panel and 

244 # the full report. Purely additive — omitted when no verdict is available. 

245 headline = _verdict_headline(synthesis, vote) 

246 if headline: 

247 lines.append(f"> ⚡ **TL;DR · {headline}**\n") 

248 

249 # bolt: Explicit list materialization lets join evaluate iteratively in C 

250 panel = ", ".join([f"`{r.agent}` ({r.vendor})" for r in reviews]) 

251 lines.append(f"**Panel:** {panel}\n") 

252 

253 # Review-scope note (issue #9): only rendered when the caller supplies it 

254 # (incremental mode), so the default report is unchanged. 

255 if review_scope: 

256 lines.append(f"{review_scope}\n") 

257 

258 # Compact, deterministic PR-level classification (issue #7). Derived from the 

259 # structured findings/groups when not supplied explicitly so the section 

260 # always renders for a normal run. 

261 if classification is None: 

262 classification = _classification.classify(findings=findings, groups=groups) 

263 lines.extend(_classification_block(classification)) 

264 

265 if context_mode is not None or redact_secrets is not None: 

266 lines.append("## Context policy\n") 

267 if context_mode is not None: 

268 lines.append(f"- context mode: {context_mode}") 

269 if redact_secrets is not None: 

270 state = "on" if redact_secrets else "off" 

271 extra = f" ({redaction_count} redacted)" if redact_secrets else "" 

272 lines.append(f"- secret redaction: {state}{extra}") 

273 lines.append("") 

274 

275 if groups: 

276 lines.extend(_consensus_block(groups)) 

277 lines.append("---\n") 

278 

279 # Panel-vote verdict (issue #220): when voting, the tally is the headline 

280 # verdict and the chair's synthesis becomes supporting reasoning. 

281 if vote is not None: 

282 lines.extend(_vote_block(vote)) 

283 lines.append("---\n") 

284 

285 if verify is not None: 

286 lines.append("## Verification\n") 

287 lines.append(f"> Verified by `{chair}`\n") 

288 if verify.ok: 

289 lines.append(verify.output.strip() + "\n") 

290 else: 

291 lines.append(f"_Verification failed: {flatten_inline(verify.error)}_\n") 

292 lines.append("---\n") 

293 

294 chair_heading = "Chair's reasoning" if vote is not None else "Chair verdict" 

295 if synthesis and synthesis.ok: 

296 lines.append(f"## {chair_heading}\n") 

297 lines.append(f"> Synthesized by `{chair}`\n") 

298 lines.append(synthesis.output.strip() + "\n") 

299 elif synthesis and not synthesis.ok: 

300 lines.append(f"## {chair_heading}\n") 

301 lines.append(f"_Synthesis failed: {flatten_inline(synthesis.error)}_\n") 

302 

303 lines.append("---\n") 

304 lines.append("## Structured findings\n") 

305 if findings: 

306 # ``f.file``/``f.line`` may be None (a finding need not be located), so 

307 # coerce in the sort key — comparing None against str/int raises TypeError. 

308 ranked = sorted( 

309 findings, 

310 key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.file or "", f.line or 0), 

311 ) 

312 for f in ranked: 

313 lines.append(_finding_line(f)) 

314 lines.append("") 

315 else: 

316 lines.append("_(no structured findings parsed)_\n") 

317 

318 if warnings: 

319 lines.append("> ⚠️ agent output warnings\n") 

320 for w in warnings: 

321 lines.append(f"- {w}") 

322 lines.append("") 

323 

324 lines.append("## Round 1 — independent reviews\n") 

325 for r in reviews: 

326 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

327 lines.append(_block(f"`{r.agent}` ({r.vendor}) — {status}", r.output if r.ok else "")) 

328 

329 if debate: 

330 lines.append("## Round 2 — cross-examination\n") 

331 for r in debate: 

332 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

333 lines.append(_block(f"`{r.agent}` — {status}", r.output if r.ok else "")) 

334 

335 if metadata is not None: 

336 lines.append("---\n") 

337 lines.extend(_metadata_block(metadata)) 

338 

339 lines.append("---") 

340 lines.append( 

341 "\n<sub>Generated by " 

342 "[ai-jury](https://github.com/berkayturanci/ai-jury)" 

343 " — a cross-vendor multi-agent PR review jury.</sub>" 

344 ) 

345 return "\n".join(lines) 

346 

347 

348_LIVE_LABELS = { 

349 "review": "Round 1 review", 

350 "debate": "Cross-examination", 

351 "verify": "Verification", 

352 "synthesis": "Decision — verdict & reasoning", 

353} 

354 

355 

356def render_live_step( 

357 kind: str, result: AgentResult, round_no: int | None = None 

358) -> tuple[str, str]: 

359 """Format one streamed step as ``(title, body)`` for live output (issue #210). 

360 

361 Pure — no I/O. The CLI ``--live`` handler prints this to stdout and (with 

362 ``--pr``) posts it as its own comment, as each step completes. ``kind`` is one 

363 of review / debate / verify / synthesis.""" 

364 label = _LIVE_LABELS.get(kind, kind) 

365 if kind == "debate" and round_no: 

366 label = f"Cross-examination · round {round_no}" 

367 if kind in ("verify", "synthesis"): 

368 who = f"chair `{result.agent}`" 

369 else: 

370 who = f"`{result.agent}` ({result.vendor})" 

371 status = f"{result.duration_s:.0f}s" if result.ok else _fail_status(result) 

372 title = f"🏛️ AI Jury — {label}: {who}{status}" 

373 body = result.output.strip() if result.ok else "" 

374 return title, (body or "_(no output)_") 

375 

376 

377def _conversation_blocks( 

378 reviews: list[AgentResult], 

379 debate: list[AgentResult], 

380 synthesis: AgentResult | None, 

381 verify: AgentResult | None, 

382 *, 

383 chair: str, 

384) -> list[str]: 

385 """The chronological deliberation, foregrounded: each reviewer's raw output, 

386 then the debate exchanges in order, then verification, then the chair's 

387 decision *and its reasoning* — so a reader can follow who said what and why 

388 the chair ruled as it did (issue: full transcript).""" 

389 lines: list[str] = ["## Round 1 — independent reviews\n"] 

390 for r in reviews: 

391 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

392 lines.append(_block(f"`{r.agent}` ({r.vendor}) — {status}", r.output if r.ok else "")) 

393 if debate: 

394 lines.append("## Round 2 — cross-examination (debate)\n") 

395 for r in debate: 

396 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

397 lines.append(_block(f"`{r.agent}` — {status}", r.output if r.ok else "")) 

398 if verify is not None: 

399 lines.append("## Verification\n") 

400 lines.append(f"> Verified by `{chair}`\n") 

401 lines.append( 

402 verify.output.strip() + "\n" 

403 if verify.ok 

404 else f"_Verification failed: {flatten_inline(verify.error)}_\n" 

405 ) 

406 lines.append("## Decision — verdict & reasoning\n") 

407 if synthesis and synthesis.ok: 

408 lines.append(f"> Decided by `{chair}`\n") 

409 lines.append(synthesis.output.strip() + "\n") 

410 elif synthesis and not synthesis.ok: 

411 lines.append(f"_Synthesis failed: {flatten_inline(synthesis.error)}_\n") 

412 else: 

413 lines.append("_(no synthesis produced)_\n") 

414 return lines 

415 

416 

417def _summary_blocks( 

418 findings: list[Finding], 

419 warnings: list[str], 

420 groups: list, 

421 classification: dict, 

422 vote=None, 

423) -> list[str]: 

424 """Consensus + structured-findings recap (the auditable at-a-glance summary).""" 

425 lines = list(_classification_block(classification)) 

426 if vote is not None: 

427 lines.extend(_vote_block(vote)) 

428 if groups: 

429 lines.extend(_consensus_block(groups)) 

430 lines.append("## Structured findings\n") 

431 if findings: 

432 ranked = sorted( 

433 findings, 

434 key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.file or "", f.line or 0), 

435 ) 

436 # bolt: CPython optimization — list comprehension instead of generator expressions in extend() 

437 lines.extend([_finding_line(f) for f in ranked]) 

438 lines.append("") 

439 else: 

440 lines.append("_(no structured findings parsed)_\n") 

441 if warnings: 

442 lines.append("> ⚠️ agent output warnings\n") 

443 lines.extend([f"- {w}" for w in warnings]) 

444 lines.append("") 

445 return lines 

446 

447 

448def render_transcript( 

449 reviews: list[AgentResult], 

450 debate: list[AgentResult], 

451 synthesis: AgentResult | None, 

452 *, 

453 chair: str, 

454 findings: list[Finding] | None = None, 

455 warnings: list[str] | None = None, 

456 groups: list | None = None, 

457 verify: AgentResult | None = None, 

458 context_mode: str | None = None, 

459 redact_secrets: bool | None = None, 

460 redaction_count: int = 0, 

461 metadata: dict | None = None, 

462 classification: dict | None = None, 

463 review_scope: str | None = None, 

464 lead_with_summary: bool = False, 

465 vote=None, 

466) -> str: 

467 """Render the full play-by-play transcript (issue: full transcript / --verbose). 

468 

469 Two layouts from one function: 

470 

471 * ``lead_with_summary=False`` (``--transcript``) — a dedicated, conversation-first 

472 document: Round 1 → debate → verification → the chair's decision & reasoning, 

473 then a compact consensus/findings recap for auditability. 

474 * ``lead_with_summary=True`` (``--verbose``) — the consensus/verdict summary first, 

475 then the same full transcript below it, in one document. 

476 

477 The default :func:`render` (consensus-first summary with a raw appendix) is 

478 unchanged, so existing reports/goldens are unaffected. 

479 """ 

480 findings = findings or [] 

481 warnings = warnings or [] 

482 groups = groups or [] 

483 if classification is None: 

484 classification = _classification.classify(findings=findings, groups=groups) 

485 

486 lines: list[str] = [] 

487 lines.append( 

488 "# 🏛️ AI Jury — verbose report\n" if lead_with_summary else "# 🏛️ AI Jury — full transcript\n" 

489 ) 

490 # TL;DR callout (parity with render()): the verdict headline leads the 

491 # verbose/transcript report too, so every renderer surfaces the outcome first. 

492 headline = _verdict_headline(synthesis, vote) 

493 if headline: 

494 lines.append(f"> ⚡ **TL;DR · {headline}**\n") 

495 # bolt: explicit list enables optimized string join bypassing generator loop overhead 

496 panel = ", ".join([f"`{r.agent}` ({r.vendor})" for r in reviews]) 

497 lines.append(f"**Panel:** {panel}\n") 

498 if review_scope: 

499 lines.append(f"{review_scope}\n") 

500 

501 # Disclose the context/redaction policy (parity with render()): whoever reads 

502 # the shared transcript should see whether secrets were redacted before the 

503 # diff reached the agents. 

504 if context_mode is not None or redact_secrets is not None: 

505 lines.append("## Context policy\n") 

506 if context_mode is not None: 

507 lines.append(f"- context mode: {context_mode}") 

508 if redact_secrets is not None: 

509 state = "on" if redact_secrets else "off" 

510 extra = f" ({redaction_count} redacted)" if redact_secrets else "" 

511 lines.append(f"- secret redaction: {state}{extra}") 

512 lines.append("") 

513 

514 if lead_with_summary: 

515 lines.extend(_summary_blocks(findings, warnings, groups, classification, vote=vote)) 

516 lines.append("---\n") 

517 lines.append("# Full transcript\n") 

518 lines.extend(_conversation_blocks(reviews, debate, synthesis, verify, chair=chair)) 

519 else: 

520 lines.extend(_conversation_blocks(reviews, debate, synthesis, verify, chair=chair)) 

521 lines.append("---\n") 

522 lines.extend(_summary_blocks(findings, warnings, groups, classification, vote=vote)) 

523 

524 if metadata is not None: 

525 lines.append("---\n") 

526 lines.extend(_metadata_block(metadata)) 

527 

528 lines.append("---") 

529 lines.append( 

530 "\n<sub>Generated by " 

531 "[ai-jury](https://github.com/berkayturanci/ai-jury)" 

532 " — a cross-vendor multi-agent PR review jury.</sub>" 

533 ) 

534 return "\n".join(lines) 

535 

536 

537def render_sections( 

538 reviews: list[AgentResult], 

539 debate: list[AgentResult], 

540 synthesis: AgentResult | None, 

541 *, 

542 chair: str, 

543 findings: list[Finding] | None = None, 

544 warnings: list[str] | None = None, 

545 groups: list | None = None, 

546 verify: AgentResult | None = None, 

547 classification: dict | None = None, 

548 vote=None, 

549) -> list[tuple[str, str]]: 

550 """Split the report into ordered ``(title, body)`` sections for phased posting. 

551 

552 Returns up to three sections — **Round 1** (independent reviews), **Round 2** 

553 (debate, omitted when there was none), and **Decision** (verification + chair 

554 verdict + consensus + structured findings) — so a PR can show the flow as 

555 separate, readable comments (issue #127). ``render()`` (the single-blob 

556 report) is unchanged. Empty sections are skipped. 

557 """ 

558 findings = findings or [] 

559 warnings = warnings or [] 

560 groups = groups or [] 

561 sections: list[tuple[str, str]] = [] 

562 

563 # Round 1 — independent reviews. 

564 # bolt: Explicitly evaluating as a list allows C-level optimizations in join 

565 r1 = [f"**Panel:** {', '.join([f'`{r.agent}` ({r.vendor})' for r in reviews])}\n"] 

566 for r in reviews: 

567 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

568 r1.append(_block(f"`{r.agent}` ({r.vendor}) — {status}", r.output if r.ok else "")) 

569 sections.append(("🏛️ AI Jury — Round 1: independent reviews", "\n".join(r1).strip())) 

570 

571 # Round 2 — cross-examination (only if a debate ran). 

572 if debate: 

573 r2 = [] 

574 for r in debate: 

575 status = f"{r.duration_s:.0f}s" if r.ok else _fail_status(r) 

576 r2.append(_block(f"`{r.agent}` — {status}", r.output if r.ok else "")) 

577 sections.append(("🏛️ AI Jury — Round 2: cross-examination (debate)", "\n".join(r2).strip())) 

578 

579 # Decision — verification + chair verdict + consensus + findings. 

580 dec: list[str] = [] 

581 if classification is None: 

582 classification = _classification.classify(findings=findings, groups=groups) 

583 dec.extend(_classification_block(classification)) 

584 if vote is not None: 

585 dec.extend(_vote_block(vote)) 

586 if groups: 

587 dec.extend(_consensus_block(groups)) 

588 if verify is not None: 

589 dec.append("## Verification\n") 

590 dec.append(f"> Verified by `{chair}`\n") 

591 dec.append( 

592 verify.output.strip() + "\n" 

593 if verify.ok 

594 else f"_Verification failed: {flatten_inline(verify.error)}_\n" 

595 ) 

596 chair_heading = "Chair's reasoning" if vote is not None else "Chair verdict" 

597 if synthesis and synthesis.ok: 

598 dec.append(f"## {chair_heading}\n") 

599 dec.append(f"> Synthesized by `{chair}`\n") 

600 dec.append(synthesis.output.strip() + "\n") 

601 elif synthesis and not synthesis.ok: 

602 dec.append(f"## {chair_heading}\n\n_Synthesis failed: {flatten_inline(synthesis.error)}_\n") 

603 if findings: 

604 dec.append("## Structured findings\n") 

605 ranked = sorted( 

606 findings, 

607 key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.file or "", f.line or 0), 

608 ) 

609 # bolt: Optimizes speed by allowing Python C implementations of extend() 

610 dec.extend([_finding_line(f) for f in ranked]) 

611 if warnings: 

612 dec.append("\n> ⚠️ agent output warnings\n") 

613 dec.extend([f"- {w}" for w in warnings]) 

614 sections.append(("🏛️ AI Jury — Decision: verdict & consensus", "\n".join(dec).strip())) 

615 

616 return sections