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. Three rules for good judge prompts:
- 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."
# 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:
1. Reasoning quality (0–1): Does the agent reason step-by-step from evidence to conclusion? \
Does it cite specific filings, scores, or registry data before deciding?
2. Faithfulness (0–1): Does the answer use ONLY facts present in the retrieved context? \
Score 0 if it invents any fact not in context.
3. Evidence sufficiency (0–1): Did the agent use at least two independent pieces of evidence \
before reaching its confidence score? One clue is not enough for a high-confidence merge.
4. Confidence calibration (0–1): Is the confidence score plausible given the evidence? \
Score 1 if confidence matches evidence strength, 0 if they contradict.
IMPORTANT:
- Do NOT favor longer or shorter answers — length does not equal quality.
- Base ALL scores on the criteria above, not on whether you agree with the final decision.
- Return ONLY valid JSON — no markdown, no explanation outside the JSON.
Return:
{{
"reasoning_quality": 0.0,
"faithfulness": 0.0,
"evidence_sufficiency": 0.0,
"confidence_calibration": 0.0,
"overall": 0.0,
"explanation": "one sentence explaining the overall score"
}}"""
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 = 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 (each 0.0–1.0):
1. reasoning_quality: Does the agent reason step-by-step from evidence?
2. faithfulness: Does it use ONLY facts from context?
3. evidence_sufficiency: Did it use ≥2 independent evidence pieces?
4. confidence_calibration: Is the confidence score consistent with evidence?
IMPORTANT: Do not favor longer answers. Return ONLY valid JSON.
Return: {"reasoning_quality":0.0,"faithfulness":0.0,"evidence_sufficiency":0.0,\
"confidence_calibration":0.0,"overall":0.0,"explanation":"one sentence"}`;
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 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 explicit scoring criteria, JSON output parsing that handles Mistral's markdown fences, and a bias mitigation note in 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.
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: 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 |
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?
Knowledge check complete