mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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).
443 lines
15 KiB
Python
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},
|
|
]
|