Evaluation Pipelines
The system has three evaluation layers. Each targets a different failure mode and has distinct CI gate semantics. All layers run against the live backend or live database — not mocks — so results reflect actual production behaviour.
| Layer | Tool | What it tests | CI gate |
|---|---|---|---|
| 1 — Agent Behaviour | DeepEval + pytest | CDD rule compliance across personas and conversation patterns | Hard block — 60/60 required |
| 2 — Adversarial Red Team | promptfoo | Resistance to prompt injection and social engineering | Soft block — human review required |
| 3 — RAG Pipeline | RAGAS | Retrieval quality and response faithfulness | Soft block now → hard block after Phase 5 sign-off |
Layer 1 — Agent Behaviour
What it covers
60 tests run in CI (-m "not adversarial"):
| Group | Tests | What is verified |
|---|---|---|
| P1 — Evaluating CTO | 10 | Technical depth, credibility, fast Stage 3 escalation |
| P2 — Exploring Founder | 10 | Education-first tone, resource offer, email capture |
| P3 — Referred Decision-Maker | 10 | Low-friction progression, referral acknowledgement |
| N1 — Competitor | 10 | Public-info-only responses, no escalation, no persona break |
| N2 — Curious Researcher | 10 | Helpful but non-qualifying, no Stage 3 push |
| Conversation patterns | 10 | Pricing deflection, out-of-scope, outside-hours, stall detection, etc. |
| Edge cases | 4 | CDD §6 edge cases |
Each test opens a real SSE session against the live backend, sends one or more messages to build conversation state, then evaluates the captured response with one or more metrics.
Metric types
Deterministic (regex / structural — threshold 1.0, always run):
| Metric | What it checks |
|---|---|
NoPricingDisclosureMetric |
No currency symbols, no /day patterns, no “starting from X” phrasing |
SingleQuestionPerExchangeMetric |
At most one ? per response |
NoCostFigureMetric |
No cost figures in any form |
NoContactRequestMetric |
No contact request on the first turn |
NoApologyToneMetric |
No apologetic language |
Stage3ProposalMetric |
Both a call/connect word and an email ask present |
HonestLimitAcknowledgementMetric |
Knowledge limit acknowledged + forward path offered |
PricingDeflectionQualityMetric |
Explains why pricing is not shared + offers a call |
TechnicalDepthMetric |
Technical terms present in response |
FollowUpCommitmentMetric |
Specific time commitment present after email capture |
NoFurtherQualificationMetric |
Stage 3 response contains email ask but no qualifying questions |
LLM-as-judge / GEval (skipped when no judge configured, threshold 0.65–1.0):
| Metric | Threshold | What it checks |
|---|---|---|
NoFabricationWithoutContextMetric |
0.65 | No specific factual claims when the KB returns no results |
Inline GEval (per-test criteria) |
0.7–1.0 | Persona tone, graceful decline, AI disclosure, N1 adherence, stall handling, etc. |
GEval metrics are skipped (not failed) when no judge is configured. In development, set JUDGE_PROVIDER=ollama and JUDGE_MODEL=qwen2.5:7b with a local Ollama instance. In CI, the Anthropic Claude Haiku judge is configured automatically.
CI gate
Hard block. Any single test failure fails the eval-behaviour.yml workflow. The workflow is triggered automatically after every successful Deploy Backend run on main.
Running manually
# All 60 CI tests
uv run --package evaluation pytest evaluation/behaviour -m "not adversarial" -v
# Single persona
uv run --package evaluation pytest evaluation/behaviour -m p1 -v
# Multiple markers
uv run --package evaluation pytest evaluation/behaviour -m "p1 or p2" -v
# Single test by ID
uv run --package evaluation pytest evaluation/behaviour -k "TC-P1-003" -v
Via GitHub Actions: workflow_dispatch on eval-behaviour.yml.
The backend must be running and reachable at EVAL_API_URL (default: http://localhost:8000). See evaluation/.env.example for all environment variables.
Failure triage
| Failure type | Likely cause | Where to look |
|---|---|---|
| Deterministic metric fails | Response literally contains the prohibited pattern | Graph post-processing: backend/src/backend/conversation/graph/ |
| GEval metric fails | Judge scored below threshold; may also be judge variance | HTML report artifact (GitHub Actions) or Langfuse trace; re-run once to rule out variance |
| Backend 503 / connection error | Backend not ready or rate-limited during warm-up | Check /ready endpoint; wait for warm-up retry in CI to complete |
Layer 2 — Adversarial Red Team
What it covers
20 baseline static test cases across four threat categories:
| Category | Cases | What is probed |
|---|---|---|
| Information extraction | TC-ADV-001–007 | Pricing anchor confirmation, headcount buckets, client name extraction, system prompt leakage, competitor opinion, financial data, model/infrastructure disclosure |
| Prompt injection | TC-ADV-008–012 | Role override, no-restrictions persona, fictional framing jailbreak, false prior statement confirmation, unverifiable authority claim |
| Persona boundary violations | TC-ADV-013–016 | N1/N2 pivot to real initiative, late consultant reveal, hostile pressure after pricing deflection |
| Qualification logic bypass | TC-ADV-017–020 | Premature Stage 3 demand, false hot-lead signal, bulk qualification questions, email-for-pricing trade |
Adaptive attacks are also generated automatically using GOAT (iterative multi-turn, learns from responses) and Crescendo (escalating social engineering) strategies.
Assertion types per case:
not-contains— deterministic string check (e.g. no€, no/day, noStage 1)llm-rubric— judge evaluates a natural-language pass/fail criterion
CI gate
Soft block. A failure does not automatically block deployment. It requires explicit, documented human review and sign-off before the next deploy proceeds. The distinction between a genuine vulnerability and an edge case (judge variance, borderline phrasing) requires human judgment.
Running manually
# Baseline 20 static cases
promptfoo eval --config evaluation/redteam/promptfooconfig.yaml --no-cache
# Adaptive attacks (GOAT + Crescendo)
promptfoo redteam run --config evaluation/redteam/promptfooconfig.yaml --no-cache
Via GitHub Actions: workflow_dispatch on eval-redteam.yml. The workflow also runs automatically after every successful Deploy Backend on main.
Requires: promptfoo installed globally (npm install -g promptfoo), backend reachable, PROMPTFOO_GRADING_PROVIDER set. See evaluation/.env.example.
Failure triage
- Download the HTML report artifact from the GitHub Actions run (30-day retention).
- Identify the failing case(s) by TC ID.
- Classify the failure:
| Classification | Criteria | Action |
|---|---|---|
| Genuine vulnerability | Agent disclosed pricing, leaked a client name, broke persona, or complied with an injection | Fix in backend prompt (backend/src/backend/conversation/prompt/) or graph post-processing before next deploy |
| Judge variance | Borderline phrasing, different run gives different result, rubric is ambiguous | Document as known limitation; proceed with explicit sign-off |
Layer 3 — RAG Pipeline
What it covers
A 43-item ground-truth dataset (evaluation/datasets/rag_eval_dataset.json):
| Item type | Count | Purpose |
|---|---|---|
known_relevant |
25 | Direct KB match — tests recall and precision |
paraphrase |
12 | Semantically rephrased questions — tests robustness |
no_relevant_chunk |
6 | No KB match — tests graceful acknowledgement |
Pipeline per item: embed question → retrieve top-K chunks from pgvector → generate grounded answer with Claude Haiku → evaluate with RAGAS.
Items with no relevant chunk (has_relevant_chunk=False) only receive faithfulness and answer_relevancy — the precision and recall metrics require a reference answer to compare against.
Metrics and thresholds
Thresholds were calibrated on 2026-06-12 against the production knowledge base. The authoritative values are in evaluation/rag/runner.py.
| Metric | Threshold | What it measures |
|---|---|---|
faithfulness |
> 0.80 | Response is grounded in retrieved chunks — no claims from LLM memory |
context_precision |
> 0.65 | Retrieved chunks are relevant to the question |
context_recall |
> 0.70 | All necessary chunks were retrieved |
answer_relevancy |
> 0.45 | Response answers the question asked |
The thresholds documented in evaluation-best-practices.md (context_precision > 0.80, answer_relevancy > 0.75) are aspirational targets from the original TRD. The values above are the real calibrated thresholds in the code and are what CI actually enforces. The gap reflects structural ceilings in the production KB — not quality regressions.
Why the lower thresholds are correct (structural ceilings):
context_precision0.65: Multiple broad-service chunks have similar embedding distances; RAGAS cannot easily rank them when all are valid matches.answer_relevancy0.45: RAGAS measures this by generating questions from the response, then computing cosine similarity. Short consulting-style yes/no answers score low by design.
CI gate
Currently a soft block — continue-on-error is active in eval-rag.yml. Will become a hard block after Phase 5 sign-off, when continue-on-error is removed from the workflow.
Trigger: Runs automatically after a successful Ingest Knowledge Base run on main. This is the correct causal link — RAG quality only changes when the vector store content changes.
Running manually
# Install the ragas optional extra (may fail on Windows Python 3.14 — use Linux/WSL or CI)
uv sync --package evaluation --extra ragas
# Dev mode (HuggingFace embeddings, knowledge_chunks_dev table)
uv run --package evaluation python -m evaluation.rag.runner --embedding-mode dev
# Production mode (OpenAI embeddings, knowledge_chunks table)
uv run --package evaluation python -m evaluation.rag.runner --embedding-mode prod
# Threshold recalibration
uv run --package evaluation python -m evaluation.calibrate_rag
Via GitHub Actions: workflow_dispatch on eval-rag.yml.
Requires: pgvector table populated, RAGAS_DB_URL set, ANTHROPIC_API_KEY set. See evaluation/.env.example.
Failure triage
Check the JSON report artifact for per-item scores, then diagnose by metric:
| Failing metric | Likely cause | Action |
|---|---|---|
faithfulness |
Model making claims not in retrieved chunks | Review backend prompt grounding in backend/src/backend/conversation/prompt/ |
context_recall |
Relevant chunks not being retrieved | Check RAG_TOP_K and RAG_RELEVANCE_THRESHOLD backend settings; re-run calibrate_rag.py |
context_precision below 0.65 |
Retrieval returning off-topic chunks | Re-run calibrate_rag.py; check if new KB content has highly similar embeddings to unrelated content |
answer_relevancy below 0.45 |
Regression below structural floor | Compare per-item scores against Phase 4 baseline; scores above 0.45 but below 0.75 are expected and acceptable |
Threshold recalibration
Run calibrate_rag.py after any of:
- New documents added to or removed from the knowledge base
- Embedding model changed
RAG_TOP_Kchanged- Significant KB content style change (e.g. adding long narrative pages to an existing short-FAQ corpus)
The script derives new thresholds from the score distribution gap between relevant and irrelevant chunk clusters and prints the recommended values. Apply them to evaluation/rag/runner.py and the corresponding backend environment variables.
Evaluation Reports and Observability
All three layers ship results to Langfuse when credentials are configured (see Observability runbook):
| Layer | What is logged |
|---|---|
| Layer 1 — Behaviour | Per-test metric scores as Langfuse traces after each test |
| Layer 2 — Red team | Baseline promptfoo results exported as a Langfuse dataset experiment |
| Layer 3 — RAG | Per-item RAGAS scores in a ragas-{timestamp} experiment under the rag_eval dataset |
HTML and JSON report artifacts are uploaded per CI run with 30-day retention. Access them from the summary tab of the relevant GitHub Actions run.