Evaluation-Driven Development
Build a local eval loop that scores prompts against a test suite, compares before-and-after results, and gates promotion on quality thresholds.
You end with a score_prompt() function, a baseline-versus-revised comparison, and a gate script that exits non-zero when faithfulness or toxicity drops below your bar.
| Time | Difficulty | Package |
|---|---|---|
| 15 min | Intermediate | ai-evaluation, openai |
- Future AGI account → app.futureagi.com
- API keys:
FI_API_KEYandFI_SECRET_KEY(see Get your API keys) - Python 3.11+
- OpenAI API key (
OPENAI_API_KEY)
Install
pip install ai-evaluation openai
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
export OPENAI_API_KEY="your-openai-api-key"
Tutorial
Define the eval suite
Each test case pairs a customer question (input) with the context the agent should answer from.
TEST_CASES = [
{
"input": "What is your return window for electronics?",
"context": (
"Electronics may be returned within 30 days of purchase with original "
"packaging and proof of purchase. Items must be in unused condition."
),
},
{
"input": "My order arrived damaged. What should I do?",
"context": (
"Customers who receive damaged items should photograph the damage and "
"contact support within 48 hours. A replacement or full refund will be "
"issued after review."
),
},
{
"input": "Can I return a sale item for a full refund?",
"context": (
"Sale items are eligible for exchange only. Full refunds are not available "
"on sale purchases. Store credit may be offered at management discretion."
),
},
{
"input": "How long does standard shipping take?",
"context": (
"Standard shipping takes 5-7 business days within the continental US. "
"Expedited options (2-day and overnight) are available at checkout."
),
},
{
"input": "Do you price-match competitors?",
"context": (
"We offer a price-match guarantee for identical items sold by authorized "
"retailers. The match must be requested at the time of purchase. "
"Marketplace sellers and auction sites are excluded."
),
},
] Write the scoring function
score_prompt() calls OpenAI for each test case, runs two evals on every response, and returns per-metric pass rates.
| Metric | Engine | model= required? |
|---|---|---|
faithfulness | Local | Not needed; omit model= to stay on the local engine |
toxicity | Future AGI Turing | Yes; pass model="turing_small" |
import os
from openai import OpenAI
from fi.evals import evaluate
openai_client = OpenAI()
def score_prompt(prompt_template: str, test_cases: list) -> dict:
faithfulness_passes = 0
toxicity_passes = 0
per_case = []
for case in test_cases:
system_prompt = prompt_template.format(context=case["context"])
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": case["input"]},
],
)
output = response.choices[0].message.content
# faithfulness: local metric, no model= argument
faith_result = evaluate(
"faithfulness",
output=output,
context=case["context"],
)
# toxicity: Turing metric, model= is required
tox_result = evaluate(
"toxicity",
output=output,
model="turing_small",
)
if faith_result.passed:
faithfulness_passes += 1
if tox_result.passed:
toxicity_passes += 1
per_case.append({
"input": case["input"],
"output": output,
"faithfulness_score": faith_result.score,
"faithfulness_pass": faith_result.passed,
"faithfulness_reason": faith_result.reason,
"toxicity_score": tox_result.score,
"toxicity_pass": tox_result.passed,
"toxicity_reason": tox_result.reason,
})
n = len(test_cases)
faith_rate = faithfulness_passes / n
tox_rate = toxicity_passes / n
return {
"faithfulness": faith_rate,
"toxicity": tox_rate,
"composite": (faith_rate + tox_rate) / 2,
"per_case": per_case,
}Run a quick smoke test on one case before scoring the full suite:
smoke = evaluate(
"faithfulness",
output="Electronics can be returned within 30 days with proof of purchase.",
context=TEST_CASES[0]["context"],
)
print(smoke.score, smoke.reason)
# Illustrative: a fully-supported answer scores close to 1.0 Score a baseline and a revised prompt
Start with a thin prompt, score it, then revise and re-score.
BASELINE_PROMPT = """\
You are a customer support agent.
Answer the customer's question using the information below.
Context:
{context}
"""
REVISED_PROMPT = """\
You are a friendly and professional customer support agent for an e-commerce retailer.
INSTRUCTIONS:
1. Answer ONLY using the information provided in the Context section below.
2. Do NOT add policies, timeframes, or details that are not stated in the Context.
3. If the Context does not contain enough information to fully answer the question,
say so clearly and offer to escalate to the support team.
4. Keep your response concise (2-4 sentences), empathetic, and solution-focused.
Context:
{context}
"""
def print_results(label: str, results: dict):
print(f"\n{'='*40}")
print(f" {label}")
print(f"{'='*40}")
print(f"{'Metric':<16} {'Pass rate':>10}")
print("-" * 28)
print(f"{'faithfulness':<16} {results['faithfulness']:>9.0%}")
print(f"{'toxicity':<16} {results['toxicity']:>9.0%}")
print(f"{'composite':<16} {results['composite']:>9.0%}")
for i, case in enumerate(results["per_case"], 1):
faith = "PASS" if case["faithfulness_pass"] else "FAIL"
tox = "PASS" if case["toxicity_pass"] else "FAIL"
print(f" [{i}] {case['input'][:50]:<52} faith={faith} tox={tox}")
# Run both
baseline = score_prompt(BASELINE_PROMPT, TEST_CASES)
revised = score_prompt(REVISED_PROMPT, TEST_CASES)
print_results("BASELINE", baseline)
print_results("REVISED", revised)
# Show delta
print(f"\n--- Improvement ---")
print(f"faithfulness: {baseline['faithfulness']:.0%} → {revised['faithfulness']:.0%}")
print(f"toxicity: {baseline['toxicity']:.0%} → {revised['toxicity']:.0%}")
print(f"composite: {baseline['composite']:.0%} → {revised['composite']:.0%}")Expected output:
========================================
BASELINE
========================================
Metric Pass rate
----------------------------
faithfulness 60%
toxicity 80%
composite 70%
[1] What is your return window for electronics? faith=PASS tox=PASS
[2] My order arrived damaged. What should I do? faith=FAIL tox=PASS
[3] Can I return a sale item for a full refund? faith=PASS tox=PASS
[4] How long does standard shipping take? faith=PASS tox=PASS
[5] Do you price-match competitors? faith=FAIL tox=FAIL
========================================
REVISED
========================================
Metric Pass rate
----------------------------
faithfulness 80%
toxicity 100%
composite 90%
[1] What is your return window for electronics? faith=PASS tox=PASS
[2] My order arrived damaged. What should I do? faith=PASS tox=PASS
[3] Can I return a sale item for a full refund? faith=PASS tox=PASS
[4] How long does standard shipping take? faith=PASS tox=PASS
[5] Do you price-match competitors? faith=FAIL tox=PASS
--- Improvement ---
faithfulness: 60% → 80%
toxicity: 80% → 100%
composite: 70% → 90%These numbers are illustrative for this test suite and model; your own run will vary with the model and test cases you use.
Inspect the remaining failure
One case in the revised run still fails faithfulness. Read EvalResult.reason to see what the local faithfulness metric flagged instead of guessing.
for case in revised["per_case"]:
if not case["faithfulness_pass"]:
print(f"Input: {case['input']}")
print(f"Output: {case['output']}")
print(f"Score: {case['faithfulness_score']:.2f}")
print(f"Reason: {case['faithfulness_reason']}")You should see the price-match case (the one that failed in both runs) printed with its input, output, score, and the metric’s reason for the fail. Read reason before touching the prompt again: it names the exact unsupported claim, so you edit the prompt to remove that claim instead of guessing.
Gate promotion on eval thresholds
Block promotion if any metric falls below your quality bar. The non-zero exit code integrates with Makefiles, pre-commit hooks, and CI scripts.
import sys
FAITHFULNESS_THRESHOLD = 0.75
TOXICITY_THRESHOLD = 0.80
results = score_prompt(REVISED_PROMPT, TEST_CASES)
print(f"faithfulness: {results['faithfulness']:.0%} (threshold: {FAITHFULNESS_THRESHOLD:.0%})")
print(f"toxicity: {results['toxicity']:.0%} (threshold: {TOXICITY_THRESHOLD:.0%})")
try:
assert results["faithfulness"] >= FAITHFULNESS_THRESHOLD, (
f"Faithfulness too low: {results['faithfulness']:.0%} < {FAITHFULNESS_THRESHOLD:.0%}"
)
assert results["toxicity"] >= TOXICITY_THRESHOLD, (
f"Toxicity too low: {results['toxicity']:.0%} < {TOXICITY_THRESHOLD:.0%}"
)
print("\nPrompt approved for production push.")
sys.exit(0)
except AssertionError as e:
print(f"\nGATE FAILED: {e}")
print("Fix the prompt and re-run before promoting.")
sys.exit(1)With the illustrative step 3 numbers, both thresholds clear and the run prints:
faithfulness: 80% (threshold: 75%)
toxicity: 100% (threshold: 80%)
Prompt approved for production push.A lower faithfulness rate trips the gate and exits 1 instead. The script’s exit code is what a CI step checks to decide pass or fail.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
faith_result.passed is False on a correct-looking answer | The response adds a detail not present in context (dates, policies, numbers) | Reword the prompt to say “answer only using the Context” and drop anything the context doesn’t state |
evaluate("toxicity", ...) comes back with status == "error" and no score | toxicity runs on the Turing engine and requires model= | Pass model="turing_small" on every toxicity call |
faith_result.score is None | faithfulness routes to the local engine when no model= is passed, so this recipe omits it | Omit model= on the faithfulness call to stay on the local engine |
OPENAI_API_KEY not set | Env var only exported in the current shell, not persisted | Re-export before each session, or add it to .env and load it with python-dotenv |
score_prompt() is slow across 5 test cases | Each case makes a sequential OpenAI call plus two eval calls | Batch test cases with a thread pool, or cut the suite while iterating on prompt wording |
| Composite score stays flat across baseline and revised | The revised prompt changed wording but not what the model is instructed to ground on | Check the diff actually adds a constraint (e.g. “answer only from Context”), not just tone |
sys.exit(1) fires but the shell shows exit code 0 | The gate script is sourced instead of executed | Run it as python gate.py, not source gate.py |
One flaky metric on a fixed threshold usually means the threshold, not the prompt, needs a second look: rerun the suite once before treating a single fail as a regression.
Once the local gate passes, automate the same checks on every pull request. See Automated Eval in CI/CD for the full GitHub Actions setup with PR comments and branch protection.
Questions & Discussion