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
« 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."""
3from __future__ import annotations
5from . import classification as _classification
6from .adapters import AgentResult
7from .findings import SEVERITY_ORDER, Finding, flatten_inline
10def _block(title: str, body: str) -> str:
11 return f"### {title}\n\n{body.strip() or '_(no output)_'}\n"
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)}"
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})"
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"]
40_STATUS_LABELS = {
41 "verified": "verified",
42 "unsupported": "unsupported",
43 "needs_human_decision": "needs human decision",
44}
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)"
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})"]
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)}")
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)}")
74 if f.suggested_fix:
75 parts.append(f"\n - _fix:_ {flatten_inline(f.suggested_fix)}")
77 return "".join(parts) if len(parts) > 1 else parts[0]
80def _metadata_block(metadata: dict) -> list[str]:
81 """Render the deterministic run-metadata section.
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
170def _classification_block(classification: dict) -> list[str]:
171 """Render the compact PR-level classification summary.
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 ]
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
200def _vote_block(vote) -> list[str]:
201 """Render the panel-vote verdict + tally + per-reviewer ballots (issue #220).
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
216def _verdict_headline(synthesis, vote) -> str | None:
217 """One-line verdict for the report's TL;DR callout (pure, deterministic).
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
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")
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")
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")
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")
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))
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("")
304 if groups:
305 lines.extend(_consensus_block(groups))
306 lines.append("---\n")
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")
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")
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")
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")
347 if warnings:
348 lines.append("> ⚠️ agent output warnings\n")
349 for w in warnings:
350 lines.append(f"- {w}")
351 lines.append("")
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 ""))
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 ""))
364 if metadata is not None:
365 lines.append("---\n")
366 lines.extend(_metadata_block(metadata))
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)
379_LIVE_LABELS = {
380 "review": "Round 1 review",
381 "debate": "Cross-examination",
382 "verify": "Verification",
383 "synthesis": "Decision — verdict & reasoning",
384}
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).
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)_")
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
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
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).
500 Two layouts from one function:
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.
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)
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")
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("")
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))
555 if metadata is not None:
556 lines.append("---\n")
557 lines.extend(_metadata_block(metadata))
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)
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.
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]] = []
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()))
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()))
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()))
647 return sections