test(evals): LLM-judge eval tier — non-gating semantic suites (Wave 0.3) (#355)

Ports Patter's eval harness (MIT, attribution headers) into tests/evals/
with the judge transport swapped to services/llm_backend.py — the judge
runs against whatever local Ollama/LM Studio/OpenAI-compat endpoint the
user configured, keeping local-first. Both Patter hardening details kept
verbatim: verdict recomputed locally from the score (hallucinated
'passed: true' at score 0.2 fails), and tolerant JSON parsing (fences
stripped, invalid JSON -> fail-with-reasoning). Per-case containment:
agent exceptions keep the partial transcript and still judge it; a judge
failure records score 0 instead of aborting the suite.

HARD RULE preserved: LLM judges never gate CI. The scheduled workflow
(weekly + dispatch) is continue-on-error with the JSON report as artifact;
run_evals.py exits 0 always and skips cleanly when the active LLM backend
is 'off'. Deterministic probe judges remain the only gates; the harness
unit tests (10, no LLM needed) do run in gating CI.

First suite: dub translation naturalness v1 (4 cases) driving the real
cinematic_refine_sync reflect+adapt chain. The telephony-specific
session/assertions layers were deliberately not ported. The
dictation-refinement suite lands with Wave 1.1/2.1.

Spec: docs/competitive-analysis.md Spec 9b / parity program Wave 0.3.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-11 22:00:30 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 11c498eeb5
commit 1195b4e0dd
8 changed files with 781 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# LLM-judge evals — semantic quality suites, NEVER a gate.
#
# Hard rule (parity program Wave 0.3 / competitive-analysis Spec 9b): LLM
# judges never gate CI. This workflow is scheduled + manual only, the eval
# step is continue-on-error, and the JSON report is the deliverable
# (uploaded as an artifact). Deterministic probe judges in ci.yml remain
# the only gates.
#
# On the hosted runner there is no local LLM endpoint, so the run usually
# reports "skipped — no LLM backend configured"; the workflow exists so the
# suites run anywhere a TRANSLATE_BASE_URL secret/endpoint is provided
# (e.g. a self-hosted runner with Ollama).
name: evals
on:
schedule:
# Weekly, Sundays 04:00 UTC.
- cron: "0 4 * * 0"
workflow_dispatch:
permissions:
contents: read
jobs:
evals:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install deps
run: uv sync
- name: Run eval suites (non-gating)
continue-on-error: true
env:
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
run: uv run python tests/evals/run_evals.py --output eval-report.json
- name: Upload report artifact
uses: actions/upload-artifact@v4
with:
name: eval-report
path: eval-report.json
if-no-files-found: warn
+17
View File
@@ -0,0 +1,17 @@
"""LLM-judge eval tier (parity program Wave 0.3, Spec 9b).
Semantic evaluation of outputs that deterministic probe judges can't score
(dub translation naturalness, dictation-refinement quality). HARD RULE:
these evals NEVER gate CI — they run as a separate non-blocking scheduled
job (.github/workflows/evals.yml) whose report lands as an artifact.
Deterministic probe judges (tests/probe/judges/) remain the only gates.
Harness adapted from Patter (https://github.com/PatterAI/Patter),
MIT License, Copyright (c) 2026 Patter Contributors. The telephony-specific
session/assertions layers were intentionally not ported; the judge backend
is swapped to OmniVoice's local-first LLM adapter (services.llm_backend).
"""
from .case import EvalCase, EvalResult, EvalTurn, JudgeResult # noqa: F401
from .judge import LLMJudge # noqa: F401
from .runner import EvalRunner, EvalSuite, load_suite # noqa: F401
+72
View File
@@ -0,0 +1,72 @@
"""Eval case data model.
Adapted from Patter (https://github.com/PatterAI/Patter), MIT License,
Copyright (c) 2026 Patter Contributors.
An :class:`EvalCase` is either a scripted conversation (``turns``) or — the
OmniVoice extension — a structured ``input`` mapping handed verbatim to the
system under test (e.g. a dub segment with source/literal/langs). Both shapes
produce a role-tagged transcript that the judge LLM scores against
``expected_behavior`` + ``rubric``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class EvalTurn:
"""A single user utterance in a scripted conversation."""
user: str
# Optional substrings the reply should contain — a cheap pre-filter
# logged before the judge runs (the judge still decides).
expected_contains: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class EvalCase:
"""A complete evaluation scenario."""
name: str
expected_behavior: str
rubric: str
turns: tuple[EvalTurn, ...] = field(default_factory=tuple)
# OmniVoice extension: structured input for non-conversational systems
# under test (translator, refiner). When set, ``turns`` is ignored and
# the agent callable receives this mapping.
input: dict[str, Any] = field(default_factory=dict)
tags: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class JudgeResult:
"""The judge's verdict on one case."""
score: float # 0.0-1.0
passed: bool
reasoning: str
@dataclass(frozen=True)
class EvalResult:
"""The result of running a single :class:`EvalCase`."""
case_name: str
transcript: tuple[dict[str, str], ...] # [{"role": "user"|"agent", "text": ...}]
judge: JudgeResult
duration_s: float
error: str | None = None
def to_dict(self) -> dict:
return {
"case": self.case_name,
"score": self.judge.score,
"passed": self.judge.passed,
"reasoning": self.judge.reasoning,
"transcript": list(self.transcript),
"duration_s": round(self.duration_s, 3),
"error": self.error,
}
+118
View File
@@ -0,0 +1,118 @@
"""LLM-as-judge scoring for eval cases.
Adapted from Patter (https://github.com/PatterAI/Patter), MIT License,
Copyright (c) 2026 Patter Contributors. Two hardening details are ported
verbatim by design:
* the verdict is recomputed LOCALLY (``passed = score >= threshold``) —
trusting the model's self-reported ``passed`` once let a hallucinated
``passed: true`` with ``score: 0.2`` record a pass;
* JSON parsing is tolerant (code fences stripped; invalid JSON becomes a
fail-with-reasoning, never a crash).
The OpenAI-specific client is replaced by OmniVoice's local-first LLM
adapter (``services.llm_backend``) — the judge runs against whatever
Ollama/LM Studio/OpenAI-compat endpoint the user configured, keeping the
no-required-cloud guarantee. Any object exposing ``judge(prompt) -> str``
(async) can be injected via ``backend=`` for tests.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from .case import EvalCase, JudgeResult
logger = logging.getLogger("omnivoice.evals")
_JUDGE_SYSTEM = (
"You are a strict but fair evaluator of a local voice studio's text "
"outputs (translations, cleaned-up transcripts). You will be given: "
"(1) the expected behavior, (2) a rubric, (3) a transcript of inputs "
"and the system's output. "
"Return a JSON object with exactly three keys:\n"
' - "score": float between 0.0 and 1.0\n'
' - "passed": boolean (true when score >= threshold)\n'
' - "reasoning": short string explaining the score\n'
"Do not return any text outside the JSON object."
)
class _LLMBackendJudge:
"""Default judge transport: OmniVoice's active LLM backend."""
def __init__(self, timeout: float = 120.0) -> None:
self._timeout = timeout
self._backend: Any = None
def _resolve(self):
if self._backend is None:
from services.llm_backend import get_active_llm_backend
self._backend = get_active_llm_backend()
return self._backend
async def judge(self, prompt: str) -> str:
backend = self._resolve()
# LLMBackend.chat is sync; keep the judge loop responsive.
return await asyncio.to_thread(
backend.chat, system=_JUDGE_SYSTEM, user=prompt, timeout=self._timeout
)
class LLMJudge:
"""Scores case transcripts against a rubric via the configured LLM."""
def __init__(
self,
pass_threshold: float = 0.7,
backend: Any = None,
) -> None:
self.pass_threshold = pass_threshold
self._backend = backend or _LLMBackendJudge()
async def judge_case(
self, case: EvalCase, transcript: list[dict[str, str]]
) -> JudgeResult:
prompt = self._build_prompt(case, transcript)
raw = await self._backend.judge(prompt)
return self._parse(raw)
def _build_prompt(self, case: EvalCase, transcript: list[dict[str, str]]) -> str:
lines = [
f"EXPECTED BEHAVIOR: {case.expected_behavior}",
f"RUBRIC: {case.rubric}",
f"PASS THRESHOLD: {self.pass_threshold}",
"TRANSCRIPT:",
]
for turn in transcript:
lines.append(f" {turn.get('role', '?')}: {turn.get('text', '')}")
return "\n".join(lines)
def _parse(self, raw: str) -> JudgeResult:
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.warning("LLMJudge: invalid JSON, defaulting to fail: %r", raw)
return JudgeResult(
score=0.0,
passed=False,
reasoning=f"Judge returned invalid JSON: {(raw or '')[:200]}",
)
try:
score = float(data.get("score", 0.0))
except (TypeError, ValueError):
score = 0.0
score = max(0.0, min(1.0, score))
# Verdict computed locally — never trust the model's own `passed`.
passed = score >= self.pass_threshold
return JudgeResult(score=score, passed=passed, reasoning=str(data.get("reasoning", "")))
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Run the LLM-judge eval suites and write a JSON report artifact.
NEVER a CI gate (parity program Wave 0.3 hard rule): exits 0 whether cases
pass or fail — the report is the deliverable. Exits 0 with a skip notice
when no LLM backend is configured (the scheduled CI runner has none; the
suites are meant for machines with a local Ollama/LM Studio endpoint).
Usage:
uv run python tests/evals/run_evals.py --output eval-report.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent)) # tests/ -> import evals.*
sys.path.insert(0, str(_HERE.parents[1] / "backend")) # services.*
SUITES_DIR = _HERE / "suites"
def _llm_ready() -> tuple[bool, str]:
from services.llm_backend import get_active_llm_backend
backend = get_active_llm_backend()
# OffBackend reports available=True (it is a valid no-op choice) but its
# chat() raises — for evals "ready" means a real endpoint is configured.
if backend.id == "off":
return False, "active LLM backend is 'off'"
ok, msg = type(backend).is_available()
return ok, f"{backend.display_name}: {msg}"
async def _translator_agent(case_input: dict) -> str:
"""System under test for the dub-naturalness suite."""
from services.translator import cinematic_refine_sync
result = await asyncio.to_thread(
cinematic_refine_sync,
case_input["source"],
case_input["literal"],
source_lang=case_input.get("source_lang", "en"),
target_lang=case_input["target_lang"],
)
if result.get("error"):
raise RuntimeError(f"translator error: {result['error']}")
return result["text"]
_AGENTS = {
"dub_translation_naturalness": lambda: _translator_agent,
}
async def _main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--suite", default="all", help="suite stem or 'all'")
parser.add_argument("--output", type=Path, default=Path("eval-report.json"))
parser.add_argument("--pass-threshold", type=float, default=0.7)
args = parser.parse_args()
ok, detail = _llm_ready()
if not ok:
print(f"evals: SKIPPED — no LLM backend configured ({detail}).")
print("Configure TRANSLATE_BASE_URL (Ollama/LM Studio/OpenAI-compat) to run.")
args.output.write_text(
json.dumps({"skipped": True, "reason": detail}, indent=2), encoding="utf-8"
)
return 0
from evals.judge import LLMJudge
from evals.runner import EvalRunner, load_suite
stems = (
sorted(p.stem for p in SUITES_DIR.glob("*.yaml"))
if args.suite == "all"
else [args.suite]
)
reports = []
for stem in stems:
factory = _AGENTS.get(stem)
if factory is None:
print(f"evals: no agent registered for suite {stem!r}, skipping.")
continue
suite = load_suite(SUITES_DIR / f"{stem}.yaml")
runner = EvalRunner(judge=LLMJudge(pass_threshold=args.pass_threshold))
results = await runner.run(suite, factory)
report = json.loads(runner.report(suite, results))
reports.append(report)
print(
f"evals: {suite.name}{report['passed']}/{report['total']} passed "
f"(rate {report['pass_rate']:.2f})"
)
args.output.write_text(json.dumps({"suites": reports}, indent=2), encoding="utf-8")
print(f"evals: report written to {args.output}")
return 0 # informational by design — failures live in the report
if __name__ == "__main__":
raise SystemExit(asyncio.run(_main()))
+179
View File
@@ -0,0 +1,179 @@
"""Eval runner — executes an :class:`EvalSuite` and produces a JSON report.
Adapted from Patter (https://github.com/PatterAI/Patter), MIT License,
Copyright (c) 2026 Patter Contributors. The real-pipeline ``EvalSession``
path (telephony-specific) was not ported; OmniVoice cases either script
``turns`` against an async ``reply(text) -> str`` callable, or carry a
structured ``input`` mapping handed to an async ``run(input) -> str``
callable (the system under test: translator, refiner, ...).
Per-case error containment is preserved verbatim: a mid-case exception keeps
the partial transcript and still judges it; a judge failure records
``score 0 + reasoning`` instead of aborting the whole suite.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable
from .case import EvalCase, EvalResult, EvalTurn, JudgeResult
from .judge import LLMJudge
logger = logging.getLogger("omnivoice.evals")
# ``turns`` cases: factory returns an async ``reply(text) -> str``.
# ``input`` cases: factory returns an async ``run(input: dict) -> str``.
AgentCallable = Callable[[Any], Awaitable[str]]
AgentFactory = Callable[[], AgentCallable]
@dataclass(frozen=True)
class EvalSuite:
"""A named collection of :class:`EvalCase` to run together."""
name: str
cases: tuple[EvalCase, ...]
metadata: dict[str, Any] = field(default_factory=dict)
class EvalRunner:
def __init__(self, judge: LLMJudge | None = None) -> None:
self.judge = judge or LLMJudge()
async def run(self, suite: EvalSuite, agent_factory: AgentFactory) -> list[EvalResult]:
return [await self.run_case(case, agent_factory) for case in suite.cases]
async def run_case(self, case: EvalCase, agent_factory: AgentFactory) -> EvalResult:
start = time.monotonic()
transcript: list[dict[str, str]] = []
error: str | None = None
try:
agent = agent_factory()
if case.input:
# Structured-input path: render the input for the judge,
# then hand the mapping to the system under test.
rendered = "\n".join(f"{k}: {v}" for k, v in case.input.items())
transcript.append({"role": "user", "text": rendered})
reply = await agent(dict(case.input))
transcript.append({"role": "agent", "text": reply or ""})
else:
for turn in case.turns:
transcript.append({"role": "user", "text": turn.user})
reply = await agent(turn.user)
transcript.append({"role": "agent", "text": reply or ""})
self._log_missing_expected(case, turn, reply or "")
except Exception as exc: # noqa: BLE001 — containment is the point
error = f"{type(exc).__name__}: {exc}"
logger.exception("case=%r raised", case.name)
if error and not transcript:
return EvalResult(
case_name=case.name,
transcript=tuple(transcript),
judge=JudgeResult(score=0.0, passed=False, reasoning=error),
duration_s=time.monotonic() - start,
error=error,
)
try:
judge_result = await self.judge.judge_case(case, transcript)
except Exception as exc: # noqa: BLE001 — judge 429/timeout/missing key
# One transient judge failure must not abort the whole suite.
return EvalResult(
case_name=case.name,
transcript=tuple(transcript),
judge=JudgeResult(score=0.0, passed=False, reasoning=f"judge error: {exc}"),
duration_s=time.monotonic() - start,
error=f"judge error: {exc}",
)
return EvalResult(
case_name=case.name,
transcript=tuple(transcript),
judge=judge_result,
duration_s=time.monotonic() - start,
error=error,
)
@staticmethod
def _log_missing_expected(case: EvalCase, turn: EvalTurn, reply: str) -> None:
for needle in turn.expected_contains:
if needle.lower() not in reply.lower():
logger.info("case=%r expected_contains=%r missing in reply", case.name, needle)
def report(self, suite: EvalSuite, results: list[EvalResult]) -> str:
"""Render a JSON report suitable for CI artifacts. Never a gate."""
total = len(results)
passed = sum(1 for r in results if r.judge.passed)
payload = {
"suite": suite.name,
"total": total,
"passed": passed,
"failed": total - passed,
"pass_rate": (passed / total) if total else 0.0,
"cases": [r.to_dict() for r in results],
}
return json.dumps(payload, indent=2)
def load_suite(path: Path) -> EvalSuite:
"""Load a suite from YAML or JSON.
Schema (YAML)::
name: "dub translation naturalness v1"
cases:
- name: "idiom is adapted, not translated"
expected_behavior: "The adapted line replaces the idiom ..."
rubric: "Pass if ..."
input:
source: "It's raining cats and dogs."
literal: "..."
"""
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
import yaml
data = yaml.safe_load(text)
else:
data = json.loads(text)
if not isinstance(data, dict):
raise ValueError(f"Eval suite {path} must be a mapping, got {type(data).__name__}")
cases_raw = data.get("cases", [])
if not isinstance(cases_raw, list):
raise ValueError(f"Eval suite {path}: 'cases' must be a list")
cases: list[EvalCase] = []
for i, c in enumerate(cases_raw):
if not isinstance(c, dict):
raise ValueError(f"Eval suite {path}: case {i} must be a mapping")
turns = tuple(
EvalTurn(
user=str(t.get("user", "")),
expected_contains=tuple(t.get("expected_contains", []) or []),
)
for t in c.get("turns", []) or []
if isinstance(t, dict)
)
cases.append(
EvalCase(
name=str(c.get("name", f"case_{i}")),
turns=turns,
input=dict(c.get("input", {}) or {}),
expected_behavior=str(c.get("expected_behavior", "")),
rubric=str(c.get("rubric", "")),
tags=tuple(c.get("tags", []) or []),
)
)
return EvalSuite(
name=str(data.get("name", path.stem)),
cases=tuple(cases),
metadata=dict(data.get("metadata", {}) or {}),
)
@@ -0,0 +1,70 @@
# Dub translation naturalness v1 — semantic regression suite for the
# cinematic translate chain (services/translator.py reflect+adapt pass).
#
# System under test: cinematic_refine_sync(source, literal, ...) — each case
# feeds a source line + a deliberately stiff literal translation; the judge
# scores whether the adapted line reads like natural dialogue. NEVER a CI
# gate (parity program Wave 0.3 hard rule).
name: dub translation naturalness v1
metadata:
system_under_test: services.translator.cinematic_refine_sync
cases:
- name: english idiom adapts to spanish equivalent
tags: [idiom, es]
input:
source: "It's raining cats and dogs out there, take an umbrella."
literal: "Esta lloviendo gatos y perros alla afuera, lleva un paraguas."
source_lang: en
target_lang: es
expected_behavior: >
The adapted Spanish line replaces the English idiom with a natural
Spanish equivalent (e.g. "esta lloviendo a cantaros") instead of the
word-for-word animal phrase, and keeps the umbrella advice.
rubric: >
Fail if the output still contains a literal cats-and-dogs construction.
Pass if a fluent Spanish speaker would find the line natural and the
meaning (heavy rain + take an umbrella) is preserved.
- name: register matches casual dialogue in german
tags: [register, de]
input:
source: "No way, you've got to be kidding me!"
literal: "Kein Weg, du musst mich verspotten!"
source_lang: en
target_lang: de
expected_behavior: >
The adapted German line is a natural colloquial exclamation (e.g.
"Das gibt's doch nicht!" or "Das kann nicht dein Ernst sein!"), not the
ungrammatical word-for-word rendering.
rubric: >
Fail if "Kein Weg" or "verspotten" survive. Pass if the line is
idiomatic spoken German with the same incredulous register.
- name: contraction-heavy line stays speakable in french
tags: [speakability, fr]
input:
source: "I dunno, it's kinda late — let's just call it a day."
literal: "Je ne sais pas, c'est un peu tard - appelons-le simplement un jour."
source_lang: en
target_lang: fr
expected_behavior: >
The adapted French line drops the calqued "appelons-le un jour" and uses
a natural equivalent (e.g. "on arrete la pour aujourd'hui"), keeping the
casual hedging tone.
rubric: >
Fail if the call-it-a-day calque survives. Pass if the line is natural
spoken French of similar length and casualness.
- name: honorific register preserved in formal speech
tags: [register, formal, es]
input:
source: "Ladies and gentlemen, thank you for joining us this evening."
literal: "Damas y caballeros, gracias por unirse a nosotros esta noche."
source_lang: en
target_lang: es
expected_behavior: >
The adapted line keeps the formal register of a public address — it must
NOT become casual — while staying natural.
rubric: >
Fail if the register drops to informal. Pass if a native speaker would
accept it as a natural formal opening of similar length.
+163
View File
@@ -0,0 +1,163 @@
"""Deterministic unit tests for the eval harness (no LLM required).
These DO run in gating CI — they test the harness mechanics, not semantic
quality. The semantic suites themselves run only in the non-gating
scheduled workflow (.github/workflows/evals.yml).
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from evals.case import EvalCase, EvalTurn
from evals.judge import LLMJudge
from evals.runner import EvalRunner, EvalSuite, load_suite
SUITES_DIR = Path(__file__).resolve().parent / "suites"
class FakeJudgeBackend:
"""Canned judge transport: returns queued raw strings."""
def __init__(self, *responses: str):
self._responses = list(responses)
async def judge(self, prompt: str) -> str:
return self._responses.pop(0)
def _run(coro):
return asyncio.run(coro)
def _echo_factory():
async def reply(payload):
return f"echo: {payload}"
return reply
# ── Judge hardening (ported from Patter verbatim — keep these honest) ──────
def test_verdict_recomputed_locally_ignores_hallucinated_passed():
judge = LLMJudge(pass_threshold=0.7,
backend=FakeJudgeBackend('{"score": 0.2, "passed": true, "reasoning": "nope"}'))
case = EvalCase(name="c", expected_behavior="x", rubric="y",
turns=(EvalTurn(user="hi"),))
result = _run(judge.judge_case(case, [{"role": "user", "text": "hi"}]))
assert result.passed is False # 0.2 < 0.7 regardless of the model's claim
assert result.score == 0.2
def test_judge_strips_code_fences():
judge = LLMJudge(backend=FakeJudgeBackend('```json\n{"score": 0.9, "reasoning": "ok"}\n```'))
case = EvalCase(name="c", expected_behavior="x", rubric="y")
result = _run(judge.judge_case(case, []))
assert result.passed is True and result.score == 0.9
def test_invalid_judge_json_fails_with_reasoning_not_crash():
judge = LLMJudge(backend=FakeJudgeBackend("I think it passes!"))
case = EvalCase(name="c", expected_behavior="x", rubric="y")
result = _run(judge.judge_case(case, []))
assert result.passed is False
assert "invalid JSON" in result.reasoning
def test_score_clamped_to_unit_interval():
judge = LLMJudge(backend=FakeJudgeBackend('{"score": 7, "reasoning": ""}'))
result = _run(judge.judge_case(EvalCase(name="c", expected_behavior="", rubric=""), []))
assert result.score == 1.0
# ── Runner containment ──────────────────────────────────────────────────────
def test_agent_exception_keeps_partial_transcript_and_still_judges():
calls = []
class SpyJudge(LLMJudge):
async def judge_case(self, case, transcript):
calls.append(list(transcript))
return await super().judge_case(case, transcript)
judge = SpyJudge(backend=FakeJudgeBackend('{"score": 0.0, "reasoning": "partial"}'))
def factory():
state = {"n": 0}
async def reply(text):
state["n"] += 1
if state["n"] == 2:
raise RuntimeError("boom")
return "ok"
return reply
case = EvalCase(name="c", expected_behavior="x", rubric="y",
turns=(EvalTurn(user="one"), EvalTurn(user="two")))
result = _run(EvalRunner(judge=judge).run_case(case, factory))
assert result.error == "RuntimeError: boom"
# Partial transcript (turn one + its reply + turn two) was judged.
assert calls and len(calls[0]) == 3
def test_judge_failure_records_zero_not_suite_abort():
class ExplodingBackend:
async def judge(self, prompt):
raise TimeoutError("judge LLM timed out")
suite = EvalSuite(name="s", cases=(
EvalCase(name="a", expected_behavior="x", rubric="y", turns=(EvalTurn(user="hi"),)),
EvalCase(name="b", expected_behavior="x", rubric="y", turns=(EvalTurn(user="hi"),)),
))
runner = EvalRunner(judge=LLMJudge(backend=ExplodingBackend()))
results = _run(runner.run(suite, _echo_factory))
assert len(results) == 2 # second case still ran
assert all("judge error" in r.judge.reasoning for r in results)
assert all(r.judge.passed is False for r in results)
def test_structured_input_case_renders_transcript():
judge = LLMJudge(backend=FakeJudgeBackend('{"score": 1.0, "reasoning": "ok"}'))
case = EvalCase(name="c", expected_behavior="x", rubric="y",
input={"source": "hello", "target_lang": "es"})
result = _run(EvalRunner(judge=judge).run_case(case, _echo_factory))
assert result.judge.passed is True
assert "source: hello" in result.transcript[0]["text"]
assert result.transcript[1]["text"].startswith("echo: ")
def test_report_shape():
judge = LLMJudge(backend=FakeJudgeBackend(
'{"score": 1.0, "reasoning": "ok"}', '{"score": 0.1, "reasoning": "bad"}'))
suite = EvalSuite(name="s", cases=(
EvalCase(name="a", expected_behavior="x", rubric="y", turns=(EvalTurn(user="hi"),)),
EvalCase(name="b", expected_behavior="x", rubric="y", turns=(EvalTurn(user="hi"),)),
))
runner = EvalRunner(judge=judge)
results = _run(runner.run(suite, _echo_factory))
report = json.loads(runner.report(suite, results))
assert report == {
"suite": "s", "total": 2, "passed": 1, "failed": 1, "pass_rate": 0.5,
"cases": report["cases"],
}
assert report["cases"][0]["case"] == "a"
# ── Suite loading ────────────────────────────────────────────────────────────
def test_load_shipped_dub_suite():
suite = load_suite(SUITES_DIR / "dub_translation_naturalness.yaml")
assert suite.cases, "shipped suite must not be empty"
for case in suite.cases:
assert case.input.get("source") and case.input.get("target_lang")
assert case.expected_behavior and case.rubric
def test_load_suite_rejects_non_mapping(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text("- just\n- a list\n", encoding="utf-8")
with pytest.raises(ValueError, match="must be a mapping"):
load_suite(bad)