mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 20:53:15 +00:00
* feat: add reviewer graph + eval target wiring
- New `reviewer` graph (`agent/reviewer.py`) registered in langgraph.json
alongside the main `agent` graph. Reuses the same sandbox lifecycle,
GH proxy auth, and middleware primitives from `agent.server`, but with
a narrower tool set, a reviewer-specific system prompt, no
commit/push, and the `task` (subagent) tool stripped via
`_ToolExclusionMiddleware` so review stays in one context.
- New `github_comment` tool: agents call it once per issue with
`(file, line, body, severity)` and the eval scores those calls
against golden comments.
- `ensure_no_empty_msg` middleware (the no_op nudge) is intentionally
*not* on the reviewer's stack — that middleware exists to enforce the
main agent's "always finalize via Slack/Linear/PR" contract, which
the reviewer doesn't have. The main agent's behavior is unchanged.
- `evals/reviewer/target.py`: send PR info as a user message, extract
every `github_comment` tool call (multiple expected per review) into
the run output.
- `evals/reviewer/judge.py`: per-example evaluator now returns a list
of metrics under `{"results": [...]}` so LangSmith averages each
numeric key (f1/precision/recall/tp/fp/fn) across the experiment in
the UI. Dropped the broken `aggregate_pr` summary evaluator that
reached for an attribute that doesn't exist on `RunTree`.
- `evals/reviewer/run_eval.py`: `--limit` now slices the dataset via
`client.list_examples(limit=N)` since `aevaluate` doesn't accept
`max_examples`.
- Makefile: `dev` and `run` targets now use `uv run` so they work
without an activated venv.
* resolve comments
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
199 lines
6.7 KiB
Python
199 lines
6.7 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 model martian used to score Devin Review). 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 threading
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from langchain_anthropic import ChatAnthropic
|
|
from langsmith.schemas import Example, Run
|
|
|
|
JUDGE_MODEL = "claude-opus-4-5"
|
|
|
|
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
|
|
|
|
|
|
def _get_judge() -> ChatAnthropic:
|
|
global _judge
|
|
if _judge is None:
|
|
_judge = ChatAnthropic(model=JUDGE_MODEL, temperature=0.0, max_tokens=512)
|
|
return _judge
|
|
|
|
|
|
def _format_candidate(c: dict) -> 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: dict) -> 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: dict, candidate: dict) -> dict[str, Any]:
|
|
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("}")
|
|
return json.loads(raw[start : end + 1])
|
|
except (ValueError, json.JSONDecodeError):
|
|
return {"match": False, "confidence": 0.0, "reasoning": f"unparseable: {raw[:200]}"}
|
|
|
|
|
|
_PER_EXAMPLE_COUNTS: dict[UUID, dict[str, int | float]] = {}
|
|
_COUNTS_LOCK = threading.Lock()
|
|
|
|
|
|
def _record_counts(example_id: UUID, counts: dict[str, int | float]) -> None:
|
|
with _COUNTS_LOCK:
|
|
_PER_EXAMPLE_COUNTS[example_id] = counts
|
|
|
|
|
|
def _drain_counts() -> list[dict[str, int | float]]:
|
|
with _COUNTS_LOCK:
|
|
snapshot = list(_PER_EXAMPLE_COUNTS.values())
|
|
_PER_EXAMPLE_COUNTS.clear()
|
|
return snapshot
|
|
|
|
|
|
def judge_match(run: Run, example: Example) -> dict[str, Any]:
|
|
"""Per-example evaluator: compute precision/recall/f1/tp/fp/fn against goldens.
|
|
|
|
Stashes the raw counts on a process-local cache keyed by ``example.id`` so
|
|
``aggregate_pr`` can compute micro-averages without re-judging.
|
|
"""
|
|
candidates: list[dict] = list((run.outputs or {}).get("comments") or [])
|
|
goldens: list[dict] = list((example.outputs or {}).get("golden_comments") or [])
|
|
|
|
if not goldens:
|
|
return {"results": [{"key": "f1", "score": None, "comment": "no goldens"}]}
|
|
|
|
matched_goldens: set[int] = set()
|
|
matched_candidates: set[int] = set()
|
|
|
|
for ci, cand in enumerate(candidates):
|
|
for gi, gold in enumerate(goldens):
|
|
if gi in matched_goldens:
|
|
continue
|
|
res = _judge_pair(gold, cand)
|
|
if res.get("match"):
|
|
matched_goldens.add(gi)
|
|
matched_candidates.add(ci)
|
|
break
|
|
|
|
tp = len(matched_goldens)
|
|
fp = max(0, len(candidates) - len(matched_candidates))
|
|
fn = max(0, len(goldens) - tp)
|
|
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
|
recall = tp / (tp + fn) if (tp + fn) else 0.0
|
|
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
|
|
|
_record_counts(
|
|
example.id,
|
|
{"tp": tp, "fp": fp, "fn": fn, "precision": precision, "recall": recall, "f1": f1},
|
|
)
|
|
|
|
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_goldens", "score": len(goldens)},
|
|
]
|
|
}
|
|
|
|
|
|
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": []}
|
|
|
|
micro_tp = sum(int(c["tp"]) for c in counts)
|
|
micro_fp = sum(int(c["fp"]) for c in counts)
|
|
micro_fn = sum(int(c["fn"]) for c in counts)
|
|
|
|
micro_p = micro_tp / (micro_tp + micro_fp) if (micro_tp + micro_fp) else 0.0
|
|
micro_r = micro_tp / (micro_tp + micro_fn) if (micro_tp + micro_fn) else 0.0
|
|
micro_f1 = _f1(micro_p, micro_r)
|
|
|
|
n = len(counts)
|
|
macro_p = sum(float(c["precision"]) for c in counts) / n
|
|
macro_r = sum(float(c["recall"]) for c in counts) / n
|
|
macro_f1 = sum(float(c["f1"]) for c in counts) / n
|
|
|
|
return {
|
|
"results": [
|
|
{"key": "micro_precision", "score": micro_p},
|
|
{"key": "micro_recall", "score": micro_r},
|
|
{"key": "micro_f1", "score": micro_f1},
|
|
{"key": "macro_precision", "score": macro_p},
|
|
{"key": "macro_recall", "score": macro_r},
|
|
{"key": "macro_f1", "score": macro_f1},
|
|
{"key": "total_tp", "score": micro_tp},
|
|
{"key": "total_fp", "score": micro_fp},
|
|
{"key": "total_fn", "score": micro_fn},
|
|
]
|
|
}
|