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

333 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 21:31 +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 # A short panel is stated, not left to be inferred from the agent table (#501). 

118 # Silence is the failure mode: a run with two non-reviewing slots described 

119 # itself as a full panel, and only the chair's prose said otherwise. 

120 panel = metadata.get("panel") or {} 

121 if panel.get("short"): 

122 lines.append( 

123 f"- ⚠️ **effective panel: {panel.get('effective', 0)} of " 

124 f"{panel.get('configured', 0)} reviewer(s)** " 

125 f"({panel.get('abstained', 0)} returned no review, " 

126 f"{panel.get('failed', 0)} failed) — " 

127 f"{panel.get('vendors', 0)} vendor(s) contributed. An abstention is not " 

128 "an approval; treat cross-vendor consensus accordingly." 

129 ) 

130 total = metadata["total_wall_clock_s"] 

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

132 lines.append("") 

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

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

135 for a in metadata["agents"]: 

136 code = a.get("error_code") 

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

138 # "ok" alone hid a slot that returned nothing reviewable (#501). 

139 review = a.get("review_status") 

140 if review and review != "findings": 

141 status = f"{status}, {review}" 

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

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

144 if attempts and attempts > 1: 

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

146 lines.append( 

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

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

149 ) 

150 lines.append("") 

151 economics = metadata.get("economics") 

152 if economics and economics.get("breakdown"): 

153 lines.append("### 💰 Run Economics (estimated)\n") 

154 lines.append( 

155 f"- total tokens (est): ~{economics['total_tokens_est']:,} · " 

156 f"cost (est): ~${economics['total_cost_usd_est']:.4f} USD" 

157 ) 

158 if economics.get("local_free_slots"): 

159 lines.append( 

160 f"- ⚡ {economics['local_free_slots']} slot(s) powered by local models ($0.00 free offline)" 

161 ) 

162 lines.append("") 

163 lines.append( 

164 "_Wall-clock seconds and token counts are approximate cost proxies (no direct billing " 

165 "telemetry is extracted from CLIs), not guaranteed dollar costs._\n" 

166 ) 

167 return lines 

168 

169 

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

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

172 

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

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

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

176 """ 

177 return [ 

178 "## Classification\n", 

179 _classification.summary_line(classification), 

180 "", 

181 ] 

182 

183 

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

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

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

187 for g in groups: 

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

189 for bucket in _BUCKET_ORDER: 

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

191 if not bg: 

192 continue 

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

194 for g in bg: 

195 lines.append(_group_line(g)) 

196 lines.append("") 

197 return lines 

198 

199 

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

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

202 

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

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

205 """ 

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

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

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

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

210 for b in vote.ballots: 

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

212 lines.append("") 

213 return lines 

214 

215 

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

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

218 

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

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

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

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

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

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

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

226 """ 

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

228 return vote.verdict 

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

230 return None 

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

232 for i, row in enumerate(rows): 

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

234 collected: list[str] = [] 

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

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

237 break 

238 if not nxt.strip(): 

239 if collected: 

240 break 

241 continue 

242 collected.append(nxt.strip()) 

243 return " ".join(collected) or None 

244 return None 

245 

246 

247def render( 

248 reviews: list[AgentResult], 

249 debate: list[AgentResult], 

250 synthesis: AgentResult | None, 

251 *, 

252 chair: str, 

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

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

255 groups: list | None = None, 

256 verify: AgentResult | None = None, 

257 context_mode: str | None = None, 

258 redact_secrets: bool | None = None, 

259 redaction_count: int = 0, 

260 metadata: dict | None = None, 

261 classification: dict | None = None, 

262 review_scope: str | None = None, 

263 vote=None, 

264) -> str: 

265 findings = findings or [] 

266 warnings = warnings or [] 

267 groups = groups or [] 

268 lines: list[str] = [] 

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

270 

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

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

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

274 headline = _verdict_headline(synthesis, vote) 

275 if headline: 

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

277 

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

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

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

281 

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

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

284 if review_scope: 

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

286 

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

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

289 # always renders for a normal run. 

290 if classification is None: 

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

292 lines.extend(_classification_block(classification)) 

293 

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

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

296 if context_mode is not None: 

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

298 if redact_secrets is not None: 

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

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

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

302 lines.append("") 

303 

304 if groups: 

305 lines.extend(_consensus_block(groups)) 

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

307 

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

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

310 if vote is not None: 

311 lines.extend(_vote_block(vote)) 

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

313 

314 if verify is not None: 

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

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

317 if verify.ok: 

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

319 else: 

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

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

322 

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

324 if synthesis and synthesis.ok: 

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

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

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

328 elif synthesis and not synthesis.ok: 

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

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

331 

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

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

334 if findings: 

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

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

337 ranked = sorted( 

338 findings, 

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

340 ) 

341 for f in ranked: 

342 lines.append(_finding_line(f)) 

343 lines.append("") 

344 else: 

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

346 

347 if warnings: 

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

349 for w in warnings: 

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

351 lines.append("") 

352 

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

354 for r in reviews: 

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

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

357 

358 if debate: 

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

360 for r in debate: 

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

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

363 

364 if metadata is not None: 

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

366 lines.extend(_metadata_block(metadata)) 

367 

368 lines.append("---") 

369 lines.append( 

370 "\n<sub>🏛️ Synthesized by " 

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

372 " — Cross-vendor multi-agent code review · " 

373 "[⭐ Star on GitHub](https://github.com/berkayturanci/ai-jury) · " 

374 "[Add to your repo](https://ai-jury.dev/)</sub>" 

375 ) 

376 return "\n".join(lines) 

377 

378 

379_LIVE_LABELS = { 

380 "review": "Round 1 review", 

381 "debate": "Cross-examination", 

382 "verify": "Verification", 

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

384} 

385 

386 

387def render_live_step( 

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

389) -> tuple[str, str]: 

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

391 

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

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

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

395 label = _LIVE_LABELS.get(kind, kind) 

396 if kind == "debate" and round_no: 

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

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

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

400 else: 

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

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

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

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

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

406 

407 

408def _conversation_blocks( 

409 reviews: list[AgentResult], 

410 debate: list[AgentResult], 

411 synthesis: AgentResult | None, 

412 verify: AgentResult | None, 

413 *, 

414 chair: str, 

415) -> list[str]: 

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

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

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

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

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

421 for r in reviews: 

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

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

424 if debate: 

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

426 for r in debate: 

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

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

429 if verify is not None: 

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

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

432 lines.append( 

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

434 if verify.ok 

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

436 ) 

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

438 if synthesis and synthesis.ok: 

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

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

441 elif synthesis and not synthesis.ok: 

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

443 else: 

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

445 return lines 

446 

447 

448def _summary_blocks( 

449 findings: list[Finding], 

450 warnings: list[str], 

451 groups: list, 

452 classification: dict, 

453 vote=None, 

454) -> list[str]: 

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

456 lines = list(_classification_block(classification)) 

457 if vote is not None: 

458 lines.extend(_vote_block(vote)) 

459 if groups: 

460 lines.extend(_consensus_block(groups)) 

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

462 if findings: 

463 ranked = sorted( 

464 findings, 

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

466 ) 

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

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

469 lines.append("") 

470 else: 

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

472 if warnings: 

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

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

475 lines.append("") 

476 return lines 

477 

478 

479def render_transcript( 

480 reviews: list[AgentResult], 

481 debate: list[AgentResult], 

482 synthesis: AgentResult | None, 

483 *, 

484 chair: str, 

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

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

487 groups: list | None = None, 

488 verify: AgentResult | None = None, 

489 context_mode: str | None = None, 

490 redact_secrets: bool | None = None, 

491 redaction_count: int = 0, 

492 metadata: dict | None = None, 

493 classification: dict | None = None, 

494 review_scope: str | None = None, 

495 lead_with_summary: bool = False, 

496 vote=None, 

497) -> str: 

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

499 

500 Two layouts from one function: 

501 

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

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

504 then a compact consensus/findings recap for auditability. 

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

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

507 

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

509 unchanged, so existing reports/goldens are unaffected. 

510 """ 

511 findings = findings or [] 

512 warnings = warnings or [] 

513 groups = groups or [] 

514 if classification is None: 

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

516 

517 lines: list[str] = [] 

518 lines.append( 

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

520 ) 

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

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

523 headline = _verdict_headline(synthesis, vote) 

524 if headline: 

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

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

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

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

529 if review_scope: 

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

531 

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

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

534 # diff reached the agents. 

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

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

537 if context_mode is not None: 

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

539 if redact_secrets is not None: 

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

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

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

543 lines.append("") 

544 

545 if lead_with_summary: 

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

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

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

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

550 else: 

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

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

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

554 

555 if metadata is not None: 

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

557 lines.extend(_metadata_block(metadata)) 

558 

559 lines.append("---") 

560 lines.append( 

561 "\n<sub>Generated by " 

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

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

564 ) 

565 return "\n".join(lines) 

566 

567 

568def render_sections( 

569 reviews: list[AgentResult], 

570 debate: list[AgentResult], 

571 synthesis: AgentResult | None, 

572 *, 

573 chair: str, 

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

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

576 groups: list | None = None, 

577 verify: AgentResult | None = None, 

578 classification: dict | None = None, 

579 vote=None, 

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

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

582 

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

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

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

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

587 report) is unchanged. Empty sections are skipped. 

588 """ 

589 findings = findings or [] 

590 warnings = warnings or [] 

591 groups = groups or [] 

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

593 

594 # Round 1 — independent reviews. 

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

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

597 for r in reviews: 

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

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

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

601 

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

603 if debate: 

604 r2 = [] 

605 for r in debate: 

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

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

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

609 

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

611 dec: list[str] = [] 

612 if classification is None: 

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

614 dec.extend(_classification_block(classification)) 

615 if vote is not None: 

616 dec.extend(_vote_block(vote)) 

617 if groups: 

618 dec.extend(_consensus_block(groups)) 

619 if verify is not None: 

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

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

622 dec.append( 

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

624 if verify.ok 

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

626 ) 

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

628 if synthesis and synthesis.ok: 

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

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

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

632 elif synthesis and not synthesis.ok: 

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

634 if findings: 

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

636 ranked = sorted( 

637 findings, 

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

639 ) 

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

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

642 if warnings: 

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

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

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

646 

647 return sections