Intent-Routing Eval (E3)
Scores the live 6-intent router (OrchestratorAgent.classify) against a golden
dataset of labelled messages. Run it before any deploy that touches the router
prompt (src/config/prompts/intent_router.md) or its few-shots.
Files
| File | What |
|---|---|
intent_dataset.json |
Golden dataset β message + known-correct expected_intent per case. The source of truth scoring compares against. |
run_eval.py |
Runner β calls the router per case, scores correctness, records latency + tokens. |
results/ |
Timestamped run reports, one JSON per run (never overwritten). |
Run
Run as a module (-m), not the file path β module mode puts the repo root on
sys.path so src imports resolve; python eval/intent/run_eval.py fails.
uv run python -m eval.intent.run_eval # full dataset
uv run python -m eval.intent.run_eval --limit 6 # quick smoke test
uv run python -m eval.intent.run_eval --langfuse # also stream traces to Langfuse
Needs a populated .env (Azure OpenAI) β it calls the live model and spends
tokens. Output: a per-case detail table + an aggregate summary in the terminal,
and results/eval_result_<timestamp>.json.
Tracking is the committed result files, not Langfuse β the JSON reports in
results/ are the versionable audit trail (see below). --langfuse is an
optional extra: when set, each case is also sent as a Langfuse trace (grouped
under one intent_eval_<ts> session) with a intent_correct 1/0 score, so the
same run is browsable in the Langfuse dashboard. It is off by default and the
eval runs fully without Langfuse configured.
What's measured
- correctness β overall + per-intent + per-language accuracy (
got == expected) - runtime β average ms per case
- tokens β input / output / total (read from the model response, no Langfuse)
Content-filter blocks count as out_of_scope passes
Aggressive jailbreak / manipulation inputs are often rejected by Azure's own
content filter (HTTP 400, code=content_filter) before the router model runs.
The live app treats that as a refusal (chat_handler._is_content_filter_error),
so for an out_of_scope case the block is the correct end-to-end outcome. The
runner mirrors this: such a case is recorded as got=blocked and scored correct
(not ERROR:BadRequestError). This keeps out_of_scope accuracy honest β the
router isn't penalised for inputs the platform guardrail caught first. A
content-filter block on any other expected intent is still scored wrong (an
unexpected block). Non-filter exceptions remain ERROR:<type> and score wrong.
Commit convention for results/
The reports are versionable, not a scratch log:
- Do commit a result after a meaningful change β e.g. a new
intent_router.mdversion, or new dataset cases. The new timestamped file adds to the history; old files are never replaced. This is how we answer "did accuracy improve after prompt v2?" β diff two committed result files. - Don't commit throwaway runs while iterating. Just leave them unstaged or delete them.
So the audit trail = prompt versions (in src/config/prompts/) lined up against
the committed result files here.
Dataset notes
- 6 intents:
chat,help,check,unstructured_flow,structured_flow,out_of_scope. Each has 6+ distinct scenarios (not EN/ID translation pairs), balanced across English + Indonesian. (problem_statementwas dropped from the router on 2026-06-24 β the goal is now user-enteredobjective+business_questions, no agent validation β so its cases were removed here.) carried_over: truerows mirror the pre-reworkintent_router.mdexamples (regression).langenables per-language scoring.idis a stable handle for diffing the same case across runs.- Routing labels are decided from the question phrasing, not from which file
holds the answer (the router has no catalog access). See the
_groundingnote inintent_dataset.json. - Owner: Rifqi (structured/DB-grounded rows) + Sofhia (unstructured/document + tabular-file rows). Merge both into this one file.