open-swe/evals/reviewer/judge.py
Adam Moussa 62d9945df4
refactor: consolidate reviewer modules into agent/review/
Part of the domain-reorg adoption (build plan step C2): fork content,
upstream layout. Nine 1:1 module moves (reviewer_diff/eval_store/
findings/groups/publish/reconcile/trace_context + review_style_
collector/guidance) into agent/review/, with internal relative
imports re-wired to the new package depth. agent/review/__init__.py
mirrors upstream's thin re-export shim (one of the 21 verified "A"
structural adds).

Rewrote the 38 grep hits across importer files (agent/{analyzer,
ci_autofix,reviewer,webapp}.py, agent/dashboard/*, agent/middleware/
settle_review_check.py, agent/tools/*, agent/utils/github_feedback.py,
agent/webhooks/github.py, evals/reviewer/*, and the reviewer test
suite) to point at agent.review.*; 4 of the 38 hits were name
collisions (list_reviewer_findings, reviewer_outcomes,
_reviewer_thread_id, reviewer_thread_id — not the moved modules) and
were left untouched. tests/test_github_checks.py's module-alias
import (`from agent import reviewer_publish`) follows upstream's own
`from agent.review import publish as reviewer_publish` pattern so
downstream `reviewer_publish.*` call sites needed no changes.
agent/reviewer.py and agent/webapp.py stay in place per the hard
rule (fork content, import-only rewire) and are not part of this
package.

Gates: ruff check + ruff format --check, pytest --co -q (1637
collected), full unit suite (1637 passed), and the reviewer/findings
suite in isolation (pytest -k "review or finding", 421 passed).
2026-07-17 13:52:03 -04:00

443 lines
15 KiB
Python

"""LLM-judge evaluator for the reviewer eval.
Pairwise matches each agent-emitted candidate against each golden comment using
claude-opus-4-5 (the judge model used by the martian benchmark). Returns
precision/recall/f1 per example, plus aggregate micro/macro metrics across
the experiment via a summary evaluator.
The judge prompt is kept verbatim from
withmartian/code-review-benchmark `step3_judge_comments.py` so scores are
directly comparable to martian's published numbers.
"""
from __future__ import annotations
import json
import os
import threading
from functools import cache
from typing import Any, NotRequired, TypedDict
from uuid import UUID
from langchain_anthropic import ChatAnthropic
from langsmith.schemas import Example, Run
from agent.review.findings import REVIEW_FINDING_CAP
JUDGE_MODEL = "claude-opus-4-5"
# Call Anthropic directly. Without an explicit base_url the Anthropic SDK falls
# back to ANTHROPIC_BASE_URL, which in dev shells points at the LangSmith
# gateway and 403s for this model — silently nulling every judge score.
JUDGE_BASE_URL = os.environ.get("JUDGE_ANTHROPIC_BASE_URL", "https://api.anthropic.com")
JUDGE_SYSTEM = "You are a precise code review evaluator. Always respond with valid JSON."
JUDGE_PROMPT = """You are evaluating AI code review tools.
Determine if the candidate issue matches the golden (expected) comment.
Golden Comment (the issue we're looking for):
{golden_comment}
Candidate Issue (from the tool's review):
{candidate}
Instructions:
- Determine if the candidate identifies the SAME underlying issue as the golden comment
- Accept semantic matches - different wording is fine if it's the same problem
- Focus on whether they point to the same bug, concern, or code issue
Respond with ONLY a JSON object:
{{"reasoning": "brief explanation", "match": true/false, "confidence": 0.0-1.0}}"""
_judge: ChatAnthropic | None = None
class ReviewComment(TypedDict):
comment: NotRequired[str]
body: NotRequired[str]
file: NotRequired[str]
line: NotRequired[int | None]
severity: NotRequired[str]
class PairResult(TypedDict):
match: bool
confidence: float
reasoning: str
class MatrixCell(PairResult):
candidate_index: int
golden_index: int
class ExampleCounts(TypedDict):
tp: int
fp: int
fn: int
precision: float
recall: float
f1: float
medium_plus_tp: int
medium_plus_fp: int
medium_plus_fn: int
medium_plus_precision: float
medium_plus_recall: float
medium_plus_f1: float
is_synthetic: bool
def _get_judge() -> ChatAnthropic:
global _judge
if _judge is None:
api_key = os.environ.get("JUDGE_ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError(
"No Anthropic API key for the judge. Set JUDGE_ANTHROPIC_API_KEY or "
"ANTHROPIC_API_KEY (the judge calls Anthropic directly, not via a gateway)."
)
_judge = ChatAnthropic(
model=JUDGE_MODEL,
temperature=0.0,
max_tokens=512,
base_url=JUDGE_BASE_URL,
api_key=api_key,
max_retries=3,
)
return _judge
def _format_candidate(c: ReviewComment) -> str:
parts = []
if c.get("file"):
loc = c["file"]
if c.get("line") is not None:
loc += f":{c['line']}"
parts.append(f"Location: {loc}")
if c.get("severity"):
parts.append(f"Severity: {c['severity']}")
parts.append(f"Comment: {c.get('body') or c.get('comment') or ''}")
return "\n".join(parts)
def _format_golden(g: ReviewComment) -> str:
parts = []
if g.get("severity"):
parts.append(f"Severity: {g['severity']}")
parts.append(f"Comment: {g.get('comment', '')}")
return "\n".join(parts)
def _judge_pair(golden: ReviewComment, candidate: ReviewComment) -> PairResult:
prompt = JUDGE_PROMPT.format(
golden_comment=_format_golden(golden),
candidate=_format_candidate(candidate),
)
msg = _get_judge().invoke(
[{"role": "system", "content": JUDGE_SYSTEM}, {"role": "user", "content": prompt}]
)
raw = msg.content if isinstance(msg.content, str) else str(msg.content)
try:
start, end = raw.find("{"), raw.rfind("}")
parsed = json.loads(raw[start : end + 1])
except (ValueError, json.JSONDecodeError):
return {"match": False, "confidence": 0.0, "reasoning": f"unparseable: {raw[:200]}"}
if not isinstance(parsed, dict):
return {"match": False, "confidence": 0.0, "reasoning": "judge returned non-object"}
match = parsed.get("match")
confidence = parsed.get("confidence")
reasoning = parsed.get("reasoning")
return {
"match": match if isinstance(match, bool) else False,
"confidence": (
min(max(float(confidence), 0.0), 1.0)
if isinstance(confidence, (int, float)) and not isinstance(confidence, bool)
else 0.0
),
"reasoning": reasoning if isinstance(reasoning, str) else "",
}
_PER_EXAMPLE_COUNTS: dict[UUID, ExampleCounts] = {}
_COUNTS_LOCK = threading.Lock()
def _record_counts(example_id: UUID, counts: ExampleCounts) -> None:
with _COUNTS_LOCK:
_PER_EXAMPLE_COUNTS[example_id] = counts
def _drain_counts() -> list[ExampleCounts]:
with _COUNTS_LOCK:
snapshot = list(_PER_EXAMPLE_COUNTS.values())
_PER_EXAMPLE_COUNTS.clear()
return snapshot
def _coerce_comments(value: object) -> list[ReviewComment]:
if not isinstance(value, list):
return []
comments: list[ReviewComment] = []
for item in value:
if not isinstance(item, dict):
continue
comment: ReviewComment = {}
comment_text = item.get("comment")
if isinstance(comment_text, str):
comment["comment"] = comment_text
body = item.get("body")
if isinstance(body, str):
comment["body"] = body
file = item.get("file")
if isinstance(file, str):
comment["file"] = file
severity = item.get("severity")
if isinstance(severity, str):
comment["severity"] = severity
line = item.get("line")
if isinstance(line, int) or line is None:
comment["line"] = line
comments.append(comment)
return comments
def _dedupe_candidates(candidates: list[ReviewComment]) -> tuple[list[ReviewComment], int]:
unique: list[ReviewComment] = []
seen: set[tuple[str, int | None, str]] = set()
for candidate in candidates:
key = (
candidate.get("file", ""),
candidate.get("line"),
" ".join((candidate.get("body") or candidate.get("comment") or "").casefold().split()),
)
if key in seen:
continue
seen.add(key)
unique.append(candidate)
return unique, len(candidates) - len(unique)
def _build_matrix(
candidates: list[ReviewComment], goldens: list[ReviewComment]
) -> list[list[PairResult]]:
return [[_judge_pair(golden, candidate) for golden in goldens] for candidate in candidates]
def _select_pairs(matrix: list[list[PairResult]]) -> tuple[tuple[int, int], ...]:
golden_count = len(matrix[0]) if matrix else 0
@cache
def _solve(
candidate_index: int, matched_mask: int
) -> tuple[int, float, tuple[tuple[int, int], ...]]:
if candidate_index >= len(matrix):
return 0, 0.0, ()
best = _solve(candidate_index + 1, matched_mask)
for golden_index in range(golden_count):
if matched_mask & (1 << golden_index):
continue
cell = matrix[candidate_index][golden_index]
if not cell["match"]:
continue
count, confidence, pairs = _solve(
candidate_index + 1, matched_mask | (1 << golden_index)
)
candidate = (
count + 1,
confidence + cell["confidence"],
((candidate_index, golden_index), *pairs),
)
if candidate[:2] > best[:2]:
best = candidate
return best
return _solve(0, 0)[2]
def _metrics(
tp: int, candidate_count: int, golden_count: int
) -> tuple[int, int, float, float, float]:
fp = max(0, candidate_count - tp)
fn = max(0, golden_count - tp)
precision = tp / candidate_count if candidate_count else 0.0
recall = tp / golden_count if golden_count else 0.0
return fp, fn, precision, recall, _f1(precision, recall)
def _is_medium_plus(comment: ReviewComment) -> bool:
return comment.get("severity", "").casefold() in {"medium", "high", "critical"}
def _recall_at_cap(tp: int, golden_count: int, cap: int) -> tuple[float, float]:
if golden_count == 0:
return 0.0, 0.0
reachable_goldens = min(cap, golden_count)
recall_at_cap = min(tp, reachable_goldens) / reachable_goldens if reachable_goldens else 0.0
return recall_at_cap, reachable_goldens / golden_count
def judge_match(run: Run, example: Example) -> dict[str, Any]:
"""Judge every pair, then choose the strongest maximum-cardinality matching."""
raw_candidates = _coerce_comments((run.outputs or {}).get("comments"))
candidates, duplicate_count = _dedupe_candidates(raw_candidates)
goldens = _coerce_comments((example.outputs or {}).get("golden_comments"))
if not goldens:
return {"results": [{"key": "f1", "score": None, "comment": "no goldens"}]}
matrix = _build_matrix(candidates, goldens)
selected_pairs = _select_pairs(matrix)
tp = len(selected_pairs)
fp, fn, precision, recall, f1 = _metrics(tp, len(candidates), len(goldens))
medium_candidate_indices = [
i for i, candidate in enumerate(candidates) if _is_medium_plus(candidate)
]
medium_golden_indices = [i for i, golden in enumerate(goldens) if _is_medium_plus(golden)]
medium_matrix = [
[matrix[candidate_index][golden_index] for golden_index in medium_golden_indices]
for candidate_index in medium_candidate_indices
]
medium_tp = len(_select_pairs(medium_matrix))
medium_fp, medium_fn, medium_precision, medium_recall, medium_f1 = _metrics(
medium_tp, len(medium_candidate_indices), len(medium_golden_indices)
)
repo = (example.inputs or {}).get("repo")
is_synthetic = isinstance(repo, str) and repo.startswith("ai-code-review-evaluation/")
_record_counts(
example.id,
{
"tp": tp,
"fp": fp,
"fn": fn,
"precision": precision,
"recall": recall,
"f1": f1,
"medium_plus_tp": medium_tp,
"medium_plus_fp": medium_fp,
"medium_plus_fn": medium_fn,
"medium_plus_precision": medium_precision,
"medium_plus_recall": medium_recall,
"medium_plus_f1": medium_f1,
"is_synthetic": is_synthetic,
},
)
selected = set(selected_pairs)
cells: list[MatrixCell] = []
for candidate_index, row in enumerate(matrix):
for golden_index, result in enumerate(row):
cells.append(
{
"candidate_index": candidate_index,
"golden_index": golden_index,
**result,
}
)
matrix_feedback = {
"candidates": candidates,
"goldens": goldens,
"cells": cells,
"selected_pairs": [
{"candidate_index": candidate_index, "golden_index": golden_index}
for candidate_index, golden_index in selected_pairs
],
"unmatched_candidates": [
{"candidate_index": index, "candidate": candidate}
for index, candidate in enumerate(candidates)
if not any(pair[0] == index for pair in selected)
],
}
recall_at_cap, recall_ceiling_at_cap = _recall_at_cap(tp, len(goldens), REVIEW_FINDING_CAP)
return {
"results": [
{"key": "f1", "score": f1},
{"key": "precision", "score": precision},
{"key": "recall", "score": recall},
{"key": "tp", "score": tp},
{"key": "fp", "score": fp},
{"key": "fn", "score": fn},
{"key": "n_candidates", "score": len(candidates)},
{"key": "n_candidates_raw", "score": len(raw_candidates)},
{"key": "n_duplicates", "score": duplicate_count},
{"key": "n_goldens", "score": len(goldens)},
{"key": "recall_at_cap", "score": recall_at_cap},
{"key": "recall_ceiling_at_cap", "score": recall_ceiling_at_cap},
{"key": "medium_plus_f1", "score": medium_f1},
{"key": "medium_plus_precision", "score": medium_precision},
{"key": "medium_plus_recall", "score": medium_recall},
{"key": "pairwise_match_matrix", "value": json.dumps(matrix_feedback)},
]
}
def _f1(p: float, r: float) -> float:
return 2 * p * r / (p + r) if (p + r) else 0.0
def aggregate_pr(runs: list[Run], examples: list[Example]) -> dict[str, Any]:
"""Summary evaluator: micro/macro precision-recall-F1 across the experiment.
Reads the per-example counts that ``judge_match`` stashed in the
process-local cache. Falls back to an empty result set if the cache
is empty (e.g. summary evaluator ran in a different process).
"""
counts = _drain_counts()
if not counts:
return {"results": []}
results = _aggregate_metrics(counts)
results.extend(
_aggregate_metrics(counts, key_prefix="medium_plus_", field_prefix="medium_plus_")
)
synthetic = [count for count in counts if count["is_synthetic"]]
upstream = [count for count in counts if not count["is_synthetic"]]
if synthetic:
results.extend(_aggregate_metrics(synthetic, key_prefix="synthetic_"))
if upstream:
results.extend(_aggregate_metrics(upstream, key_prefix="upstream_"))
return {"results": results}
def _aggregate_metrics(
counts: list[ExampleCounts],
*,
key_prefix: str = "",
field_prefix: str = "",
) -> list[dict[str, Any]]:
tp_key = f"{field_prefix}tp"
fp_key = f"{field_prefix}fp"
fn_key = f"{field_prefix}fn"
precision_key = f"{field_prefix}precision"
recall_key = f"{field_prefix}recall"
f1_key = f"{field_prefix}f1"
micro_tp = sum(int(count[tp_key]) for count in counts)
micro_fp = sum(int(count[fp_key]) for count in counts)
micro_fn = sum(int(count[fn_key]) for count in counts)
micro_precision = micro_tp / (micro_tp + micro_fp) if micro_tp + micro_fp else 0.0
micro_recall = micro_tp / (micro_tp + micro_fn) if micro_tp + micro_fn else 0.0
count = len(counts)
return [
{"key": f"{key_prefix}micro_precision", "score": micro_precision},
{"key": f"{key_prefix}micro_recall", "score": micro_recall},
{"key": f"{key_prefix}micro_f1", "score": _f1(micro_precision, micro_recall)},
{
"key": f"{key_prefix}macro_precision",
"score": sum(float(item[precision_key]) for item in counts) / count,
},
{
"key": f"{key_prefix}macro_recall",
"score": sum(float(item[recall_key]) for item in counts) / count,
},
{
"key": f"{key_prefix}macro_f1",
"score": sum(float(item[f1_key]) for item in counts) / count,
},
{"key": f"{key_prefix}total_tp", "score": micro_tp},
{"key": f"{key_prefix}total_fp", "score": micro_fp},
{"key": f"{key_prefix}total_fn", "score": micro_fn},
]