Files
Claude 5d44121b28 feat(eval): LLM-as-judge evaluators for finding actionability and review self-consistency
Two llm_as_judge evaluators score the review generation directly: a
NUMERIC 0-1 on finding actionability, a BOOLEAN on whether the summary
agrees with the findings. Both run on every observation whose trace
name is pr-review or opencode-review.

The judge is kimi-k2.7-code through the headroom hub. Local Ollama
returns Anthropic-format responses but the thinking blocks lack the
signature field Langfuse Zod schema requires; the evaluator preflight
fails as Invalid JSON response. A small judge-proxy pod on 8802
forwards to the hub and patches every thinking block with a synthetic
signature before returning.

Trace + generation output now includes the findings themselves
(capped at 25) rather than just the count, so a judge has something
to grade. generation input/output mirrors the trace so an
observation-level evaluator can read them.

Idempotent: existing evaluators and rules are skipped on re-run,
not duplicated. The connection is upserted on provider.
2026-08-31 17:17:16 +00:00

225 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Evaluation — scorers, ground truth, and the dataset
Langfuse already receives one trace per review (`README-langfuse.md`). This is
the layer on top: numbers attached to those traces that say how the reviewer
*behaved*, and the beginnings of a ground-truth signal that says whether it was
*right*.
Those two things are deliberately kept apart, because only one of them exists
yet.
## What could and could not be built
`feedback.db` has recorded 113 reviews across 4 repos. It has recorded **zero**
reactions, zero thread resolutions and zero replies. The harvester, the schema
and the daily analyzer are all working; nobody has ever reacted to a bot
comment.
That rules out an accuracy metric today. Correctness needs labels, and a
judge scored against no labels is theatre. So the scorers here measure
behaviour, which is computable from data already in hand, and a separate
bridge exists to turn human reactions into scores the moment any arrive.
## The five behavioural scores
Emitted with every review by `eval_scores.py`, folded into the same ingestion
batch as the trace so they cost no extra request.
| score | type | what a change in it means |
|---|---|---|
| `finding_rate` | NUMERIC | Findings posted. 0 is the restraint case — good on clean code, a failure when the run degraded. Only the rate over time separates those. |
| `severity_info_ratio` | NUMERIC 01 | Share of findings the model rated `info`/`trivial`. Rising = the model is hedging rather than committing. `None` when the review was silent: a ratio over an empty set is undefined, and charting it as 0 would read as perfect calibration. |
| `severity_max` | CATEGORICAL | Highest severity surfaced, `none` when silent. Categorical because "did this ever surface something serious" is the real question, and a mean of severity ranks answers nothing. |
| `dropped_findings` | NUMERIC | Findings the model emitted that the parser rejected for an unusable `path`/`line`. This is the only score here that measures the model's raw output. |
| `cost_per_finding` | NUMERIC | Equivalent USD per finding. A cheaper model that finds nothing is not cheaper. |
### Why `dropped_findings` needed a change to the parser
`parse_findings` and `parse_review_output` discard any finding with a missing or
unusable location. That happens silently, so a model emitting ten findings at
invalid locations was indistinguishable from a model that found nothing — both
produce an empty list. `ai_review.last_parse_dropped()` exposes the delta,
recorded at parse time.
It must be read at parse time specifically: by the time findings reach
`_emit_langfuse`, `apply_repo_config` has already filtered them by
`severity_threshold` and `max_findings`, and those drops are the config working
as intended, not the model misbehaving.
## Ground truth: `feedback_scores.py`
Turns `feedback.db` into two session-level scores, keyed on `"{repo}#{pr}"`
(which is what `langfuse_trace` already sets as `sessionId`).
| score | meaning |
|---|---|
| `review_engagement` | Share of a PR's findings that drew any human reaction, resolution or reply. **Watch this first** — every quality number is vapour until it moves off 0. |
| `review_acceptance` | Net verdict over engaged findings, 1 to +1. Absent, not 0, when nothing was engaged: zero would claim humans judged the review neutral, when the truth is nobody looked. |
Session-level rather than trace-level because feedback arrives days later
against a PR, and nothing in `feedback.db` records which re-run of the reviewer
produced which comment. The session is both the available join and the honest
granularity.
Score ids are `uuid5(namespace, repo#pr#name)`, so the daily backfill updates
rather than duplicates.
## The dataset
`pragent-reviews`, one item per PR the reviewer has run on, seeded by
`eval_bootstrap.py` from `feedback.db`.
`expectedOutput` is **the reviewer's own prior output**, not human-verified
truth — every item carries `metadata.labelled_by_human: false`. Read it as a
regression baseline: re-run a candidate model over these PRs and the diff
against this column is the behaviour change. Promoting an item to real ground
truth means a human editing it in the dataset view after re-reading the PR.
### Item ids
`{owner}__{repo}__pr{n}`. The obvious `{repo}#{pr}` cannot be used: items are
routed as `/datasets/{id}/items/{item_id}`, so the `/` in `owner/repo` splits
into extra path segments and everything after `#` is a fragment the browser
never sends — the item is created fine by the API and then 404s when opened.
Session ids elsewhere keep `{repo}#{pr}`; those are never path segments.
### Filterable metadata
The filter bar matches on `metadata` only — not on `input`, and not on the item
id — so every facet worth slicing on is a flat, primitive key in `metadata`
even where it duplicates `input`:
| key | why it is there |
| --- | --- |
| `repo`, `owner`, `repo_name` | `owner` exists because a filter on the joined `repo` matches one repo, never a whole org |
| `pr`, `head_sha` | jump from a filtered row back to the actual PR |
| `finding_count`, `has_findings` | isolate the silent reviews, which are the interesting negatives |
| `max_severity` | `"none"` rather than absent — an absent key matches no filter |
| `reviews_run` | how churny the PR was; high values skew per-item averages |
| `last_reviewed_at` / `_iso` | epoch sorts, ISO reads |
| `labelled_by_human` | `false` everywhere today; the flag to filter on before trusting any of it |
Nested objects and lists are deliberately absent: the filter bar cannot reach
into them.
`max_severity` is derived from `feedback.db`, whose `severity` column is
re-parsed out of the rendered comment by `feedback_harvest._parse_severity` and
defaults to `INFO` when its regex misses the badge. Trust the `severity_max`
**score** (read from the model's structured output) over this facet.
## Experiments
`eval_experiment.py` links reviews that already ran into a dataset run, so the
Experiments tab is populated without re-running anything. Runs are grouped by
model — the comparison the pilot actually needs is the same PRs under a
candidate model with `finding_rate` and `cost_per_finding` side by side. A new
model produces a new run automatically on the next invocation.
One trace per (run, item), the most recent: a PR re-reviewed on every push has
many traces, and a run is one output per input.
It uses `POST /api/public/dataset-run-items`, which is deprecated in favour of
the SDK experiment runner and disappears in Langfuse v4. The deprecation notice
exempts self-hosted v3 from the cutoff date, and this pilot is stdlib-only by
design. Revisit when this deployment moves to v4.
Coverage is bounded by the dataset, not by the traces: items only exist for PRs
with a row in `feedback.db`, and a review that posted no comment leaves a trace
but no row. That is why a run links fewer items than there are traces.
## Evaluators: `eval_judges.py`
Behaviour scores answer "how many, how severe, how much" — computable from data
already in hand. Two things they cannot answer:
- **Was the finding any good?** Specificity vs. hedge, generic advice vs.
fix-it-now advice — the difference between a useful review and one a
developer scrolls past.
- **Did the summary match the findings?** Claiming "no issues" above two
criticals, or describing a problem in prose that never became a finding.
These need a judge. `eval_judges.py` registers two `llm_as_judge` evaluators
against the trace names this project emits (`pr-review`, `opencode-review`)
and wires a sampling=1 rule per evaluator. Both run on every observation in a
matching trace; the only observations in those traces are the review itself.
| evaluator | output | what it answers |
|---|---|---|
| `finding_actionability` | NUMERIC 01 | How specific and fixable is each finding? |
| `review_self_consistency` | BOOLEAN | Does the summary agree with the findings? |
The judge is a different model from the reviewer (`kimi-k2.7-code` through the
headroom hub). A model grading its own output agrees with itself for reasons
that have nothing to do with quality. The judges are also asked only what they
can answer from the review itself — never whether a finding is correct, since
that needs the diff the trace does not carry.
### Why the judge goes through `judge-proxy` (port 8802)
The headroom hub in front of local Ollama returns Anthropic-format responses,
but every `thinking` content block is missing the `signature` field real
Claude emits. Langfuse's Zod schema requires it; the omission fails the
evaluator preflight as `Invalid JSON response`. The `judge-proxy` pod sits in
front of the hub on `100.74.17.70:8802` and patches every thinking block with
a synthetic signature before forwarding the response. The model is unchanged;
only the wire shape is fixed.
```bash
python3 pilot/eval_judges.py --dry-run # show what would be created
python3 pilot/eval_judges.py # create the LLM connection, evaluators, rules
```
Idempotent: existing evaluators and rules are skipped, not duplicated. The
connection is upserted on `provider` so re-runs return the same record.
## Running it
```bash
# once per project: score configs + dataset (+ score historical traces)
python3 pilot/eval_bootstrap.py --db /data/feedback.db --backfill-traces
# ship feedback verdicts (runs daily from the feedback CronJob)
python3 pilot/feedback_scores.py --db /data/feedback.db
# link already-traced reviews into a dataset run per model
python3 pilot/eval_experiment.py --dry-run
python3 pilot/eval_experiment.py
```
Both need `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`. In
cluster they come from the `pragent-langfuse` Secret and point at the ClusterIP
— never the NodePort, whose oauth2-proxy 302s ingestion to Logto and drops it.
## Gotcha: HTTP 207 is not success
The ingestion endpoint answers `207 Multi-Status` when *some* events failed, so
a batch where **every** event was rejected still returns 207. An early version
of these scorers omitted the required per-event `timestamp` and silently
ingested nothing while reporting success. `langfuse_trace._warn_on_rejected_events`
now logs the per-event errors under `LANGFUSE_DEBUG=1`. If scores are missing,
check that before anything else.
## What the first run showed
Backfilled over 42 existing traces and 13 PRs:
```
cost_per_finding n=42 mean=0.3133 min=0.0880 max=0.9042
finding_rate n=42 mean=0.4762 min=0.0000 max=4.0000
severity_info_ratio n=14 mean=0.0000
review_engagement n=14 mean=0.0000
severity_max {none: 28, medium: 11, high: 1, critical: 2}
```
Two things worth keeping:
- **The reviewer is not info-heavy.** `feedback.db` shows 61 of 62 findings at
`INFO`, which looked like a badly calibrated model. It is not: `severity_max`
reads `medium`/`high`/`critical` on every trace that found anything, and
`severity_info_ratio` is flat 0. The `INFO` in the DB comes from
`feedback_harvest._parse_severity`, which defaults to `INFO` when its regex
misses the severity badge in the rendered comment. The DB severity is a
re-parse artifact; the score reads the model's structured output directly.
- **28 of 42 reviews found nothing** (67%), and **engagement is flat zero**. The
first is not yet interpretable without the second.