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
« 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."""
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 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
141def _classification_block(classification: dict) -> list[str]:
142 """Render the compact PR-level classification summary.
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 ]
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
171def _vote_block(vote) -> list[str]:
172 """Render the panel-vote verdict + tally + per-reviewer ballots (issue #220).
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
187def _verdict_headline(synthesis, vote) -> str | None:
188 """One-line verdict for the report's TL;DR callout (pure, deterministic).
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
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")
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")
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")
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")
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))
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("")
275 if groups:
276 lines.extend(_consensus_block(groups))
277 lines.append("---\n")
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")
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")
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")
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")
318 if warnings:
319 lines.append("> ⚠️ agent output warnings\n")
320 for w in warnings:
321 lines.append(f"- {w}")
322 lines.append("")
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 ""))
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 ""))
335 if metadata is not None:
336 lines.append("---\n")
337 lines.extend(_metadata_block(metadata))
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)
348_LIVE_LABELS = {
349 "review": "Round 1 review",
350 "debate": "Cross-examination",
351 "verify": "Verification",
352 "synthesis": "Decision — verdict & reasoning",
353}
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).
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)_")
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
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
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).
469 Two layouts from one function:
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.
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)
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")
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("")
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))
524 if metadata is not None:
525 lines.append("---\n")
526 lines.extend(_metadata_block(metadata))
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)
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.
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]] = []
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()))
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()))
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()))
616 return sections