You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The eval slice previously shipped one toy case (echo-hello) and a
disabled-by-default nightly. A reader expecting an LLM-eval story
found the infrastructure without conviction.
Adds four worked-pattern cases that exercise the existing three
tolerance modes against a real Azure OpenAI deployment. These are
not benchmarks — they demonstrate what an eval case *looks like* for
the four LLM-eval patterns you most often need to write:
- factual-http-200 exact_match format-constrained recall
- numeric-seconds-per-day numeric_close numeric reasoning + tolerance
- definitional-fastapi-depends semantic_similar free-form judge-scored prose
- structured-json-status exact_match structured-output adherence
When the template is forked for a real project, replace these four
with cases that exercise the project's own prompts; the patterns
transfer regardless of what product is bolted on.
Provider choice — Azure OpenAI via the openai SDK with AzureOpenAI
client — is intentionally distinct from the rest of the harness
(which uses Claude via Claude Code). Demonstrates that the LLMClient
Protocol in src/eval/judge.py does its job: the eval core never
imports openai, vendor lock-in lives only in the adapter.
Changes:
- src/eval/adapters/azure_openai.py — implements LLMClient via the
openai.AzureOpenAI SDK. Reads endpoint/key/deployment/api-version
from env. Lazy-imports the SDK so the module is importable without
the optional extra installed; the adapter raises a clear
AzureOpenAIConfigError if the env or SDK is missing.
- eval/golden_patterns.json — the four cases with notes explaining
which pattern each demonstrates.
- eval/test_golden_patterns.py — separate test file gated on the
Azure env vars via pytestmark. Skipped on a stock checkout, so
`uv run pytest eval/` always exits 0. The toy test_golden_qa.py
keeps running as before.
- pyproject.toml — new optional [project.optional-dependencies] eval
extra (just `openai>=1.40.0`), mypy override for openai.* matching
the existing opentelemetry.* pattern, and a 0.2.10 -> 0.2.11
self-version bump.
- .github/workflows/eval-nightly.yml — env vars renamed from the
placeholder LLM_* set to AZURE_OPENAI_*. Header comment updated
with the Azure setup recipe. uv sync now passes --extra eval.
- docs/EVAL_HARNESS.md — new "Worked patterns" section with the
table mapping case -> tolerance -> pattern, the local setup
recipe, and a "Swapping providers" note documenting the
Protocol-based extension path.
Local gates: mypy --strict clean on 42 source files (was 31), ruff
clean, ruff format clean, import-linter both contracts kept, 192
unit tests pass, eval/ runs 1 passed + 4 skipped without LLM env.
Closes#94
├── golden_qa.json # Toy smoke case — runs without LLM credentials
19
+
├── test_golden_qa.py # Parametrised runner for the toy case
20
+
├── golden_patterns.json # Four worked-pattern cases — require Azure OpenAI
21
+
└── test_golden_patterns.py # Skipped unless AZURE_OPENAI_* env vars are set
18
22
```
19
23
20
24
## How it works
@@ -86,11 +90,43 @@ python -m src.eval # CLI runner — prints the markdown report
86
90
87
91
The pytest invocation is marked `@pytest.mark.eval`, so the default `pytest tests/` skips it.
88
92
93
+
## Worked patterns (Azure OpenAI)
94
+
95
+
The four cases in `eval/golden_patterns.json` are *not* benchmarks. They exist to demonstrate what an eval case looks like against each of the runner's tolerance modes; together they cover the four LLM-eval patterns you most often need to write:
96
+
97
+
| Case ID | Tolerance | Pattern demonstrated |
98
+
|---|---|---|
99
+
|`factual-http-200`|`exact_match`| Format-constrained factual recall. The prompt forces a single canonical token; if the model wraps the answer in prose, the case fails loudly. |
100
+
|`numeric-seconds-per-day`|`numeric_close`| Numeric reasoning with extraction tolerance. The runner pulls the first number from each side and compares within 1 %, so `86,400` and `86400 seconds` both match. |
101
+
|`definitional-fastapi-depends`|`semantic_similar`| Free-form prose scored by an LLM judge at ≥ 0.8. Use for explanations and any case where wording can vary but the underlying claim is checkable. |
102
+
|`structured-json-status`|`exact_match`| Structured-output adherence. The prompt asks for raw JSON; markdown-fenced or prose-wrapped responses fail — which is the failure mode downstream parsers also hit. |
103
+
104
+
The cases all call a real Azure OpenAI deployment via the adapter at `src/eval/adapters/azure_openai.py`. When you fork the template for a real project, replace these four with cases that exercise your own product's prompts; the patterns transfer.
105
+
106
+
### Setup
107
+
108
+
```sh
109
+
uv sync --extra dev --extra eval# installs the openai SDK
export AZURE_OPENAI_DEPLOYMENT="gpt-4o-mini"# or whatever you deployed
114
+
export AZURE_OPENAI_API_VERSION="2024-10-21"# optional, this is the default
115
+
116
+
uv run pytest eval/test_golden_patterns.py -v
117
+
```
118
+
119
+
Without the env vars, `eval/test_golden_patterns.py` is skipped via `pytestmark` — `eval/test_golden_qa.py` still runs as a smoke check on the runner mechanics, so `uv run pytest eval/` always exits 0 on a fresh checkout.
120
+
121
+
### Swapping providers
122
+
123
+
`src/eval/judge.py` defines `LLMClient` as a `Protocol` — the eval core does not import `openai` anywhere. To target a different provider (Anthropic, vLLM, vanilla OpenAI), write a new adapter under `src/eval/adapters/` that implements `complete_json(*, model, prompt) -> str` and update the runner fixture in your test file. Nothing in `src/eval/` itself changes.
124
+
89
125
## Nightly opt-in
90
126
91
127
`.github/workflows/eval-nightly.yml` ships `workflow_dispatch`-only by default to avoid accidental LLM API spend. To turn on a real nightly:
92
128
93
-
1. Add the LLM secrets in repo settings: `LLM_API_KEY` (required), `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_MODEL` (optional, depending on adapter).
129
+
1. Add the Azure OpenAI secrets in repo settings: `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT`, and optionally `AZURE_OPENAI_API_VERSION`.
"question": "What HTTP status code means OK? Respond with only the number, no prose.",
5
+
"category": "factual-recall",
6
+
"expected_answer": "200",
7
+
"tolerance": "exact_match",
8
+
"difficulty": "easy",
9
+
"notes": "Pattern: factual recall with format-constrained output. exact_match works because the prompt forces a single canonical token. If the model adds prose (\"The status code is 200.\") this fails loudly — which is the point: format adherence is part of the assertion."
10
+
},
11
+
{
12
+
"id": "numeric-seconds-per-day",
13
+
"question": "How many seconds are in 24 hours? Respond with the integer only.",
14
+
"category": "numeric-reasoning",
15
+
"expected_answer": "86400",
16
+
"tolerance": "numeric_close",
17
+
"difficulty": "easy",
18
+
"notes": "Pattern: numeric extraction with 1% tolerance. The runner pulls the first number from each side and compares ratios, so '86,400', '86400 seconds', and '86400.0' all match. Use this tolerance for math, conversions, and any case where formatting around the number is uninteresting."
19
+
},
20
+
{
21
+
"id": "definitional-fastapi-depends",
22
+
"question": "In one sentence: what does FastAPI's Depends() do?",
23
+
"category": "definitional",
24
+
"expected_answer": "Depends declares a callable that FastAPI resolves at request time and injects the result into the parameter, enabling dependency injection for things like authentication, database sessions, or settings.",
25
+
"tolerance": "semantic_similar",
26
+
"difficulty": "medium",
27
+
"notes": "Pattern: free-form prose scored by LLM judge. semantic_similar passes at score >= 0.8 via the judge in src/eval/judge.py. Use this for definitions, explanations, and any case where wording can legitimately vary but the underlying claim is checkable."
28
+
},
29
+
{
30
+
"id": "structured-json-status",
31
+
"question": "Return exactly this JSON object and nothing else (no markdown fence, no prose, no trailing newline): {\"ok\": true, \"version\": 1}",
"notes": "Pattern: format adherence on structured output. Models commonly wrap JSON in ```json``` fences or add a preamble; exact_match after normalisation (lowercase + whitespace-collapse) accepts a clean response but rejects the fenced or prose-wrapped version. This is the failure mode you want to catch — downstream parsers break the same way."
0 commit comments