Custom evaluators¶
There are three ways to add a metric. Pick by where the logic should live.
SDK metric (@opengrade.metric) |
Custom LLM judge (opengrade.LlmJudge) |
Server evaluator (register_evaluator) |
|
|---|---|---|---|
| What you write | Python code | criteria in plain language | Python code in opengrade_server |
| Runs in | your process, during og.evaluate / @trace(metrics=…) |
the OpenGrade API (judge model) | the OpenGrade API, on POST /experiments/{id}/evaluate |
| Change the server? | no | no (stored as a project evaluator) | yes (contribution) |
| Usable from UI / metrics sets / re-score | listed under "Metrics computed in your code"; cannot be re-run by the server | yes; live=True also scores production traffic |
yes |
| Good for | deterministic, app-specific checks | subjective qualities: tone, format, policy, completeness | reusable metrics everyone should get |
App-specific checks belong in the SDK: see Custom metrics and Custom LLM judges; the judge config is documented in 05-api.md. The rest of this page is for contributors adding an evaluator to the server.
The protocol¶
backend/src/opengrade_server/evaluation/protocol.py:
@dataclass(frozen=True, slots=True)
class EvaluationSample:
input: Any
expected_output: Any | None
actual_output: Any | None
context: Any | None # e.g. {"documents": [...], "latency_ms": ...}
metadata: dict[str, Any] # dataset item metadata
@dataclass(frozen=True, slots=True)
class EvaluationResult:
score: float | None # 0..1 by convention; None when not scored
label: str | None # PASS | FAIL | SKIPPED | ERROR
explanation: str | None # shown per item in the UI
metadata: dict[str, Any] # JSON-serialisable, stored with the result
class Evaluator(Protocol):
name: str
async def evaluate(self, sample: EvaluationSample) -> EvaluationResult: ...
An evaluator is built by a factory Callable[[dict[str, Any]], Evaluator] that receives the evaluator's config (the JSON stored on the evaluator entity). A class whose __init__ takes config is a valid factory.
Rules:
- Validate config in
__init__and raiseValueErroron bad input.POST /projects/{id}/evaluatorsinstantiates the factory once, so bad config is rejected at creation (400), not at scoring time. - Missing inputs →
SKIPPED, notFAIL. Use the helpers inevaluation/outcomes.py:pass_,fail_min,skip,error. - No network or file access in deterministic evaluators. LLM-based evaluators go through the
LlmClientprotocol (seeevaluation/judges/) so provider, retries, timeouts and the judge cache stay in one place. - Never
eval/execconfig. Evaluators are trusted code; their config is user data (security rules). - Be deterministic. The same sample and config give the same result; compare and the quality gate depend on it.
Example: max_length¶
# backend/src/opengrade_server/evaluation/deterministic.py
class MaxLengthEvaluator:
name = "max_length"
def __init__(self, config: dict[str, Any]) -> None:
max_chars = config.get("max_chars")
if not isinstance(max_chars, int) or max_chars < 1:
raise ValueError("max_length: config.max_chars must be a positive integer")
self._max_chars = max_chars
async def evaluate(self, sample: EvaluationSample) -> EvaluationResult:
if sample.actual_output is None:
return skip("actual_output is missing")
length = len(_stringify(sample.actual_output))
meta = {"length": length, "max_chars": self._max_chars}
if length <= self._max_chars:
return pass_(f"{length} ≤ {self._max_chars} chars", metadata=meta)
return fail_min(f"{length} > {self._max_chars} chars", metadata=meta)
Register it next to the built-ins:
def register_deterministic_evaluators() -> None:
...
register_evaluator("max_length", MaxLengthEvaluator)
register_deterministic_evaluators() (and the LLM judges in evaluation/judges/) run from bootstrap_evaluators() at API startup (main.py), so the new kind is available after a restart. kind is case-insensitive and must be unique.
Test it¶
Add unit tests in backend/tests/ (see test_evaluation_engine.py for the registry fixture):
@pytest.mark.asyncio
async def test_max_length() -> None:
ev = create_evaluator("max_length", {"kind": "max_length", "max_chars": 5})
assert (await ev.evaluate(_sample(actual_output="hello"))).label == "PASS"
assert (await ev.evaluate(_sample(actual_output="hello!"))).label == "FAIL"
assert (await ev.evaluate(_sample(actual_output=None))).label == "SKIPPED"
with pytest.raises(ValueError):
create_evaluator("max_length", {"kind": "max_length"})
Cover PASS, FAIL, SKIPPED and bad config. Then run the backend checks:
cd backend
uv sync --all-extras --group dev
uv run ruff check src && uv run ruff format --check src
uv run mypy src/opengrade_server
uv run pytest tests/test_evaluation_engine.py -q
Use it¶
Create an evaluator entity that points at the kind, then select it on evaluate or add it to a metrics set:
POST /api/v1/projects/{project_id}/evaluators
{"name": "short_answer", "type": "deterministic", "config": {"kind": "max_length", "max_chars": 400}}
type is one of deterministic, llm_judge, custom. In a release policy the evaluator is referenced by its entity name:
Checklist for a pull request¶
- [ ] Evaluator class +
register_evaluator(...)call - [ ] Config validated in
__init__; SKIPPED on missing inputs - [ ] Unit tests (PASS / FAIL / SKIPPED / bad config)
- [ ] Listed in
03-evaluation-engine.mdwith its config keys - [ ] For an LLM judge: prompt versioned (
prompt_versionin result metadata) and covered byscripts/judge_sanity.py
Limits¶
Server evaluators are loaded only from the opengrade_server package: API startup clears the registry and runs bootstrap_evaluators(), and there is no plugin discovery (e.g. Python entry points) yet. Until there is, an evaluator that should not live upstream belongs in the SDK as an @opengrade.metric.