M18: Evaluation & Testing
You shipped a ReAct agent. It seems to work. But "seems to work" is not a quality standard. This module teaches you to measure, track, and defend the quality of your agent with a production-grade eval harness — no cloud eval services required.
Learning Objectives
- Understand why eval is non-negotiable and what happens when you skip it
- Apply three layers of testing: unit, integration, and LLM-as-judge
- Build a pytest-based eval harness for the entity resolution agent (Capstone C3)
- Configure DeepEval in local mode with Ollama as the evaluator model
- Set up Ragas for RAG pipeline evaluation with local sentence-transformers
- Design an LLM-as-judge prompt that avoids position and length bias
- Wire evals into GitHub Actions with fail-on-regression thresholds
Why Evaluation Matters
Imagine a bridge inspector who only checks bridges right after they are built — never after years of traffic, weather, or earthquake. The bridge looks fine on day one. You can drive across it. The inspector signs off. Nobody ever checks again.
Three years later, a crack that started small has propagated silently through the structure. A truck crosses and the bridge fails. The inspector had no system for detecting incremental degradation, so every change felt fine right up until catastrophic failure. This is exactly what happens to an LLM agent when you ship without evals.
Your agent is the bridge. Every prompt change, model version bump, or tool modification is a new load cycle. Without a testing harness, you have no idea whether the cumulative effect of 50 small changes has quietly eroded agent quality. Eval is not a nice-to-have — it is the structural inspection program that keeps your bridge from falling.
A True Production Story
A team ships an entity resolution agent to process 50,000 UCC filings per day. The initial demo is impressive. They update the system prompt twice to "improve tone." They bump from mistral to mistral-large. They add a new tool. At no point do they run any evals.
Six weeks later, a data quality audit reveals that the agent is now incorrectly merging entities that should remain separate — producing a false merge rate of 18%. That is 9,000 corrupted records per day. The team spends three weeks debugging. The root cause: the second system prompt change subtly shifted how the agent weights fuzzy match scores, but nobody noticed because there was no automated regression test.
Google's internal research found that ~40% of LLM pipeline changes caused regressions in at least one quality metric, even when the change was intended as an improvement. Without evals, you are flying blind: roughly half your "improvements" are secretly making something else worse. A minimal eval suite that runs in 5 minutes on every PR prevents weeks of downstream debugging.
Three Levels of Eval
An eval level describes how high up the agent stack you are testing. Unit tests check individual tools in isolation. Integration tests run the full agent loop against known inputs. LLM-as-judge evals assess subjective qualities — reasoning coherence, answer relevancy, faithfulness — that deterministic tests cannot measure. You need all three because each catches failures the others miss.
Level 1: Unit Tests — Tool Behavior
Unit tests check individual tool functions in complete isolation. fuzzy_match_score("Acme LLC", "ACME LLC") should return a score above 0.9. search_filings_by_name("nonexistent") should return an empty list, not raise an exception. These tests require zero LLM calls and run in milliseconds.
Level 2: Integration Tests — Agent Loop
Integration tests run the full ReAct agent end-to-end against a known input and verify that the output satisfies a deterministic assertion. "Given the query 'Is Acme Logistics LLC the same entity as ACME LOGISTICS L.L.C.?', the agent must call fuzzy_match_score at least once and produce a confidence score above 0.85." The LLM is called, but the pass/fail criterion is deterministic.
Level 3: LLM-as-Judge — Quality
The LLM-as-judgeA pattern where a second language model is used to evaluate the output quality of a primary model or agent. The judge model scores responses on dimensions like faithfulness (did the agent only use facts from context?), relevancy (did it answer the actual question?), and coherence (does its reasoning make sense?). pattern uses a second Mistral instance to evaluate output quality on dimensions that cannot be expressed as deterministic assertions: Is the reasoning coherent? Is the answer faithful to the retrieved context? Does the confidence score match the evidence presented? These tests are expensive — each requires an LLM call — so run them on a smaller golden set.
You now have a mental model for three test layers. Unit tests run on every commit in milliseconds. Integration tests run on every PR in minutes. LLM-as-judge tests run nightly or on major changes. Each layer has a different cost-to-confidence ratio.
Building an Eval Dataset
An eval dataset is a collection of (input, expected_output) pairs where the correct answer has been verified by a human expert or a trusted reference system. For an entity resolution agent, one example might be: input = "Are 'Acme Logistics LLC' and 'Acme Logistics Inc' the same company?", expected_output = a merged profile with confidence ≥ 0.9 and canonical_name = "Acme Logistics LLC". The "golden" in "golden test set" means these answers are treated as ground truth.
Schema for Entity Resolution Evals
Store your eval cases in a JSON file. Each case has an ID, the input query, the expected tool calls (ordered), and the expected output attributes. The tools list enables integration-test assertions without requiring exact output text matching.
# eval_dataset.json — one entry per golden case
# WHAT: Each case captures input, expected tool sequence, and pass criteria
# WHY: Separating test data from test logic lets domain experts update
# cases without touching Python
# GOTCHA: Keep expected_min_confidence realistic for your model tier —
# Mistral-7B scores ~0.05 lower than Mistral-large on average
EVAL_SCHEMA = {
"cases": [
{
"id": "ER-001",
"description": "Obvious match — same company, different punctuation",
"input": "Are 'Acme Logistics LLC' and 'ACME LOGISTICS, L.L.C.' the same entity?",
"expected_tools_called": [
"search_filings_by_name",
"fuzzy_match_score",
"get_business_registry_data",
"merge_entity_profile"
],
"expected_output": {
"decision": "merge",
"expected_min_confidence": 0.90,
"must_contain_keywords": ["canonical", "match"]
}
},
{
"id": "ER-002",
"description": "Ambiguous case — similar names, different businesses",
"input": "Is 'Apex Capital Partners LP' the same as 'Apex Capital LLC'?",
"expected_tools_called": [
"search_filings_by_name",
"fuzzy_match_score",
"get_filing_details"
],
"expected_output": {
"decision": "no_merge",
"expected_max_confidence": 0.70,
"must_contain_keywords": ["different", "separate"]
}
},
{
"id": "ER-003",
"description": "DBA (doing business as) alias detection",
"input": "Is 'TechBridge Solutions' the same entity as 'TechBridge Solutions dba CloudBridge'?",
"expected_tools_called": [
"search_filings_by_name",
"get_business_registry_data",
"merge_entity_profile"
],
"expected_output": {
"decision": "merge",
"expected_min_confidence": 0.85,
"must_contain_keywords": ["alias", "dba"]
}
}
]
}
# WHAT: Loader function with Ollama availability guard
# WHY: Evals should fail gracefully — a missing Ollama server should
# produce a clear error, not a cryptic connection timeout
# GOTCHA: Always check Ollama health before starting the eval run —
# a silent Ollama failure corrupts your entire results file
import json
import sys
import httpx
def load_eval_cases(path: str = "eval_dataset.json") -> list[dict]:
"""Load golden test cases with validation."""
try:
with open(path) as f:
data = json.load(f)
cases = data.get("cases", [])
if not cases:
raise ValueError("Eval dataset is empty — add cases before running")
print(f"Loaded {len(cases)} eval cases from {path}")
return cases
except FileNotFoundError:
print(f"ERROR: {path} not found. Run `python build_eval_dataset.py` first.")
sys.exit(1)
def check_ollama_health(base_url: str = "http://localhost:11434") -> bool:
"""Verify Ollama is running before spending time on evals."""
try:
resp = httpx.get(f"{base_url}/api/tags", timeout=3.0)
models = [m["name"] for m in resp.json().get("models", [])]
if "mistral" not in " ".join(models):
print("WARNING: 'mistral' model not found. Run: ollama pull mistral")
return False
return True
except (httpx.ConnectError, httpx.TimeoutException):
print("ERROR: Ollama is not running. Start it with: ollama serve")
return False
// evalDataset.js — golden test set loader
// WHAT: Load and validate eval cases from JSON, guard against missing Ollama
// WHY: Separating test data from test logic makes domain expert updates
// safe — no code change needed to add a new eval case
// GOTCHA: Use fetch() with a short timeout — don't let a cold Ollama
// server silently stall your entire CI run
import { readFileSync } from 'fs';
import fetch from 'node-fetch';
export function loadEvalCases(path = './eval_dataset.json') {
let data;
try {
data = JSON.parse(readFileSync(path, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`ERROR: ${path} not found. Run build_eval_dataset.js first.`);
process.exit(1);
}
throw err;
}
const cases = data.cases ?? [];
if (cases.length === 0) {
throw new Error('Eval dataset is empty — add cases before running evals');
}
console.log(`Loaded ${cases.length} eval cases from ${path}`);
return cases;
}
export async function checkOllamaHealth(baseUrl = 'http://localhost:11434') {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const resp = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal });
clearTimeout(timeout);
const { models = [] } = await resp.json();
const hasMistral = models.some(m => m.name.includes('mistral'));
if (!hasMistral) {
console.warn("WARNING: 'mistral' model not found. Run: ollama pull mistral");
return false;
}
return true;
} catch {
console.error('ERROR: Ollama is not running. Start it with: ollama serve');
return false;
}
}
You now have a structured eval dataset schema and a loader with a pre-flight Ollama health check. Every eval script will call check_ollama_health() before making any LLM calls — so failures are fast and clear, not silent and cryptic.
For a golden test set to be statistically meaningful: 30 cases minimum for integration tests (enough to detect a 10% regression at 80% power), 100+ cases for unit tests (cover all edge cases in each tool), and 20–30 cases for LLM-as-judge (each costs an LLM call, so keep it focused). Data sourcing: start with real inputs from your production logs, add adversarial cases manually (similar names that should NOT merge), and include boundary cases (confidence ~0.75 — the ambiguous zone).
Unit Testing Your Tools
Unit tests for agent tools are ordinary pytest tests. The tools in the entity resolution agent are pure Python functions — they receive dicts, return dicts, call no external services. This makes them ideal for unit testing: fast, deterministic, zero LLM cost.
# tests/test_tools_unit.py
# WHAT: Parametrized unit tests for all five entity resolution tools
# WHY: Parametrize lets you add new cases without new test functions —
# one failing case won't abort the rest
# GOTCHA: entity_tools.py returns None on not-found, not raises —
# test for None explicitly or you'll miss silent failures
import pytest
from entity_tools import (
search_filings_by_name,
fuzzy_match_score,
get_filing_details,
get_business_registry_data,
merge_entity_profile,
)
# --- fuzzy_match_score ---
@pytest.mark.parametrize("name_a, name_b, min_score, max_score", [
# obvious match
("Acme Logistics LLC", "ACME LOGISTICS, L.L.C.", 0.88, 1.0),
# DBA alias
("TechBridge Solutions", "TechBridge Solutions dba CloudBridge", 0.75, 1.0),
# clearly different
("Apex Capital Partners", "Riverdale Municipal Services", 0.0, 0.4),
# empty string edge case
("", "Acme LLC", 0.0, 0.1),
# same exact string
("Unified Data Corp", "Unified Data Corp", 1.0, 1.0),
])
def test_fuzzy_match_score(name_a, name_b, min_score, max_score):
result = fuzzy_match_score({"name_a": name_a, "name_b": name_b})
assert result is not None, "fuzzy_match_score returned None"
score = result["score"]
assert min_score <= score <= max_score, (
f"Expected score in [{min_score}, {max_score}] for "
f"'{name_a}' vs '{name_b}', got {score:.3f}"
)
# --- search_filings_by_name ---
@pytest.mark.parametrize("query, expected_min_results, expected_max_results", [
("Acme Logistics", 1, 20), # should find something
("xyzzy_no_match_ever", 0, 0), # should return empty list
("", 0, 0), # empty query — graceful empty return
])
def test_search_filings_by_name(query, expected_min_results, expected_max_results):
result = search_filings_by_name({"query": query, "limit": 20})
assert result is not None, "search_filings_by_name returned None"
assert isinstance(result.get("filings", []), list)
count = len(result.get("filings", []))
assert expected_min_results <= count <= expected_max_results, (
f"Query '{query}': expected {expected_min_results}–{expected_max_results} results, got {count}"
)
# --- get_filing_details ---
def test_get_filing_details_valid():
# First get a real filing ID from search
search_result = search_filings_by_name({"query": "Acme", "limit": 1})
filings = search_result.get("filings", [])
if not filings:
pytest.skip("No filings in test database — check entity_tools.py fixture data")
filing_id = filings[0]["filing_id"]
detail = get_filing_details({"filing_id": filing_id})
assert detail is not None
assert "entity_name" in detail
assert "state" in detail
def test_get_filing_details_invalid_id():
result = get_filing_details({"filing_id": "DOES_NOT_EXIST_99999"})
# GOTCHA: function should return None or {"error": ...}, NOT raise
assert result is None or "error" in result
# --- merge_entity_profile ---
def test_merge_entity_profile_produces_canonical_name():
result = merge_entity_profile({
"filing_ids": ["F001", "F002"],
"canonical_name": "Acme Logistics LLC",
"confidence": 0.94
})
assert result is not None
assert result.get("canonical_name") == "Acme Logistics LLC"
assert result.get("confidence") >= 0.90
def test_merge_entity_profile_rejects_low_confidence():
"""Merges below 0.7 should be flagged for human review."""
result = merge_entity_profile({
"filing_ids": ["F001", "F002"],
"canonical_name": "Ambiguous Corp",
"confidence": 0.55
})
# Either returns None, raises, or marks as needs_review
if result is not None:
assert result.get("needs_review") is True or result.get("confidence", 1) < 0.7
// tests/tools.unit.test.js (Vitest)
// WHAT: Unit tests for entity resolution tools
// WHY: Pure-function tools have no LLM dependency — test them fast
// GOTCHA: Tools return null on not-found — check for null explicitly
import { describe, test, expect } from 'vitest';
import {
searchFilingsByName,
fuzzyMatchScore,
getFilingDetails,
getBusinessRegistryData,
mergeEntityProfile,
} from '../src/entityTools.js';
// --- fuzzyMatchScore ---
describe('fuzzyMatchScore', () => {
const cases = [
['Acme Logistics LLC', 'ACME LOGISTICS, L.L.C.', 0.88, 1.0],
['TechBridge Solutions', 'TechBridge Solutions dba CloudBridge', 0.75, 1.0],
['Apex Capital Partners', 'Riverdale Municipal Services', 0.0, 0.4],
['', 'Acme LLC', 0.0, 0.1],
['Unified Data Corp', 'Unified Data Corp', 1.0, 1.0],
];
test.each(cases)(
'%s vs %s → score in [%f, %f]',
(nameA, nameB, minScore, maxScore) => {
const result = fuzzyMatchScore({ name_a: nameA, name_b: nameB });
expect(result).not.toBeNull();
expect(result.score).toBeGreaterThanOrEqual(minScore);
expect(result.score).toBeLessThanOrEqual(maxScore);
}
);
});
// --- searchFilingsByName ---
describe('searchFilingsByName', () => {
test('finds results for known name', () => {
const result = searchFilingsByName({ query: 'Acme Logistics', limit: 20 });
expect(result).not.toBeNull();
expect(result.filings.length).toBeGreaterThanOrEqual(1);
});
test('returns empty list for unknown name', () => {
const result = searchFilingsByName({ query: 'xyzzy_no_match_ever', limit: 20 });
expect(result.filings).toHaveLength(0);
});
});
// --- mergeEntityProfile ---
describe('mergeEntityProfile', () => {
test('produces canonical name on high confidence', () => {
const result = mergeEntityProfile({
filing_ids: ['F001', 'F002'],
canonical_name: 'Acme Logistics LLC',
confidence: 0.94,
});
expect(result?.canonical_name).toBe('Acme Logistics LLC');
expect(result?.confidence).toBeGreaterThanOrEqual(0.90);
});
test('flags low-confidence merges for review', () => {
const result = mergeEntityProfile({
filing_ids: ['F001', 'F002'],
canonical_name: 'Ambiguous Corp',
confidence: 0.55,
});
if (result !== null) {
expect(result.needs_review || result.confidence < 0.7).toBe(true);
}
});
});
Integration Testing the Agent Loop
# tests/test_agent_integration.py
# WHAT: Run the full entity resolution agent and assert on tool traces
# and output quality — no LLM output text matching, just structure
# WHY: Text matching is brittle. Assert on the *shape* of the output
# (tools called, confidence range, presence of canonical_name)
# so the test survives paraphrasing in Mistral's response
# GOTCHA: Set a per-test timeout — Ollama can hang on cold start.
# Always skip gracefully when Ollama is unavailable so the
# test suite doesn't block other CI jobs
import pytest
import time
from openai import OpenAI
from entity_agent import run_entity_resolution_agent # your Capstone C3 agent
from eval_utils import check_ollama_health, load_eval_cases
# --- Fixture: skip all integration tests if Ollama is down ---
@pytest.fixture(scope="session", autouse=True)
def require_ollama():
if not check_ollama_health():
pytest.skip("Ollama not running — skipping integration tests")
# WHAT: One parametrized test covers every case in the golden set
# WHY: Adding cases = editing JSON, not editing Python
@pytest.fixture(scope="session")
def eval_cases():
return load_eval_cases("eval_dataset.json")
@pytest.mark.parametrize("case", load_eval_cases("eval_dataset.json"),
ids=[c["id"] for c in load_eval_cases("eval_dataset.json")])
def test_agent_integration(case):
"""Run agent on one golden case and assert on output structure."""
start = time.time()
# WHAT: Run the full ReAct agent — this calls Ollama
# GOTCHA: Wrap in try/except so one bad Ollama response doesn't
# poison your entire pytest run
try:
result = run_entity_resolution_agent(
query=case["input"],
max_iterations=8, # prevent infinite loops in evals
timeout_seconds=30, # fail fast, don't hang CI
)
except Exception as e:
pytest.fail(f"Agent raised unexpected exception: {e}")
elapsed = time.time() - start
# WHAT: Assert tools were actually called (not hallucinated)
tools_called = result.get("tools_called", [])
expected_tools = case["expected_tools_called"]
for required_tool in expected_tools[:2]: # check first two are present
assert required_tool in tools_called, (
f"Case {case['id']}: expected tool '{required_tool}' "
f"but agent called: {tools_called}"
)
# WHAT: Assert output meets pass criteria
expected_out = case["expected_output"]
actual_confidence = result.get("confidence", 0)
actual_decision = result.get("decision", "")
if "expected_min_confidence" in expected_out:
assert actual_confidence >= expected_out["expected_min_confidence"], (
f"Case {case['id']}: confidence {actual_confidence:.3f} "
f"< threshold {expected_out['expected_min_confidence']}"
)
if "expected_max_confidence" in expected_out:
assert actual_confidence <= expected_out["expected_max_confidence"], (
f"Case {case['id']}: confidence {actual_confidence:.3f} "
f"> max {expected_out['expected_max_confidence']}"
)
if "decision" in expected_out:
assert actual_decision == expected_out["decision"], (
f"Case {case['id']}: expected decision '{expected_out['decision']}', "
f"got '{actual_decision}'"
)
# WHAT: Performance guard — flag slow cases even if they pass
if elapsed > 15:
pytest.warns(UserWarning,
match=f"Case {case['id']} took {elapsed:.1f}s — investigate")
// tests/agent.integration.test.js (Vitest)
// WHAT: Run the full entity resolution agent and assert on output shape
// WHY: Assert on structure, not text — survives LLM paraphrasing
// GOTCHA: Skip all tests if Ollama is unavailable to avoid blocking CI
import { describe, test, expect, beforeAll } from 'vitest';
import { runEntityResolutionAgent } from '../src/entityAgent.js';
import { checkOllamaHealth, loadEvalCases } from '../src/evalUtils.js';
const cases = loadEvalCases('./eval_dataset.json');
beforeAll(async () => {
const healthy = await checkOllamaHealth();
if (!healthy) {
console.warn('Ollama not running — integration tests will be skipped');
}
});
describe.each(cases)('Integration: $id — $description', (evalCase) => {
test('runs agent and meets output criteria', async () => {
const healthy = await checkOllamaHealth();
if (!healthy) return; // graceful skip
let result;
try {
result = await runEntityResolutionAgent({
query: evalCase.input,
maxIterations: 8,
timeoutMs: 30_000,
});
} catch (err) {
throw new Error(`Agent raised unexpected error: ${err.message}`);
}
const { toolsCalled = [], confidence = 0, decision = '' } = result;
const { expected_tools_called, expected_output: exp } = evalCase;
// Check first two required tools are present
for (const tool of expected_tools_called.slice(0, 2)) {
expect(toolsCalled).toContain(tool);
}
if (exp.expected_min_confidence !== undefined) {
expect(confidence).toBeGreaterThanOrEqual(exp.expected_min_confidence);
}
if (exp.expected_max_confidence !== undefined) {
expect(confidence).toBeLessThanOrEqual(exp.expected_max_confidence);
}
if (exp.decision) {
expect(decision).toBe(exp.decision);
}
}, 35_000); // vitest per-test timeout
});
The integration harness runs the real agent against real Mistral via Ollama. It captures the tool call trace and asserts on structure (which tools were called, in what order) and on the numeric output (confidence range, decision). Critically, it never asserts on exact text — so refactoring your system prompt doesn't break the tests.
DeepEval Framework (Local Mode)
DeepEval is an open-source LLM evaluation framework that provides metrics like HallucinationMetric, AnswerRelevancyMetric, and FaithfulnessMetric. By default it wants an OpenAI key to power its judge model. In this module, we configure it to use a local Ollama model as the judge instead, so the entire eval pipeline runs offline.
DeepEval's default judge is GPT-4. For the OS track, we replace it with a local Mistral instance via DeepEval's deepeval.use_local_model() hook (available in DeepEval ≥ 1.4) combined with an OllamaEmbeddingModel from langchain-community. This gives you full LLM-as-judge evaluation with zero API cost and no data leaving your machine. The trade-off: Mistral-7B is a weaker judge than GPT-4, so your score thresholds should be ~0.05 lower than cloud-based benchmarks.
# tests/test_deepeval_metrics.py
# WHAT: LLM quality metrics via DeepEval, using local Ollama as judge
# WHY: HallucinationMetric and AnswerRelevancyMetric catch quality
# regressions that deterministic tests cannot see
# GOTCHA: DeepEval >= 1.4 required for local model support.
# Install: pip install deepeval>=1.4 langchain-community
# Set DEEPEVAL_TELEMETRY_OPT_OUT=YES to prevent phone-home
import os
import pytest
from deepeval import evaluate
from deepeval.metrics import HallucinationMetric, AnswerRelevancyMetric
from deepeval.models import DeepEvalBaseLLM
from deepeval.test_case import LLMTestCase
from openai import OpenAI
# Disable telemetry to Confident AI cloud
os.environ["DEEPEVAL_TELEMETRY_OPT_OUT"] = "YES"
# WHAT: Custom judge model wrapper pointing at local Ollama
# WHY: DeepEval requires a DeepEvalBaseLLM subclass to use a
# non-OpenAI model as the evaluator
# GOTCHA: The judge's generate() method must return a plain string,
# not an OpenAI response object
class OllamaJudge(DeepEvalBaseLLM):
"""Wraps local Ollama as a DeepEval judge model."""
def __init__(self, model: str = "mistral", base_url: str = "http://localhost:11434/v1"):
self.model = model
self.client = OpenAI(base_url=base_url, api_key="ollama")
def load_model(self):
return self
def generate(self, prompt: str) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0, # deterministic judge
max_tokens=512,
)
return resp.choices[0].message.content or ""
except Exception as e:
return f"JUDGE_ERROR: {e}"
async def a_generate(self, prompt: str) -> str:
return self.generate(prompt)
def get_model_name(self) -> str:
return f"ollama/{self.model}"
# WHAT: Instantiate local judge — shared across all test cases
judge = OllamaJudge(model="mistral")
# WHAT: Hallucination metric — did the agent claim facts not in context?
# GOTCHA: context must be a LIST of strings; passing a single string
# silently disables the metric and always returns 1.0
@pytest.fixture(scope="session")
def ollama_guard():
"""Skip all deepeval tests if Ollama is not running."""
from eval_utils import check_ollama_health
if not check_ollama_health():
pytest.skip("Ollama not running")
@pytest.mark.parametrize("case_id, input_query, actual_output, context", [
(
"HALLUC-001",
"Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?",
"Based on the UCC filing search, both names refer to the same entity. "
"The fuzzy match score is 0.94 and the SOS registry confirms they share "
"the same EIN. Confidence: 0.94. Decision: merge.",
[
"Filing F001: Acme Logistics LLC, Delaware, EIN 12-3456789",
"Filing F002: ACME LOGISTICS L.L.C., New York, EIN 12-3456789",
"Fuzzy match score: 0.94",
],
),
(
"HALLUC-002",
"Is Apex Capital Partners LP the same as Apex Capital LLC?",
"These appear to be different entities. Apex Capital Partners LP is a "
"Delaware limited partnership. Apex Capital LLC is registered in Texas. "
"No shared principals found. Confidence: 0.32. Decision: no_merge.",
[
"Filing F003: Apex Capital Partners LP, Delaware, LP registration",
"Filing F004: Apex Capital LLC, Texas, LLC registration",
"No shared officers or EIN found",
],
),
])
def test_hallucination_metric(ollama_guard, case_id, input_query, actual_output, context):
"""Agent output must not hallucinate facts beyond what's in context."""
test_case = LLMTestCase(
input=input_query,
actual_output=actual_output,
context=context,
)
metric = HallucinationMetric(
model=judge,
threshold=0.5, # score > 0.5 means too much hallucination — fail
)
metric.measure(test_case)
assert metric.score <= 0.5, (
f"Case {case_id}: Hallucination score {metric.score:.3f} exceeds threshold 0.5. "
f"Reason: {metric.reason}"
)
@pytest.mark.parametrize("case_id, input_query, actual_output", [
(
"REL-001",
"What is the confidence score for merging Acme Logistics LLC and ACME LOGISTICS L.L.C.?",
"The confidence score is 0.94, based on a fuzzy match score of 0.94 "
"and matching EIN numbers confirmed by the SOS registry.",
),
(
"REL-002",
"Should Apex Capital Partners LP be merged with Apex Capital LLC?",
"No. These are separate entities registered in different states with "
"no shared principals or EIN. Confidence: 0.32.",
),
])
def test_answer_relevancy_metric(ollama_guard, case_id, input_query, actual_output):
"""Agent answer must be relevant to the question asked."""
test_case = LLMTestCase(
input=input_query,
actual_output=actual_output,
)
metric = AnswerRelevancyMetric(
model=judge,
threshold=0.7, # score >= 0.7 required to pass
)
metric.measure(test_case)
assert metric.score >= 0.7, (
f"Case {case_id}: Relevancy score {metric.score:.3f} below threshold 0.7. "
f"Reason: {metric.reason}"
)
// DeepEval is a Python-only framework (as of 2025).
// For Node.js projects, use the LLM-as-judge pattern directly
// (see the #llm-judge section below) with a local Ollama call.
//
// WHAT: Node.js equivalent using direct Ollama judge call
// WHY: No deepeval JS port exists — implement the scoring logic yourself
// GOTCHA: Keep your judge prompt identical to deepeval's Hallucination
// prompt so metrics stay comparable across languages
import OpenAI from 'openai';
const judge = new OpenAI({
baseURL: 'http://localhost:11434/v1',
apiKey: 'ollama',
});
async function measureHallucination({ input, actualOutput, context }) {
const contextStr = context.join('\n');
const prompt = `You are an impartial judge evaluating whether an AI assistant's
output contains hallucinations — statements not supported by the given context.
CONTEXT:
${contextStr}
QUESTION: ${input}
ANSWER: ${actualOutput}
Score from 0.0 (completely faithful) to 1.0 (heavily hallucinated).
Respond ONLY with a JSON object: {"score": 0.12, "reason": "brief explanation"}`;
try {
const resp = await judge.chat.completions.create({
model: 'mistral',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 256,
});
const raw = resp.choices[0].message.content ?? '{}';
const { score = 0, reason = '' } = JSON.parse(raw);
return { score, reason };
} catch (err) {
console.error('Judge error:', err.message);
return { score: 0, reason: 'judge_error' };
}
}
// Example test runner
const testCases = [
{
input: 'Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?',
actualOutput: 'Yes — confidence 0.94, confirmed by EIN match and SOS registry.',
context: [
'Filing F001: Acme Logistics LLC, Delaware, EIN 12-3456789',
'Filing F002: ACME LOGISTICS L.L.C., New York, EIN 12-3456789',
],
maxHallucination: 0.5,
},
];
(async () => {
for (const tc of testCases) {
const { score, reason } = await measureHallucination(tc);
const pass = score <= tc.maxHallucination;
console.log(`[${pass ? 'PASS' : 'FAIL'}] Hallucination: ${score.toFixed(2)} — ${reason}`);
if (!pass) process.exitCode = 1;
}
})();
You wrote two DeepEval metrics using a local OllamaJudge class. HallucinationMetric checks whether the agent invented facts beyond what the retrieved context contains. AnswerRelevancyMetric checks whether the agent actually answered the question. Both use Mistral as the judge model — zero API cost, offline evaluation.
Ragas for RAG Pipelines
Ragas is a framework specifically for evaluating retrieval-augmented generationA pattern where the model retrieves relevant documents from a knowledge base before generating an answer, so its response is grounded in actual evidence rather than just parametric memory. pipelines. Its three core metrics: context precision (were the retrieved chunks actually useful?), faithfulness (does the answer only use facts from the chunks?), and answer relevancy (does the answer address the actual question?). For the entity resolution agent, "context" is the set of filing records retrieved by search_filings_by_name.
Ragas defaults to OpenAI for both the LLM judge and embeddings. To run offline: wrap Ollama via langchain-community.llms.Ollama as the LLM, and use sentence-transformers (runs on CPU) for embeddings. Install: pip install ragas langchain-community sentence-transformers. No API keys needed.
# tests/test_ragas_rag.py
# WHAT: Ragas evaluation for the retrieval pipeline in entity resolution
# WHY: The agent's quality depends heavily on what it retrieved —
# a high-confidence merge based on bad retrieval is a dangerous failure
# GOTCHA: ragas>=0.1.6 required for local LLM support.
# sentence-transformers runs on CPU — first run downloads ~80MB model
import pytest
from datasets import Dataset
# WHAT: Wire Ragas to use Ollama (LLM) + sentence-transformers (embeddings)
# GOTCHA: Import order matters — set the model BEFORE importing metrics
# otherwise Ragas silently falls back to OpenAI and raises an auth error
from langchain_community.llms import Ollama
from langchain_community.embeddings import HuggingFaceEmbeddings
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from ragas import evaluate
# WHAT: Local LLM wrapper — uses Ollama Mistral as the judge
ollama_llm = LangchainLLMWrapper(
Ollama(model="mistral", base_url="http://localhost:11434", temperature=0)
)
# WHAT: Local embedding model — runs on CPU, no network call
# WHY: Ragas uses embeddings to compute semantic similarity for
# answer_relevancy. sentence-transformers/all-MiniLM-L6-v2 is
# a 80MB model that runs fast on CPU.
local_embeddings = LangchainEmbeddingsWrapper(
HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
)
# WHAT: Configure all metrics to use local models
# GOTCHA: Must set both llm and embeddings on each metric —
# they don't inherit from a global config
for metric in [faithfulness, answer_relevancy, context_precision]:
metric.llm = ollama_llm
metric.embeddings = local_embeddings
# WHAT: Build a Ragas Dataset from agent run results
# Each row = one (question, retrieved_contexts, answer, ground_truth) tuple
def build_ragas_dataset(agent_results: list[dict]) -> Dataset:
"""
Convert agent run output into Ragas evaluation format.
agent_results[i] = {
"question": str,
"contexts": list[str], # retrieved filing chunks
"answer": str, # agent final answer
"ground_truth": str, # from golden test set
}
"""
return Dataset.from_list(agent_results)
# WHAT: Ragas evaluation fixture — builds dataset from golden cases
@pytest.fixture(scope="module")
def ragas_results():
"""Run agent on RAG-specific cases and collect retrieved contexts."""
from eval_utils import check_ollama_health
if not check_ollama_health():
pytest.skip("Ollama not running")
# These are hand-crafted for the RAG eval — each includes the
# contexts the agent actually retrieved during the run
return [
{
"question": "Is Acme Logistics LLC the same entity as ACME LOGISTICS L.L.C.?",
"contexts": [
"Filing F001: Acme Logistics LLC, State: Delaware, EIN: 12-3456789, Status: Active",
"Filing F002: ACME LOGISTICS, L.L.C., State: New York, EIN: 12-3456789, Status: Active",
"Fuzzy match score (token_sort_ratio): 0.96",
],
"answer": (
"Both filings share EIN 12-3456789 and a fuzzy match score of 0.96. "
"The SOS registry confirms these are the same entity under Delaware registration. "
"Decision: MERGE. Canonical name: Acme Logistics LLC. Confidence: 0.94."
),
"ground_truth": "Yes, these are the same entity. Confidence: 0.94.",
},
{
"question": "Should Apex Capital Partners LP be merged with Apex Capital LLC?",
"contexts": [
"Filing F003: Apex Capital Partners LP, State: Delaware, Type: Limited Partnership",
"Filing F004: Apex Capital LLC, State: Texas, Type: LLC",
"No shared principals, no matching EIN",
],
"answer": (
"Apex Capital Partners LP and Apex Capital LLC are registered in different states "
"with different entity types and no shared officers or EIN. "
"Decision: NO MERGE. Confidence: 0.32."
),
"ground_truth": "No — these are separate entities. Confidence: 0.32.",
},
]
def test_ragas_faithfulness(ragas_results):
"""Agent answers must only use facts present in retrieved context."""
dataset = build_ragas_dataset(ragas_results)
results = evaluate(
dataset=dataset,
metrics=[faithfulness],
)
score = results["faithfulness"]
assert score >= 0.70, (
f"Faithfulness {score:.3f} below threshold 0.70 — "
"agent may be hallucinating beyond retrieved context"
)
def test_ragas_answer_relevancy(ragas_results):
"""Agent answers must address the actual question asked."""
dataset = build_ragas_dataset(ragas_results)
results = evaluate(
dataset=dataset,
metrics=[answer_relevancy],
)
score = results["answer_relevancy"]
assert score >= 0.75, (
f"Answer relevancy {score:.3f} below threshold 0.75 — "
"agent may be answering a different question than asked"
)
def test_ragas_context_precision(ragas_results):
"""Retrieved context must be relevant to the question — no noisy retrieval."""
dataset = build_ragas_dataset(ragas_results)
results = evaluate(
dataset=dataset,
metrics=[context_precision],
)
score = results["context_precision"]
assert score >= 0.65, (
f"Context precision {score:.3f} below threshold 0.65 — "
"search_filings_by_name may be returning irrelevant results"
)
// WHAT: Ragas is Python-only. For Node.js, implement context_precision
// and faithfulness manually using your local Ollama judge.
// WHY: The scoring logic is straightforward to reimplement —
// it's just structured LLM prompts with JSON output
// GOTCHA: Align your thresholds with the Python Ragas defaults
// (faithfulness >= 0.70, relevancy >= 0.75)
import OpenAI from 'openai';
const judge = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
async function measureFaithfulness({ question, contexts, answer }) {
const ctx = contexts.join('\n');
const prompt = `You are evaluating faithfulness of an AI answer.
CONTEXT (retrieved facts):
${ctx}
ANSWER: ${answer}
QUESTION: ${question}
Does the answer contain ONLY facts that are directly supported by the context above?
Respond with JSON: {"score": 0.0-1.0, "reason": "brief explanation"}
1.0 = fully faithful, 0.0 = completely hallucinated.`;
const resp = await judge.chat.completions.create({
model: 'mistral',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 256,
});
return JSON.parse(resp.choices[0].message.content ?? '{}');
}
async function measureContextPrecision({ question, contexts }) {
const ctx = contexts.map((c, i) => `[${i+1}] ${c}`).join('\n');
const prompt = `You are evaluating context precision.
QUESTION: ${question}
RETRIEVED CONTEXTS:
${ctx}
For each context chunk, score 1 if it is useful for answering the question, 0 if not.
Respond with JSON: {"scores": [1,1,0,...], "precision": 0.0-1.0}`;
const resp = await judge.chat.completions.create({
model: 'mistral',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 256,
});
return JSON.parse(resp.choices[0].message.content ?? '{}');
}
// Run both metrics
(async () => {
const tc = {
question: 'Is Acme Logistics LLC the same entity as ACME LOGISTICS L.L.C.?',
contexts: [
'Filing F001: Acme Logistics LLC, Delaware, EIN 12-3456789',
'Filing F002: ACME LOGISTICS L.L.C., New York, EIN 12-3456789',
],
answer: 'Both share EIN 12-3456789. Decision: MERGE. Confidence: 0.94.',
};
const faith = await measureFaithfulness(tc);
const ctx = await measureContextPrecision(tc);
console.log('Faithfulness:', faith.score.toFixed(2), '—', faith.reason);
console.log('Context Precision:', ctx.precision.toFixed(2));
if (faith.score < 0.70) { console.error('FAIL: faithfulness below 0.70'); process.exitCode = 1; }
if (ctx.precision < 0.65) { console.error('FAIL: context precision below 0.65'); process.exitCode = 1; }
})();
You configured Ragas to evaluate three dimensions of your retrieval pipeline using a local Ollama LLM and CPU-based sentence-transformers. Context precisionRagas metric measuring what fraction of retrieved context chunks were actually useful for answering the question. Low precision = noisy retrieval = wasted tokens and confused reasoning. catches noisy retrieval. FaithfulnessRagas metric measuring whether every claim in the answer is directly supported by the retrieved context. Low faithfulness = hallucination risk. catches hallucination. Answer relevancyRagas metric measuring whether the answer addresses the actual question. Low relevancy = the agent went off-topic or answered a different question. catches topic drift. All three run at zero cost.
LLM-as-Judge Pattern
Judge Prompt Design
The quality of your judge is entirely determined by your prompt. Bad judge prompts produce biased scores that don't correlate with real quality. Four rules for good judge prompts:
- Anchor every score level. A dimension described in one line — "faithfulness (0–1)" — leaves the judge to invent what 0.7 means, and it will invent something different on the next run. Give each level its own sentence of criteria (anchored rubricA scoring guide where every point on the scale has its own explicit, observable criteria — not just a label. Anchors are what make two runs, or two graders, produce the same number for the same answer.), restrict the judge to those values, and score variance between identical runs drops sharply. This matters more on a local 7B judge than on a frontier model: the smaller the judge, the more of the scale it invents when you leave it blank.
- Define criteria explicitly. "Score the reasoning quality" is ambiguous. "Score whether the agent used at least two independent pieces of evidence before concluding" is measurable.
- Avoid position bias. If you present multiple answers for comparison, randomize order. Judges favor whichever answer appears first.
- Avoid length bias. LLMs rate longer answers higher even when they say nothing useful. Add an explicit criterion: "Do not penalize or reward answer length."
One consequence of anchoring is worth calling out, because it is easy to get backwards: the judge returns the four dimension scores and nothing else. The harness snaps each one to its nearest anchor and computes overall as their mean in Python. Ask the model for an aggregate and you have handed the one number your thresholds depend on back to free-form judgement — which is exactly what the anchors were for — and a 7B model asked to average four decimals gets it wrong often enough to push cases across the pass line.
# tests/llm_judge.py
# WHAT: LLM-as-judge for entity resolution agent quality
# WHY: Some quality dimensions (reasoning coherence, evidence use)
# can only be assessed by another LLM, not by code
# GOTCHA: Use temperature=0 for the judge to get deterministic scores.
# Still expect ~0.05 variance across identical runs with Mistral-7B —
# run each case 3x and average if you need high precision
from openai import OpenAI
import json
import re
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# WHAT: Judge prompt template — explicit criteria, bias mitigations
# GOTCHA: The JSON parsing block must handle both ``` fences and raw JSON —
# Mistral-7B sometimes wraps its output in markdown code blocks
JUDGE_PROMPT_TEMPLATE = """You are an impartial evaluator assessing the quality of an \
AI entity resolution agent's response. Score each dimension from 0.0 to 1.0.
QUESTION: {question}
AGENT RESPONSE: {answer}
RETRIEVED CONTEXT (what the agent had access to):
{context}
EVALUATION CRITERIA — four dimensions, each scored on the anchored
scale below. Pick the anchor whose description matches what you actually see.
If an answer sits between two anchors, choose the LOWER one.
1. reasoning_quality — does it reason FROM evidence TO the conclusion?
1.00 = every claim is tied to a named filing, score, or registry field, in order
0.75 = reasons from evidence, but one step is asserted without a source
0.50 = conclusion and evidence both present, no chain connecting them
0.25 = evidence appears only after the conclusion, as decoration
0.00 = a conclusion with no reasoning at all
2. faithfulness — does the answer use ONLY facts present in the retrieved context?
1.00 = every fact is verbatim or a direct paraphrase of the context
0.75 = all facts grounded, but one is stated more strongly than context supports
0.50 = one unsupported detail that does not change the decision
0.25 = one unsupported fact that materially supports the decision
0.00 = invents a filing, address, officer, EIN, or date not in the context
3. evidence_sufficiency — independent evidence behind a high-confidence call.
Two fields from the SAME filing are one piece of evidence, not two.
A fuzzy name score derived from the two names is not independent of them.
1.00 = three or more independent sources, and it says why each is independent
0.75 = exactly two independent sources
0.50 = two sources that are not actually independent (same filing or submitter)
0.25 = a single source, and the answer admits the evidence is thin
0.00 = a single source presented as sufficient
4. confidence_calibration — is the stated confidence consistent with the evidence?
1.00 = confidence matches evidence strength within about 0.05
0.75 = about 0.10 too LOW for the evidence (under-confident)
0.50 = about 0.10 too HIGH for the evidence (over-confident)
0.25 = a strong claim on weak evidence, or a refusal despite conclusive evidence
0.00 = confidence and evidence flatly contradict (0.99 on one weak clue)
IMPORTANT:
- Use ONLY the five anchor values: 0.00, 0.25, 0.50, 0.75, 1.00.
- Do NOT favor longer or shorter answers — length does not equal quality.
- Score against the anchors, not against whether you agree with the decision.
- Do NOT return an overall score. The harness computes it from your four.
- Return ONLY valid JSON — no markdown, no prose outside the JSON.
Return:
{{
"reasoning_quality": 0.0,
"faithfulness": 0.0,
"evidence_sufficiency": 0.0,
"confidence_calibration": 0.0,
"explanation": "one sentence naming the anchor you chose for the lowest dimension"
}}"""
# WHAT: The four anchored dimensions, and the aggregate computed from them
# WHY: Anchors only mean something if the aggregate is arithmetic. Letting the
# model free-hand `overall` reintroduces the vibe-scoring the anchors
# removed — and a 7B judge averages four decimals badly
DIMENSIONS = ("reasoning_quality", "faithfulness",
"evidence_sufficiency", "confidence_calibration")
ANCHOR_VALUES = (0.0, 0.25, 0.5, 0.75, 1.0)
def finalize(scores: dict) -> dict:
"""Snap each dimension to its nearest anchor, then compute `overall` in code."""
for dim in DIMENSIONS:
raw = float(scores.get(dim, 0) or 0)
# GOTCHA: judges drift off the anchors (0.8, 0.65). Snap, do not trust
scores[dim] = min(ANCHOR_VALUES, key=lambda a: abs(a - raw))
scores["overall"] = round(sum(scores[d] for d in DIMENSIONS) / len(DIMENSIONS), 3)
return scores
def run_judge(question: str, answer: str, context: list[str],
model: str = "mistral") -> dict:
"""
Score one (question, answer, context) triple with the local judge.
Returns a score dict. On judge error, returns all zeros with an error key.
"""
ctx_str = "\n".join(f"- {c}" for c in context)
prompt = JUDGE_PROMPT_TEMPLATE.format(
question=question, answer=answer, context=ctx_str
)
try:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=512,
)
raw = resp.choices[0].message.content or ""
# WHAT: Strip markdown fences if present
# GOTCHA: Mistral sometimes wraps JSON in ```json ... ``` blocks
raw = re.sub(r"^```(?:json)?\s*", "", raw.strip())
raw = re.sub(r"\s*```$", "", raw)
scores = finalize(json.loads(raw))
return scores
except json.JSONDecodeError as e:
return {
"reasoning_quality": 0, "faithfulness": 0,
"evidence_sufficiency": 0, "confidence_calibration": 0,
"overall": 0, "explanation": f"JSON parse error: {e}",
"judge_error": True,
}
except Exception as e:
return {
"reasoning_quality": 0, "faithfulness": 0,
"evidence_sufficiency": 0, "confidence_calibration": 0,
"overall": 0, "explanation": str(e),
"judge_error": True,
}
def evaluate_with_judge(test_cases: list[dict],
threshold: float = 0.70) -> dict:
"""
Run judge on all cases. Returns summary with pass/fail per case.
test_cases[i] = {"question", "answer", "context", "id"}
"""
results = []
for tc in test_cases:
scores = run_judge(tc["question"], tc["answer"], tc["context"])
passed = (
not scores.get("judge_error")
and scores.get("overall", 0) >= threshold
)
results.append({
"id": tc["id"],
"scores": scores,
"passed": passed,
"overall": scores.get("overall", 0),
})
status = "PASS" if passed else "FAIL"
print(f" [{status}] {tc['id']}: overall={scores.get('overall', 0):.2f} — "
f"{scores.get('explanation', '')}")
passed_count = sum(1 for r in results if r["passed"])
pass_rate = passed_count / len(results) if results else 0
return {
"results": results,
"pass_rate": pass_rate,
"passed": passed_count,
"total": len(results),
}
// src/llmJudge.js
// WHAT: LLM-as-judge for entity resolution quality
// WHY: Reasoning coherence and evidence use cannot be tested deterministically
// GOTCHA: Strip markdown fences from Mistral output before JSON.parse
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const JUDGE_PROMPT = (question, answer, context) => `\
You are an impartial evaluator assessing an AI entity resolution agent.
QUESTION: ${question}
AGENT RESPONSE: ${answer}
RETRIEVED CONTEXT:
${context.map(c => `- ${c}`).join('\n')}
EVALUATION CRITERIA — four dimensions, each scored on the anchored
scale below. Pick the anchor whose description matches what you actually see.
If an answer sits between two anchors, choose the LOWER one.
1. reasoning_quality — does it reason FROM evidence TO the conclusion?
1.00 = every claim is tied to a named filing, score, or registry field, in order
0.75 = reasons from evidence, but one step is asserted without a source
0.50 = conclusion and evidence both present, no chain connecting them
0.25 = evidence appears only after the conclusion, as decoration
0.00 = a conclusion with no reasoning at all
2. faithfulness — does the answer use ONLY facts present in the retrieved context?
1.00 = every fact is verbatim or a direct paraphrase of the context
0.75 = all facts grounded, but one is stated more strongly than context supports
0.50 = one unsupported detail that does not change the decision
0.25 = one unsupported fact that materially supports the decision
0.00 = invents a filing, address, officer, EIN, or date not in the context
3. evidence_sufficiency — independent evidence behind a high-confidence call.
Two fields from the SAME filing are one piece of evidence, not two.
A fuzzy name score derived from the two names is not independent of them.
1.00 = three or more independent sources, and it says why each is independent
0.75 = exactly two independent sources
0.50 = two sources that are not actually independent (same filing or submitter)
0.25 = a single source, and the answer admits the evidence is thin
0.00 = a single source presented as sufficient
4. confidence_calibration — is the stated confidence consistent with the evidence?
1.00 = confidence matches evidence strength within about 0.05
0.75 = about 0.10 too LOW for the evidence (under-confident)
0.50 = about 0.10 too HIGH for the evidence (over-confident)
0.25 = a strong claim on weak evidence, or a refusal despite conclusive evidence
0.00 = confidence and evidence flatly contradict (0.99 on one weak clue)
IMPORTANT:
- Use ONLY the five anchor values: 0.00, 0.25, 0.50, 0.75, 1.00.
- Do NOT favor longer or shorter answers — length does not equal quality.
- Score against the anchors, not against whether you agree with the decision.
- Do NOT return an overall score. The harness computes it from your four.
- Return ONLY valid JSON — no markdown, no prose outside the JSON.
Return:
{
"reasoning_quality": 0.0,
"faithfulness": 0.0,
"evidence_sufficiency": 0.0,
"confidence_calibration": 0.0,
"explanation": "one sentence naming the anchor you chose for the lowest dimension"
}`;
// WHAT: The four anchored dimensions, and the aggregate computed from them
// WHY: Anchors only mean something if the aggregate is arithmetic — a 7B judge
// asked to average its own four decimals gets it wrong often enough to
// move cases across the pass threshold
const DIMENSIONS = ['reasoning_quality', 'faithfulness',
'evidence_sufficiency', 'confidence_calibration'];
const ANCHOR_VALUES = [0.0, 0.25, 0.5, 0.75, 1.0];
// Snap each dimension to its nearest anchor, then compute `overall` in code.
function finalize(scores) {
for (const dim of DIMENSIONS) {
const raw = Number(scores[dim] ?? 0);
// GOTCHA: judges drift off the anchors (0.8, 0.65). Snap, do not trust
scores[dim] = ANCHOR_VALUES.reduce(
(best, a) => (Math.abs(a - raw) < Math.abs(best - raw) ? a : best));
}
const sum = DIMENSIONS.reduce((acc, d) => acc + scores[d], 0);
scores.overall = Math.round((sum / DIMENSIONS.length) * 1000) / 1000;
return scores;
}
export async function runJudge({ question, answer, context, model = 'mistral' }) {
const prompt = JUDGE_PROMPT(question, answer, context);
try {
const resp = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 512,
});
let raw = resp.choices[0].message.content ?? '';
// Strip markdown fences
raw = raw.replace(/^```(?:json)?\s*/m, '').replace(/\s*```$/m, '').trim();
return finalize(JSON.parse(raw));
} catch (err) {
return {
reasoning_quality: 0, faithfulness: 0,
evidence_sufficiency: 0, confidence_calibration: 0,
overall: 0, explanation: `judge_error: ${err.message}`,
judge_error: true,
};
}
}
export async function evaluateWithJudge(testCases, threshold = 0.70) {
const results = [];
for (const tc of testCases) {
const scores = await runJudge(tc);
const passed = !scores.judge_error && scores.overall >= threshold;
results.push({ id: tc.id, scores, passed, overall: scores.overall });
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${tc.id}: overall=${scores.overall.toFixed(2)} — ${scores.explanation}`);
}
const passRate = results.filter(r => r.passed).length / results.length;
return { results, passRate, passed: results.filter(r => r.passed).length, total: results.length };
}
You built a production-grade LLM judge with four anchored scoring dimensions (five explicit levels each, so two runs agree on what 0.75 means), an aggregate the harness computes rather than the model, JSON output parsing that handles Mistral's markdown fences, and bias mitigations written into the prompt itself. The judge returns per-dimension scores, so you can diagnose regressions: if reasoning_quality drops while faithfulness holds, that points to a different root cause than if faithfulness drops alone.
Walk it, step by step
Three judge calls under one rubric. Two verdicts you would defend and one you should not \u2014 the harness scoring your agent is itself a model with a bias, and this run shows it.
📂 Get the files: labs-opensource/M18-evaluation-testing on GitHub — or clone the course once: git clone https://github.com/varasrinivas/agenticai-course.git
Validating the Judge
A machine shop buys a digital caliper and starts using it to accept or reject parts. Every part that measures within tolerance ships. Every part outside it goes in the scrap bin. The caliper reads to three decimal places, the numbers look authoritative, and nobody questions them for a year.
Then a customer returns a batch. The caliper had been reading 0.4mm heavy since the day it arrived — it was never checked against a gauge block. Every "in tolerance" decision for twelve months was wrong in the same direction, and because the readings were consistent, nothing ever looked suspicious. The shop did not ship bad parts because it measured carelessly. It shipped bad parts because it never measured the measuring instrument.
Your judge is the caliper, and your eval suite is the accept/reject line. A judge that is silently generous, or that quietly rewards long answers, does not produce obviously broken output — it produces confident, consistent, wrong numbers, and your whole regression history inherits the error. Judge validationMeasuring the evaluator itself against known-good and human-labelled data before trusting it to evaluate the agent. The eval-suite equivalent of calibrating an instrument against a reference standard. is the gauge block: two cheap checks that catch a miscalibrated ruler before it poisons a year of decisions.
When judge scores and your own reading of an answer disagree, the instinct is to go change the agent. Resist it. Until the judge is validated, a disagreement is at least as likely to be a broken rubric as a broken agent — and "fixing" the agent to please a miscalibrated judge actively makes the product worse while every dashboard turns green. Validate first; only then are you allowed to read a score as a fact about the agent.
Check 1 — Known-Bad Separation
The cheapest possible check: take an answer you know is good, break it on purpose, and confirm the judge notices. This is the eval equivalent of mutation testingA technique that deliberately introduces faults into code to verify the test suite catches them. A test suite that still passes against broken code is not testing anything. — a test suite that passes on deliberately broken input is not testing anything, and neither is a judge that scores garbage as highly as gold.
Break it three ways, each aimed at one dimension, so a failure tells you which anchor is decorative rather than just "something is wrong":
- Strip the reasoning. Keep the verdict, delete the evidence chain.
reasoning_qualitymust collapse. If it does not, your reasoning anchors are rewarding the presence of a confident conclusion. - Fabricate a fact. Append one plausible-sounding detail — a registry record, an EIN — that appears nowhere in the retrieved context.
faithfulnessmust collapse. A judge that misses an invented EIN will miss every hallucination your agent ever produces. - Inflate the confidence. Same evidence, confidence bumped from 0.92 to 0.99.
confidence_calibrationmust collapse. This is the subtlest of the three and the one local 7B judges most often shrug at.
The gate is a drop of at least 0.25 — one full anchor step. A judge that docks a fabricated answer by 0.05 has technically "noticed" and practically ignored it.
Check 2 — Judge vs. Human Agreement
Known-bad separation proves the judge can see a bulldozer. It says nothing about whether the judge ranks two reasonable answers the way a domain expert would — which is the job you actually hired it for. For that you need labels: a set of answers a human has scored on the same anchors, and a number that says how closely the judge tracks them.
Use Spearman rank correlationA correlation computed on the ranks of values rather than the values themselves, written as rho. It measures whether two scorers ORDER the same items the same way, ignoring any constant offset between their scales. Ranges from -1 (perfectly reversed) to +1 (identical ordering)., not Pearson, and not raw accuracy. The reason is practical: a judge that scores everything 0.2 lower than a human but in exactly the right order is perfectly usable — you shift the threshold and move on. A judge with the right average and a scrambled order is worthless, and raw accuracy cannot tell those two apart. So the lab reports both: rho for order and mean absolute errorThe average of the absolute differences between judge score and human score. Measures the size of the offset between the two scales, independent of whether the ordering agrees. for level.
This is the check the rest of the module is standing on. Your CI threshold, the regression baseline, the "average rose from 0.78 to 0.83" — all of it is arithmetic on judge output. At rho = 0.45 those numbers are a random walk that happens to have two decimal places, and every gate you built on them fires at the wrong times: real regressions sail through, and clean PRs get blocked while someone spends a day chasing a phantom. The two checks cost about 14 judge calls, roughly four minutes on CPU, run once per rubric revision. Not doing them is how a team discovers, six weeks in, that the eval suite everyone trusted was measuring answer length.
# tests/validate_judge.py
# WHAT: Validate the judge before trusting it to validate the agent
# WHY: Every threshold in this module is arithmetic on judge output. If the
# judge is miscalibrated, every downstream number inherits the error
# GOTCHA: Run this after ANY rubric edit. Changing one anchor line can flip
# the judge's ordering, and nothing else in your suite will notice
import json
import sys
from llm_judge import DIMENSIONS, run_judge
# Gates. Both are judgement calls — these are the values this lab defends.
MIN_DROP = 0.25 # a broken answer must lose at least one full anchor step
MIN_CORRELATION = 0.80 # below this, fix the rubric, not the agent
MAX_MEAN_ABS_ERROR = 0.20
# WHAT: Three ways to break one good answer, each aimed at one dimension
# WHY: Per-dimension targeting turns a failure into a diagnosis — you learn
# WHICH anchor is decorative, not just that something is off
# GOTCHA: Perturb the ANSWER only. Touch the question or context and you are
# testing a different case, not a damaged version of this one
def strip_reasoning(answer: str) -> str:
"""Keep the verdict, delete the evidence. Target: reasoning_quality."""
return "Merge. Confidence 0.92."
def fabricate_fact(answer: str) -> str:
"""Append a fact that is nowhere in the context. Target: faithfulness."""
return answer + (" The Delaware registry also confirms both filings share "
"federal EIN 47-2201855, which settles it.")
def inflate_confidence(answer: str) -> str:
"""Same evidence, indefensible confidence. Target: confidence_calibration."""
return (answer.replace("0.92", "0.99")
+ " No further verification is warranted.")
PERTURBATIONS = [
("stripped-reasoning", strip_reasoning, "reasoning_quality"),
("fabricated-fact", fabricate_fact, "faithfulness"),
("inflated-confidence", inflate_confidence, "confidence_calibration"),
]
def check_known_bad(baseline_case: dict) -> bool:
"""Score the baseline, then each perturbation, and demand a real drop."""
base = run_judge(baseline_case["question"], baseline_case["answer"],
baseline_case["context"])
if base.get("judge_error"):
# A judge that cannot answer cannot be validated — fail loudly
print(f" judge is not responding: {base['explanation']}")
return False
print(f" baseline '{baseline_case['id']}' overall={base['overall']:.2f}")
ok = True
for name, perturb, target_dim in PERTURBATIONS:
broken = run_judge(baseline_case["question"],
perturb(baseline_case["answer"]),
baseline_case["context"])
drop = base["overall"] - broken.get("overall", 0)
dim_drop = base[target_dim] - broken.get(target_dim, 0)
passed = drop >= MIN_DROP
ok = ok and passed
print(f" [{'PASS' if passed else 'FAIL'}] {name:<22} "
f"overall={broken.get('overall', 0):.2f} "
f"(drop {drop:+.2f}, {target_dim} {dim_drop:+.2f})")
if not ok:
print("\n A perturbation the judge did not punish means that dimension")
print(" is decorative. Sharpen its anchors before trusting any score.")
return ok
# WHAT: Spearman rank correlation, hand-rolled so the lab stays dependency-free
# WHY: You care whether the judge ORDERS answers like a human does. A uniformly
# harsh judge is usable; a judge with scrambled order is not, and plain
# accuracy cannot tell those two cases apart
# GOTCHA: Ties need AVERAGE ranks. Naive ranking silently deflates rho, and you
# will have ties — anchored scores only take five distinct values
def _average_ranks(values: list) -> list:
"""Rank smallest to largest; ties share the mean of the ranks they span."""
order = sorted(range(len(values)), key=lambda i: values[i])
ranks = [0.0] * len(values)
i = 0
while i < len(order):
j = i
while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
j += 1
shared = (i + j) / 2 + 1
for k in range(i, j + 1):
ranks[order[k]] = shared
i = j + 1
return ranks
def spearman(xs: list, ys: list) -> float:
"""Pearson correlation of the average ranks. Returns 0.0 if a side is constant."""
rx, ry = _average_ranks(xs), _average_ranks(ys)
n = len(xs)
mx, my = sum(rx) / n, sum(ry) / n
num = sum((rx[i] - mx) * (ry[i] - my) for i in range(n))
dx = sum((rx[i] - mx) ** 2 for i in range(n)) ** 0.5
dy = sum((ry[i] - my) ** 2 for i in range(n)) ** 0.5
return num / (dx * dy) if dx and dy else 0.0
def check_agreement(labelled: list) -> bool:
"""Judge every labelled case, then compare the ORDER to the humans."""
human, judge, rows = [], [], []
for case in labelled:
scores = run_judge(case["question"], case["answer"], case["context"])
if scores.get("judge_error"):
print(f" judge error on {case['id']}: {scores['explanation']}")
return False
human.append(case["human_overall"])
judge.append(scores["overall"])
rows.append((case["id"], case["human_overall"], scores["overall"]))
print(f" {'case':<32} {'human':>7} {'judge':>7} {'delta':>7}")
for case_id, h, j in rows:
# These flagged rows are the deliverable — each one names an anchor to rewrite
flag = " <-- disagreement" if abs(h - j) > 0.25 else ""
print(f" {case_id:<32} {h:>7.2f} {j:>7.2f} {j - h:>+7.2f}{flag}")
rho = spearman(human, judge)
mae = sum(abs(h - j) for _, h, j in rows) / len(rows)
rho_ok, mae_ok = rho >= MIN_CORRELATION, mae <= MAX_MEAN_ABS_ERROR
print(f"\n Spearman rho {rho:.3f} gate >= {MIN_CORRELATION:.2f} "
f"{'PASS' if rho_ok else 'FAIL'}")
print(f" mean abs error {mae:.3f} gate <= {MAX_MEAN_ABS_ERROR:.2f} "
f"{'PASS' if mae_ok else 'FAIL'}")
if not rho_ok:
print("\n Low rho means the judge orders answers differently than a human.")
print(" Read the disagreement rows: they name the anchor to rewrite.")
print(" Do NOT 'fix' this by changing the agent — the ruler is wrong.")
elif not mae_ok:
print("\n Order is right, level is off — the judge is uniformly harsh or")
print(" generous. Usable for A/B comparisons, not for absolute thresholds.")
return rho_ok and mae_ok
if __name__ == "__main__":
with open("eval_dataset.json", encoding="utf-8") as f:
baseline = next(c for c in json.load(f)["cases"] if c["id"] == "good-merge")
with open("human_labels.json", encoding="utf-8") as f:
labelled = json.load(f)["cases"]
print(f"Dimensions under test: {', '.join(DIMENSIONS)}")
results = [check_known_bad(baseline), check_agreement(labelled)]
if all(results):
print("\nJUDGE VALIDATED — its scores are now worth acting on.")
sys.exit(0)
print("\nJUDGE NOT VALIDATED — every downstream number is suspect.")
print("Fix the rubric before you read another agent score.")
sys.exit(1) # non-zero so CI treats a bad judge as a build failure
// tests/validateJudge.js
// WHAT: Validate the judge before trusting it to validate the agent
// WHY: Every threshold in this module is arithmetic on judge output — a
// miscalibrated judge silently corrupts all of it
// GOTCHA: Re-run after ANY rubric edit. One reworded anchor can flip the
// judge's ordering and nothing else in your suite will notice
import { readFileSync } from 'node:fs';
import { DIMENSIONS, runJudge } from './llmJudge.js';
// Gates. Both are judgement calls — these are the values this lab defends.
const MIN_DROP = 0.25; // a broken answer must lose one full anchor step
const MIN_CORRELATION = 0.80; // below this, fix the rubric, not the agent
const MAX_MEAN_ABS_ERROR = 0.20;
// WHAT: Three ways to break one good answer, each aimed at one dimension
// WHY: Per-dimension targeting turns a failure into a diagnosis
// GOTCHA: Perturb the ANSWER only — never the question or the context
const PERTURBATIONS = [
{ name: 'stripped-reasoning', target: 'reasoning_quality',
apply: () => 'Merge. Confidence 0.92.' },
{ name: 'fabricated-fact', target: 'faithfulness',
apply: (a) => `${a} The Delaware registry also confirms both filings ` +
'share federal EIN 47-2201855, which settles it.' },
{ name: 'inflated-confidence', target: 'confidence_calibration',
apply: (a) => `${a.replace('0.92', '0.99')} No further verification ` +
'is warranted.' },
];
async function checkKnownBad(c) {
const base = await runJudge(c.question, c.answer, c.context);
if (base.judge_error) {
// A judge that cannot answer cannot be validated — fail loudly
console.log(` judge is not responding: ${base.explanation}`);
return false;
}
console.log(` baseline '${c.id}' overall=${base.overall.toFixed(2)}`);
let ok = true;
for (const { name, target, apply } of PERTURBATIONS) {
const broken = await runJudge(c.question, apply(c.answer), c.context);
const overall = broken.overall ?? 0;
const drop = base.overall - overall;
const dimDrop = base[target] - (broken[target] ?? 0);
const passed = drop >= MIN_DROP;
ok = ok && passed;
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name.padEnd(22)} ` +
`overall=${overall.toFixed(2)} (drop ${drop.toFixed(2)}, ` +
`${target} ${dimDrop.toFixed(2)})`);
}
if (!ok) {
console.log('\n A perturbation the judge did not punish means that dimension');
console.log(' is decorative. Sharpen its anchors before trusting any score.');
}
return ok;
}
// WHAT: Spearman rank correlation, hand-rolled so the lab stays dependency-free
// WHY: Order matters more than level — a uniformly harsh judge is usable,
// a judge with scrambled order is not
// GOTCHA: Ties need AVERAGE ranks, and anchored scores are full of ties
function averageRanks(values) {
const order = values.map((v, i) => i).sort((a, b) => values[a] - values[b]);
const ranks = new Array(values.length).fill(0);
let i = 0;
while (i < order.length) {
let j = i;
while (j + 1 < order.length && values[order[j + 1]] === values[order[i]]) j++;
const shared = (i + j) / 2 + 1;
for (let k = i; k <= j; k++) ranks[order[k]] = shared;
i = j + 1;
}
return ranks;
}
export function spearman(xs, ys) {
const rx = averageRanks(xs);
const ry = averageRanks(ys);
const n = xs.length;
const mx = rx.reduce((a, b) => a + b, 0) / n;
const my = ry.reduce((a, b) => a + b, 0) / n;
let num = 0; let dx = 0; let dy = 0;
for (let i = 0; i < n; i++) {
num += (rx[i] - mx) * (ry[i] - my);
dx += (rx[i] - mx) ** 2;
dy += (ry[i] - my) ** 2;
}
const denom = Math.sqrt(dx) * Math.sqrt(dy);
return denom ? num / denom : 0; // constant input means no signal, not NaN
}
async function checkAgreement(labelled) {
const human = []; const judge = []; const rows = [];
for (const c of labelled) {
const scores = await runJudge(c.question, c.answer, c.context);
if (scores.judge_error) {
console.log(` judge error on ${c.id}: ${scores.explanation}`);
return false;
}
human.push(c.human_overall);
judge.push(scores.overall);
rows.push({ id: c.id, h: c.human_overall, j: scores.overall });
}
console.log(` ${'case'.padEnd(32)} ${'human'.padStart(7)} ${'judge'.padStart(7)}`);
for (const { id, h, j } of rows) {
// These flagged rows are the deliverable — each names an anchor to rewrite
const flag = Math.abs(h - j) > 0.25 ? ' <-- disagreement' : '';
console.log(` ${id.padEnd(32)} ${h.toFixed(2).padStart(7)} ` +
`${j.toFixed(2).padStart(7)}${flag}`);
}
const rho = spearman(human, judge);
const mae = rows.reduce((a, r) => a + Math.abs(r.h - r.j), 0) / rows.length;
const rhoOk = rho >= MIN_CORRELATION;
const maeOk = mae <= MAX_MEAN_ABS_ERROR;
console.log(`\n Spearman rho ${rho.toFixed(3)} gate >= 0.80 ` +
`${rhoOk ? 'PASS' : 'FAIL'}`);
console.log(` mean abs error ${mae.toFixed(3)} gate <= 0.20 ` +
`${maeOk ? 'PASS' : 'FAIL'}`);
if (!rhoOk) {
console.log('\n Low rho means the judge orders answers differently than a human.');
console.log(" Do NOT 'fix' this by changing the agent — the ruler is wrong.");
} else if (!maeOk) {
console.log('\n Order is right, level is off — usable for A/B comparisons,');
console.log(' not for absolute thresholds.');
}
return rhoOk && maeOk;
}
const baseline = JSON.parse(readFileSync('eval_dataset.json', 'utf-8'))
.cases.find((c) => c.id === 'good-merge');
const labelled = JSON.parse(readFileSync('human_labels.json', 'utf-8')).cases;
console.log(`Dimensions under test: ${DIMENSIONS.join(', ')}`);
const results = [await checkKnownBad(baseline), await checkAgreement(labelled)];
if (results.every(Boolean)) {
console.log('\nJUDGE VALIDATED — its scores are now worth acting on.');
process.exit(0);
}
console.log('\nJUDGE NOT VALIDATED — every downstream number is suspect.');
process.exit(1); // non-zero so CI treats a bad judge as a build failure
You built the only test in this module that points at the test harness instead of the agent. Check 1 damages a known-good answer three times and demands the judge dock it a full anchor step each time — if it does not, that dimension's anchors are decorative and you know exactly which ones to rewrite. Check 2 asks whether the judge orders ten human-labelled answers the way a domain expert did, and reports rho separately from mean absolute error so you can tell "wrong order" (fatal) from "uniformly harsh" (survivable, just move the threshold). Both exit non-zero on failure, so this runs as a CI step ahead of the eval suite it protects: a judge that fails validation stops the build before it can report a single misleading agent score.
Ten labelled cases is what makes this lab runnable in four minutes on a laptop CPU — it is not what makes a correlation trustworthy. At n = 10, rho swings noticeably from one relabelled case, so treat a result near the 0.80 line as "inconclusive, go label more" rather than a pass. Aim for 20–30 labelled cases before a judge gates anything in CI, and make sure the human scores span the full range: a label set where everything is good or everything is bad produces a meaningless correlation no matter how many rows it has. One more habit worth building — have a second person independently label a slice of the set. Where two humans cannot agree, no rubric anchor will save you, and that is a signal to rewrite the criterion rather than the judge.
📂 Get the files: validate_judge.py / .js and human_labels.json live alongside the judge in labs-opensource/M18-evaluation-testing.
CI Integration (GitHub Actions)
Evals only prevent regressions if they run automatically. Wire your three test layers into a GitHub Actions workflow that fails the PR if any layer regresses below its threshold.
GitHub Actions runners don't have Ollama pre-installed. You have two options: (1) Install Ollama in the CI job and run ollama serve & as a background service before your test run, or (2) Use a self-hosted runner on a machine where Ollama is already running. Option 1 is simpler but adds ~90 seconds to your CI build for the ollama pull mistral step (~4GB). Option 2 is faster but requires infra. The workflow below shows Option 1.
# .github/workflows/eval.yml
# WHAT: Three-layer eval pipeline running on every PR
# WHY: Catch regressions before they reach main — the cost is
# ~5 minutes of CI time vs. days of production debugging
# GOTCHA: ollama serve must be backgrounded (&) and you must
# wait for it to be ready before running tests
name: Agent Eval Suite
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
name: Unit Tests (tools)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt pytest
# WHAT: Unit tests have no Ollama dependency — run first, fast
- name: Run unit tests
run: pytest tests/test_tools_unit.py -v --tb=short
timeout-minutes: 2
integration-tests:
name: Integration Tests (agent loop)
runs-on: ubuntu-latest
needs: unit-tests # only run if units pass
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt pytest httpx
# WHAT: Install Ollama binary
- name: Install Ollama
run: curl -fsSL https://ollama.com/install.sh | sh
# WHAT: Start Ollama server in background, pull mistral model
# GOTCHA: Sleep 5 gives Ollama time to initialize before pull
- name: Start Ollama and pull model
run: |
ollama serve &
sleep 5
ollama pull mistral
echo "Ollama ready"
timeout-minutes: 8
- name: Run integration tests
run: pytest tests/test_agent_integration.py -v --tb=short
timeout-minutes: 10
env:
EVAL_PASS_THRESHOLD: "0.80" # 80% of cases must pass
llm-judge-tests:
name: LLM-as-Judge (quality)
runs-on: ubuntu-latest
needs: integration-tests # judge tests are expensive — run last
if: github.event_name == 'push' || github.base_ref == 'main'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt pytest deepeval>=1.4
pip install langchain-community sentence-transformers ragas
- name: Install Ollama
run: curl -fsSL https://ollama.com/install.sh | sh
- name: Start Ollama and pull model
run: |
ollama serve &
sleep 5
ollama pull mistral
timeout-minutes: 8
# WHAT: Validate the judge BEFORE using it to validate the agent
# WHY: A miscalibrated judge makes every number in the next step a lie.
# ~14 calls; fails the build rather than let a broken ruler go green
# GOTCHA: Re-runs on every push because the rubric lives in the repo —
# a one-line anchor edit is exactly what this catches
- name: Validate the judge
run: python tests/validate_judge.py
timeout-minutes: 8
# WHAT: Run judge evals with regression threshold
# GOTCHA: Set DEEPEVAL_TELEMETRY_OPT_OUT to prevent DeepEval
# from phoning home during CI
- name: Run LLM judge evals
run: |
python -m pytest tests/test_deepeval_metrics.py \
tests/test_ragas_rag.py -v --tb=short
timeout-minutes: 15
env:
DEEPEVAL_TELEMETRY_OPT_OUT: "YES"
JUDGE_PASS_THRESHOLD: "0.70"
# scripts/run_eval_with_threshold.py
# WHAT: Standalone eval runner with pass-rate threshold enforcement
# WHY: For cases where you want to run evals outside pytest —
# e.g., from a Makefile or a release gate script
# GOTCHA: Pass EVAL_PASS_THRESHOLD as env var so CI can override
# the threshold without changing code
import os
import sys
import json
from pathlib import Path
from llm_judge import evaluate_with_judge
from eval_utils import check_ollama_health, load_eval_cases
# Read threshold from environment (default 0.80)
THRESHOLD = float(os.environ.get("EVAL_PASS_THRESHOLD", "0.80"))
JUDGE_THRESHOLD = float(os.environ.get("JUDGE_PASS_THRESHOLD", "0.70"))
def main():
if not check_ollama_health():
print("ERROR: Ollama not running. Cannot run LLM-based evals.")
sys.exit(1)
# Load judge test cases (separate from golden set — judge cases
# include pre-computed agent outputs from a reference run)
judge_cases_path = Path("eval_judge_cases.json")
if not judge_cases_path.exists():
print(f"ERROR: {judge_cases_path} not found")
sys.exit(1)
with open(judge_cases_path) as f:
judge_cases = json.load(f)["cases"]
print(f"\nRunning judge evals on {len(judge_cases)} cases "
f"(threshold: {JUDGE_THRESHOLD})...")
summary = evaluate_with_judge(judge_cases, threshold=JUDGE_THRESHOLD)
print(f"\nResults: {summary['passed']}/{summary['total']} passed "
f"({summary['pass_rate']*100:.1f}%)")
# Fail CI if pass rate drops below EVAL_PASS_THRESHOLD
if summary["pass_rate"] < THRESHOLD:
print(f"\nFAIL: Pass rate {summary['pass_rate']*100:.1f}% < "
f"threshold {THRESHOLD*100:.0f}%")
print("This indicates a regression. Check the individual case scores above.")
sys.exit(1)
print(f"\nPASS: All eval layers within acceptable thresholds.")
sys.exit(0)
if __name__ == "__main__":
main()
You now have a three-stage CI pipeline: unit tests run first (fast, no Ollama), integration tests run if units pass (require Ollama), LLM judge tests run last on main branch merges (most expensive). Each stage has an explicit timeout so a hung Ollama process can't block CI indefinitely. The threshold script enforces a pass-rate gate so a single flaky case doesn't fail the whole build, but a 20% regression definitely will.
What Changed vs. the Anthropic Track
If you've worked through the main course's evaluation module, here is the complete diff between the Anthropic-specific patterns and the open-source equivalents used here.
| Anthropic / Cloud Pattern | OS Track (Ollama) Equivalent | Notes |
|---|---|---|
| Weave / W&B for eval tracking | pytest + JSON results file | No external service needed |
| OpenAI GPT-4 as DeepEval judge | OllamaJudge wrapping Mistral | ~0.05 lower scores — adjust thresholds |
| ragas with OpenAI embeddings | ragas with sentence-transformers + Ollama LLM | 80MB model download on first run |
| Anthropic SDK response.content | OpenAI response.choices[0].message.content | Same pattern in judge + harness |
| Claude's structured output (JSON mode) | JSON parsing with markdown fence stripping | Mistral often adds ``` fences around JSON |
| Confident AI cloud dashboard | DEEPEVAL_TELEMETRY_OPT_OUT=YES + local results | Set env var to avoid phone-home |
| Judge trusted on the strength of the model | Judge validated against human labels before use | Not optional on a 7B judge — see Validating the Judge |
| One-line criteria per dimension | Five anchored levels per dimension, aggregate computed in code | The smaller the judge, the more of the scale it invents when you leave it blank |
The biggest practical difference is Mistral-7B's JSON reliability. Claude and GPT-4 reliably return clean JSON when asked. Mistral-7B frequently wraps JSON in markdown code fences or includes a preamble sentence. Every judge function in this module includes a regex strip for fences before calling json.loads(). This is not a bug to fix — it is a property to handle. Always test your judge prompts with the actual model before trusting the scores.
Knowledge Check
1. Your entity resolution agent passes all unit tests but you discover a 15% false merge rate in production. Which eval level most likely caught this gap?
2. When configuring DeepEval for offline use with Ollama, which combination is correct?
3. The Ragas faithfulness metric scores 0.45 on your eval dataset. What does this most likely indicate?
4. Your LLM judge prompt asks: "Score the response quality." What is wrong with this?
5. In your GitHub Actions eval workflow, the integration tests hang for 20 minutes because Ollama failed to start. What prevents this?
6. Why does the golden test set include "boundary cases" with confidence ~0.75?
7. Your judge scores the entity resolution agent at 0.82 average. You check it against 10 human-labelled cases: Spearman rho is 0.41, mean absolute error is 0.04. What do you do?
Knowledge check complete