Consumer Agent Field Guide
How the tdfs4ds consumer agent is designed, what it is allowed to answer, and how we build golden evaluation sets that measure whether it still answers correctly.
Navigate with ← → or Space · press L for list view
What we cover
Six sections. The first three describe what the agent knows and how it knows it; the last three describe how we prove it.
A read layer over the catalogs, not a chatbot bolted on top
The consumer agent answers questions about the feature store using the store's own catalogs, business dictionary and lineage graph. Every object name it states is checked against the catalog before it reaches the user.
Who it is for
Business users and analysts who need to find a feature, understand what it means, and decide whether to trust it — without writing SQL or opening the process views.
What it reads
Feature / process / dataset catalogs, the three business-dictionary tables,
FS_LINEAGE_GRAPH, the follow-up execution log, and process-view DDL
via SHOW VIEW.
How it is invoked
One function per conversational turn. Memory is keyed on thread_id,
so multi-turn context is the caller's to control.
# tdfs4ds/agent/graph.py
from tdfs4ds.agent import consumer_agent
answer = consumer_agent("What is provider_z_score?", thread_id="demo")
# the shape evaluation uses: answer + trace + accumulated context graph
answer, trace, ctx = consumer_agent(
"and is it up to date?",
thread_id="demo",
return_trace=True,
return_context=True,
mcp_enabled=False,
)
Bilingual: French and English questions route through the same classifier.
Where the scope stops
Knowing what the agent refuses to do is what makes an evaluation set honest — half of these boundaries only show up as a scenario that should fail.
It does not write — with one exception
No ingestion, no run(), no DDL. The single writing skill is
BUILD_DATASET, which registers a dataset view on request.
Treat it as a privileged skill when you scope access.
It does not query raw data by default
Returning actual rows is the DATA_QUERY skill, and that exists only
when MCP is enabled and MCP_SERVER_URL is set. With MCP off, the agent
talks about data, never returns it.
It answers only what is documented
An undocumented store produces a mute agent: definitions come from the business dictionary, not from the model's priors. This is a deliberate constraint, and it is the single biggest prerequisite.
It declines off-topic questions
OUT_OF_SCOPE is a real intent with a real node. A deterministic rescue re-routes it when the question actually names a catalog object, so a classifier wobble does not turn an in-scope question into a refusal.
An answer that is fluent but ungrounded is the failure mode that matters. That is why the judge scores faithfulness against catalog facts rather than against a gold prose answer — there is no gold prose.
One LangGraph pass per turn
Three nodes resolve what is being asked about before any skill runs. That ordering is why a pronoun in turn 2 still lands on the right feature.
- classify
- Structured-output intent classification over a Pydantic model built
from the installed skills. Also extracts
object_name,entity_name, a confidence, and candidate interpretations. - detect_domain
- Resolves which
DATA_DOMAINowns the named object, and keeps it sticky across turns so an ambiguous name does not silently flip project. - recognize_entity
- Pins the entity when the question named one, so a feature that exists under two entities is not sent to a clarification prompt unnecessarily.
- route
- Conditional edge. A pending clarification short-circuits straight to
synthesize; a compound question goes toskill_compound, which runs each sub-intent in order; EXPLAIN passes throughpre_skill_explainfirst. - synthesize
- Most skills return their final answer directly — only SEARCH pays for a second LLM pass. That is a latency decision, not an oversight.
Checkpointing: MemorySaver, keyed on thread_id. The compiled graph is a module singleton — rebuilding it per call would discard all cross-turn state.
The skill roster
Fourteen skills ship as ca-*/SKILL.md files; two more are
injected in code; two require MCP. The classifier's vocabulary is assembled from whatever
is installed — there is no hard-coded intent list.
| Intent | Answers | Reads |
|---|---|---|
SEARCH | "find me features about X" | vector index + feature catalog |
DEFINITION | what a feature, column or dataset means | business dictionary |
EXPLAIN | how a feature is computed (the SQL formula) | SHOW VIEW DDL |
FRESHNESS | is it up to date, did it fail | FS_FOLLOW_UP |
LINEAGE | where it comes from, what depends on it | FS_LINEAGE_GRAPH |
SUMMARY | inventory of a domain or dataset | feature catalog |
DATA_DOMAINS | which projects exist, how big | feature catalog |
DATASET | which datasets expose a feature | dataset catalog |
USAGE | paste-ready SELECT per dataset | dataset + feature catalog |
FEASIBILITY | "can I build X with what exists?" | catalog + dictionary |
BUILD_DATASET | creates a dataset for a use case | writes a dataset view |
HELP | the store's own concepts | nothing — fixed knowledge |
RECAP | what we discussed this conversation | conversation memory only |
OUT_OF_SCOPE | declines politely | — |
ALGORITHM | the ML model behind a feature | tdstone2 repositories |
DATA_QUERY | actual rows and aggregations | MCP server tools |
The certainty boundary
The agent's honesty rests on one distinction, and your evaluation set has to encode it or the judge will reward confident guessing.
A feature is governed
It belongs to a DATA_DOMAIN and an entity. The entity
is the functional key, so the relationship between a feature and its grain is
certain. It is produced by a registered process, which gives it
lineage, freshness, an explainable formula and a reusable identity.
Identity is the triplet (domain, entity, feature name) — the same name under two entities is two distinct features.
A non-feature column is free
It has no entity and no domain, so its functional key and its relationships to sibling columns are not certain. They can be inferred from the object's documented columns — as an interpretation, never asserted as fact.
What is certain: which object owns it, and where that object sits in lineage.
Scenarios generated for a non-feature column carry a certainty_caveat in
expected_facts. When present, the judge is instructed to penalise faithfulness
if the answer asserts a functional key or a column relationship as fact instead of
framing it as a documentation-derived interpretation.
What survives between turns
Multi-turn evaluation is only meaningful because these carry forward. Each one is a thing a follow-up question can break.
- resolved_data_domain
- The active project. Sticky — an ambiguous feature name does not flip it; only an explicit object that resolves elsewhere does.
- resolved_feature_triplet
- feature name, entity, process id, view name for the last single feature resolved.
- resolved_entity_name
- The entity in focus for this turn. Cleared when the turn names no entity; the sticky cross-turn one lives in the context graph.
- resolved_feature_list
- The group currently under discussion — what "are these features up to date?" refers to.
- resolved_column_sources
- Column to source-table map written after an EXPLAIN turn, so "what is that column?" can drill down.
- accumulated_context_graph
- The cross-turn knowledge graph: features, datasets, objects, columns and their edges. It powers the fast paths, the visualisation panel — and it is the second grounding source the evaluation judge reads.
The context graph is not just a cache. During evaluation it is passed to the judge as the list of names the agent actually grounded on, so a real-but-unlisted name is not scored as a hallucination.
Three ways to put it in front of people
1 · Python, in a notebook
from tdfs4ds.agent import consumer_agent
consumer_agent(
"Which datasets expose total_amount?",
thread_id="notebook",
)
The surface evaluation drives.
2 · Gradio chatbot
from tdfs4ds.agent import (
build_vector_index,
launch_chatbot_with_index,
)
launch_chatbot_with_index(
port=7860,
force_rebuild=False,
)
Builds the index, then opens the UI with an agent-trace panel.
3 · Server
python -m tdfs4ds.serve
An OpenAI-compatible /v1/chat/completions endpoint where the
model field selects the agent, plus an admin web shell with dashboard,
lineage, evaluation and maintenance panels.
report = tdfs4ds.check_configuration() # names the setting to fix
Seven checks: config loading, Vector Store auth, Vector Store service, LLM transport, instruct model, embedding model, MCP. It never raises and never prints a secret.
Each skill has a hard prerequisite in the store
A skill whose prerequisite is missing does not degrade gracefully into a worse answer — it degrades into no answer. Check this table before blaming the model.
| Skill | Needs | Populated by |
|---|---|---|
DEFINITION SEARCH |
Business dictionary rows | document=True at upload, or rebuild_documentation() |
SEARCH |
Vector index over those rows | build_vector_index() |
FRESHNESS |
FS_FOLLOW_UP rows with COMPLETED status |
every run() / roll_out() |
LINEAGE |
FS_LINEAGE_GRAPH edges |
upload_features, or rebuild_lineage_graph() |
EXPLAIN |
A permanent process view whose DDL is readable | upload_features registration |
DATASET USAGE |
Registered datasets with feature rows | build_dataset() |
| time travel | Valid-time catalogs and valid-time data | the temporal design — nothing to enable |
ALGORITHM |
tdstone2 repositories + MCP enabled | a tdstone2-backed feature |
DATA_QUERY |
MCP_SERVER_URL + reachable server |
deployment configuration |
This is also your scenario coverage checklist. Generating a FRESHNESS scenario against a store that has never executed a process produces a question nobody can answer, and a score that says nothing about the agent.
The functional log is what makes freshness answerable
There is no "last updated" column on a feature. Freshness is reconstructed from the execution history of the process that produces it — so the log is not telemetry, it is the answer.
# tdfs4ds/process_store/process_followup.py -- FS_FOLLOW_UP
RUN_ID VARCHAR(255) # one run
PROCESS_TYPE VARCHAR(255)
PROCESS_ID VARCHAR(255) # joins back to the process catalog
START_DATETIME TIMESTAMP(6) WITH TIME ZONE
END_DATETIME TIMESTAMP(6) WITH TIME ZONE
VALIDTIME_DATE TIMESTAMP(6) WITH TIME ZONE # the as-of date the run wrote
APPLIED_FILTER JSON(2000) # which FilterManager segment
STATUS VARCHAR(2048) # 'COMPLETED' / 'FAILED,...' / 'RUNNING'
The resolution chain is feature catalog → PROCESS_ID →
process catalog → follow-up rows. Read it directly with:
tdfs4ds.follow_up_report(process_id="550e8400-...").to_pandas()
Per-segment freshness
A process driven by a FilterManager writes one row per segment, distinguished
by APPLIED_FILTER. It can be fresh for one segment and stale for another,
so the headline verdict reports the stalest segment and lists the breakdown.
A single max() over all rows would hide exactly the case you care about.
The RUNNING trap
A crashed run leaves STATUS = 'RUNNING' because the close never executed.
Treat a stale RUNNING as potentially incomplete data, not as work in progress —
and write an evaluation scenario for it, because it is the one freshness state
that looks healthy and is not.
Temporal catalogs, and temporal data
Time travel needs both halves. Temporal catalogs alone tell you what was registered on a date; temporal feature data tells you what the values were. Freshness questions sit between the two.
The hidden-table pattern
Every catalog table carries a _HIDDEN suffix and is mutable. A public view
without the suffix provides immutable read access and applies the valid-time predicate.
That is what makes a catalog read safe to point a conversational agent at.
A consequence worth remembering: the public view already applies
CURRENT VALIDTIME, so prefixing your own on top of it errors.
Travelling
# None = current state
tdfs4ds.FEATURE_STORE_TIME = None
# a timestamp = point-in-time read
tdfs4ds.FEATURE_STORE_TIME = "2024-01-01 00:00:00"
The follow-up log records the VALIDTIME_DATE each run wrote, so a roll-out
over a timeline leaves an auditable trail of which as-of date produced which values.
"Is this feature up to date?" is not a question about now. It is a question about the gap between the latest successful valid-time the process wrote and the date the user cares about — which only exists if both the catalog and the data are temporal.
The business dictionary is the agent's vocabulary
Three valid-time tables, three levels, designed so retrieval never needs chunking: rows are already the right grain.
| Level | Table | Key | Carries |
|---|---|---|---|
| 0 | FS_BUSINESS_DICTIONARY_OBJECTS |
database, object | object type T/V/D, business description |
| 1 | FS_BUSINESS_DICTIONARY_SECTIONS |
database, object, section | section content — OVERVIEW, ENTITY, FEATURE_THEMES, BUSINESS_QUESTIONS, INTENDED_AUDIENCE |
| 2 | FS_BUSINESS_DICTIONARY_COLUMNS |
database, table, column | business description — this is where a feature's definition lives |
A feature's description is a column-level row on its process view, not a
standalone object. The chain is entity → feature versions → process id → view name →
FS_BUSINESS_DICTIONARY_COLUMNS where table = view name and column = feature name.
# populate, then index -- in that order
tdfs4ds.rebuild_documentation(force_update=False, all_data_domains=True)
from tdfs4ds.agent import build_vector_index
build_vector_index(force_rebuild=False)
Backends: Chroma (local, default) or the Teradata Enterprise Vector Store
(VECTOR_STORE_BACKEND = 'teradata').
Drilling from a feature down to the Python that produced it
When a feature is produced by tdstone2 — in-database Python running in a Script Table Operator — EXPLAIN can only show you the SQL wrapper. ALGORITHM walks the chain into the model itself.
- algorithm
- e.g.
IsolationForest, named from the model metadata - hyperparameters
- parsed from the
ARGUMENTSJSON - model_inputs
column_names_X— and their business descriptions, resolved from the dictionary so inputs are explained, not just listed- packages
- extracted from the source, only when the question asks about code
- n_trained_partitions
- one model per partition — how many were actually trained
- sto_invocation
- the real
FROM Script(...)block, quoted verbatim
Two implementation facts
The code column is a BLOB and must be read with FROM_BYTES(CODE,'ascii');
a plain cast to VARCHAR errors. And the skill is available only when MCP is
enabled — with MCP off the classifier cannot emit the intent at all, and an
algorithm-shaped question gets a fixed "enable MCP" reply.
Two things it will not claim
Exact package versions are not stored in tdstone2 metadata, so the answer says so rather than guessing. And when the training timestamp is the open-end sentinel (year 9999), the training date is reported as unavailable rather than printed as a date.
Registering tdstone2 output as governed features: tdfs4ds.upload_tdstone2_scores(model).
A skill is a Markdown file with frontmatter
The file is the source of truth for the intent name, the classifier's description of it, and the few-shot examples. Editing the prompt means editing this file.
# tdfs4ds/skills/ca-freshness/SKILL.md
---
name: "Consumer Agent: Freshness"
intent: "FRESHNESS"
description: "user asks whether a feature/dataset is up to date or reliable"
argument-hint: "[feature or dataset name]"
allowed-tools: "Read, Grep, Glob"
---
Check whether a feature or dataset is current and reliable by inspecting
the execution history of its registered process.
## Queries # the resolution chain, documented
## Returns # every key the skill puts on its result dict
## Error cases # every `error` value a caller must handle
## Notes # the RUNNING trap, per-segment freshness
## Classifier Examples
- "Cette feature est-elle a jour ?" -> FRESHNESS
What the agent reads from it
intent becomes a value in the dynamic Pydantic model the classifier must
emit; description goes into the classifier system prompt. A skill that is
not installed is a word the classifier physically cannot say.
What a human reads from it
The Returns and Error cases tables are the contract. When you write an evaluation scenario for a skill, its Error cases table tells you which failure modes are worth a scenario of their own.
Taking the skills somewhere else
The skills are ordinary files shipped inside the package. Nothing about them is specific to our agent runtime.
Export them
tdfs4ds.list_skills()
# ['ca-algorithm', 'ca-build-dataset', ..., 'fs-upload', 'td-explain']
tdfs4ds.skill_path("ca-freshness") # path to the SKILL.md
tdfs4ds.install_skills() # -> ~/.claude/skills/
tdfs4ds.install_skills("/srv/my-agent/skills",
skills=["ca-freshness", "ca-explain", "ca-lineage"],
overwrite=False)
# or from a shell, after pip install:
# tdfs4ds-install-skills
Extend the agent with your own
# point the agent at a folder of extra skills
export TDFS4DS_SKILLS_FOLDER=/srv/my-agent/skills
# each subdirectory needs a SKILL.md with name/intent/description,
# and optionally a skill.py defining a single entry point:
# def node(state: dict) -> dict: ...
With skill.py
The intent is registered as a graph node in a local copy of the intent map — the module-level map is never mutated — and becomes routable. A load failure warns and skips rather than breaking the graph.
Without it
The skill is classifier-visible but not routable: its description shapes intent classification without adding behaviour. Useful for steering the classifier away from a category you handle elsewhere.
Inspect what the agent will actually load: tdfs4ds.consumer_agent_skill_catalog()
— bundled skills plus plugin ones, the latter tagged plugin: "true".
What we are actually protecting against
The agent has no test suite in the usual sense, because its output is prose. What it has is a harness that scores prose against catalog facts — and it exists because four things move underneath it.
The model changes
A gateway swap, a version bump, a different provider. Classification accuracy and instruction-following move together and neither is announced.
The prompts change
A description edited in one SKILL.md shifts the classifier's decision
boundary for every other skill, because they share one prompt.
The store changes
New domains, renamed features, a feature that now exists under two entities. Answers that were complete become partial without any code changing.
The documentation changes
Re-documentation rewrites what DEFINITION can say. A regression here is invisible until a user reads a definition that contradicts the formula.
No external eval framework. The judge reuses the configured instruct model through the agent's own call seam, and "expected answers" are structured catalog facts, not gold prose — because gold prose would be obsolete the moment the store changed.
Five dimensions, scored 1 to 5, per turn
Each turn gets all five. Scenario and category averages are rolled up from them; nothing is collapsed into a single number before you see the breakdown.
Grounded in real catalog facts, with no fabricated names. The dimension that matters most, and the one with a deterministic backstop underneath it.
Did it cover what the question asked. For set-returning skills the judge is told to reward coverage, not penalise a long list of real names.
Does the answer address the question that was asked, rather than an adjacent one it knows more about.
Correct intent, and handles the phrasing — a pronoun, a typo, a French question. A wrong intent caps this at 2, deterministically.
Concise, no wasted tokens. The turn's real token count is put in front of the judge rather than left to its impression of length.
Reported alongside them: intent_match_rate — a deterministic
measurement, not a judged one.
The judge reads two sources of truth, not one
This is the single most important thing to understand before you write
expected_facts by hand — because it determines what a missing fact means.
1 · Ground-truth facts
Your expected_facts for the turn. Treated as the focal
facts — a spot-check anchor, explicitly not a closed-world checklist of every
valid name.
2 · Verified catalog names
Every name the agent actually grounded on this turn, harvested from its accumulated context graph: features, entities, views, datasets, objects, columns, and the identifiers in an EXPLAIN formula.
A name appearing in either source is real. Faithfulness is penalised only for a name in neither that is presented as a stored catalog fact. An interpretation the answer explicitly labels as not-documented is allowed.
Deterministic signals computed before the judge is asked
- Intent match — compared, then stated in the prompt. A mismatch caps robustness at 2 regardless of what the judge says.
- Fact hits and misses — which expected names literally appear in the answer, case-insensitively, listed for the judge.
- Counts —
feature_countand the per-entity breakdown are catalog-derived. A correct count registers as a hit, so the judge cannot flag a right number as fabricated. - The faithfulness cap fires only on a genuine fabrication signal: the focal fact absent and no verified context at all. It fails open.
Anatomy of a single-turn scenario
One scenario, one thread, one turn. This is the shape
generate_scenarios emits and the shape you hand-write.
{
"id": "S01",
"category": "standard",
"description": "Single-turn DEFINITION",
"multi_turn": false,
"skills": ["DEFINITION"],
"uses_pronouns": false,
"thread_id": "eval_definition_0",
"turns": [
{
"turn": 1,
"user": "What is provider_z_score?",
"expected_intent": "DEFINITION",
"expected_skill": "skill_definition",
"expected_facts": {
"domain": "claims_anomaly_detection",
"entity_name": "provider_id",
"feature_name": "provider_z_score",
"process_id": "550e8400-e29b-41d4-a716-446655440000",
"view_name": "FS_PROVIDER_FLAGS",
"sibling_features": ["provider_z_score", "pharmacy_z_score"]
}
}
]
}
- thread_id
- Isolates the conversation. Give every scenario its own, or turn 1 of one scenario will be visible to turn 1 of the next.
- expected_facts
- Fills itself from the catalog when generated. When you write one by
hand, the keys the judge checks literally are
feature_name,entity_name,domain,view_name,object_name,column_name, plus the listsfeature_namesandlineage_neighbors. - expected_intent
- Drives the deterministic intent-match signal — get this wrong and you have written a robustness failure, not a test.
Anatomy of a multi-turn scenario
Turn 2 deliberately omits the feature name. It is a test of memory, and it is the only way to catch a regression in domain stickiness or triplet resolution.
{
"id": "M01",
"category": "multi_turn",
"description": "Multi-turn DEFINITION -> FRESHNESS (pronoun)",
"multi_turn": true,
"skills": ["DEFINITION", "FRESHNESS"],
"uses_pronouns": true,
"thread_id": "eval_mt_definition",
"turns": [
{ "turn": 1,
"user": "What is provider_z_score?",
"expected_intent": "DEFINITION",
"expected_skill": "skill_definition",
"expected_facts": { "...": "as before" } },
{ "turn": 2,
"user": "and is it up to date?", // no name -- on purpose
"expected_intent": "FRESHNESS",
"expected_skill": "skill_freshness",
"expected_facts": { "...": "same feature, resolved from memory" } }
]
}
Both turns run against the same thread_id in order, so turn 2 sees whatever
turn 1 left in the checkpointer. The four follow-up pairs generated automatically are:
Set use_pronouns=False to regenerate the same pairs with the name
restated — run both and the difference isolates memory from retrieval.
Fact-first generation: harvest the answer, then write the question
Skill-first templating guarantees that every skill is exercised. It does not guarantee breadth — it will ask about the same two features forever. Fact-first fixes that by starting from the catalog instead of the template.
What it harvests
Documented non-feature objects and columns from the business dictionary, plus their upstream and downstream neighbours from the persisted lineage graph. Feature process views are excluded — those are already covered by the templates.
What it writes
Object DEFINITION and LINEAGE, column DEFINITION, and a column→object positioning multi-turn. The fact is the ground truth before the question exists, so the question can never drift from a fact that is not there.
Every column scenario stamps certainty_caveat into expected_facts,
which switches on the judge rule from earlier: asserting a functional key as fact costs
faithfulness. This is the mechanism that keeps the agent honest about non-features.
The two sets are unioned and deduplicated by question text — skill-first guarantees per-skill coverage, fact-first guarantees grounded breadth.
tdfs4ds.generate_scenarios(fact_first=True, facts_per_kind=2)
# ids prefixed FO.. for objects, FC.. for columns
Step 0 — the prerequisite that is not negotiable
Scenario generation reads the live catalogs. Against an empty or undocumented store it does not fail — it produces a small, plausible-looking scenario file that measures nothing. This is the failure to guard against, because it is silent.
| Without this | Generation still returns | But the run measures |
|---|---|---|
| Registered features | templates filled with the literal word feature |
nothing — the agent correctly cannot find it |
| Registered processes | scenarios with no process_id or view_name |
EXPLAIN and LINEAGE against a void |
| Business dictionary rows | zero fact-first scenarios | DEFINITION answering from nothing |
| Execution history | FRESHNESS scenarios | "never executed", every time |
| Registered datasets | DATASET scenarios with an empty dataset_names |
an unanswerable question |
| Lineage edges | fact-first scenarios with no neighbours | LINEAGE with no ground truth to hit |
print(tdfs4ds.feature_catalog(all_data_domains=True).shape)
print(tdfs4ds.process_catalog(all_data_domains=True).shape)
print(tdfs4ds.dataset_catalog().shape)
print(tdfs4ds.follow_up_report().to_pandas().shape)
Four non-empty results, and a store that has been documented and indexed. Anything less and you are evaluating your store, not your agent.
Step 1 — connect, and configure the model that will judge
The judge is the same instruct model the agent uses. It is called through the agent's own seam, so if the agent can talk, the judge can score.
Open the database context and point at the store
import teradataml as tdml
import tdfs4ds
tdml.create_context(host=HOST, username=USER, password=PWD)
tdfs4ds.SCHEMA = "MY_FEATURE_STORE"
tdfs4ds.DATA_DOMAIN = "claims_anomaly_detection"
tdfs4ds.connect(database=tdfs4ds.SCHEMA, create_if_missing=False)
Configure the instruct model
tdfs4ds.INSTRUCT_MODEL_PROVIDER = "openai-compatible"
tdfs4ds.INSTRUCT_MODEL_URL = "https://gateway.internal/v1"
tdfs4ds.INSTRUCT_MODEL_API_KEY = "..."
tdfs4ds.INSTRUCT_MODEL_MODEL = "ministral-8b"
Prove it before you spend an evaluation run on it
report = tdfs4ds.check_configuration()
# every check carries a hint naming the setting to fix
The judge emits JSON. If your gateway accepts a structured-output request without
honouring it, the judge degrades to a default score of 3 with an explicit
judge_error — a result that looks like mediocrity and is actually a
configuration fault. Pin the mechanism with
tdfs4ds.STRUCTURED_OUTPUT_METHOD if the check reports trouble.
Step 2 — generate the grounded baseline
One call. It reads the live catalogs, fills the templates with real feature, entity and domain names, and attaches the ground truth each skill needs.
scenarios = tdfs4ds.generate_scenarios(
skills=["DEFINITION", "EXPLAIN", "FRESHNESS", "SUMMARY",
"DATA_DOMAINS", "FEASIBILITY", "LINEAGE", "USAGE", "DATASET"],
multi_turn=True, # also emit the four follow-up pairs
use_pronouns=True, # turn 2 omits the name -- tests memory
per_skill=1, # single-turn scenarios per skill
fact_first=True, # harvest objects/columns from the dictionary
facts_per_kind=2, # how many objects, how many columns
)
tdfs4ds.save_scenarios(scenarios, "golden_scenarios.json")
- skills
- Omit it to cover every templated skill. Pass a list to iterate on one.
- categories
["standard"]or["multi_turn"]to restrict; both by default.- per_skill
- Raise it and each skill gets a different feature — the templates walk the catalog rather than repeating one name.
- facts_per_kind
- Governs breadth over documented objects and columns. This is the knob that grows the set without touching the templates.
Generation is catalog reads only. It is cheap, repeatable, and safe to run against production — no LLM tokens are spent until Step 5.
Step 3 — read what it actually produced
Do this every time. A generated set is grounded in the catalog, which is not the same as being a good test of anything.
print(scenarios["meta"])
# {'scenario_version': '20260915_104233',
# 'scenario_hash': 'a1b2c3d4e5f6', <- content fingerprint
# 'generated_at': '20260915_104233',
# 'tdfs4ds_version': '0.3.1.208',
# 'n_scenarios': 17,
# 'domains': ['claims_anomaly_detection'],
# 'fact_first': True}
for s in scenarios["scenarios"]:
print(f'{s["id"]:5} {s["category"]:11} {len(s["turns"])}t {s["turns"][0]["user"]}')
What you are looking for
- Placeholder names. A question containing the literal word
featuremeans the feature catalog read came back empty. Stop and go back to Step 0. - Repetition. The same feature in every scenario means
per_skill=1and a small catalog — raise it, or add hand-written scenarios. - Empty ground truth. A DATASET scenario with
"dataset_names": []is unanswerable; drop it or register the dataset. - The domains list. If it holds one domain, you are not testing domain stickiness at all — that needs two.
Keep the hash. Two results files with different scenario_hash values
are not comparable, however similar the averages look.
Step 4 — curate: add what generation cannot reach
Templates exist for nine intents. The rest of the roster has no template at all, and those are exactly the intents where a regression is most embarrassing.
Blue: generated for you. Red: you write these, or they are never tested.
hand = {
"scenarios": [
{
"id": "H01",
"category": "standard",
"description": "OUT_OF_SCOPE must decline, not improvise",
"multi_turn": False,
"skills": ["OUT_OF_SCOPE"],
"uses_pronouns": False,
"thread_id": "eval_hand_oos",
"turns": [{
"turn": 1,
"user": "What is the weather in Paris tomorrow?",
"expected_intent": "OUT_OF_SCOPE",
"expected_skill": "skill_out_of_scope",
"expected_facts": {}
}]
},
{
"id": "H02",
"category": "standard",
"description": "FRESHNESS on a segmented process",
"multi_turn": False,
"skills": ["FRESHNESS"],
"uses_pronouns": False,
"thread_id": "eval_hand_segments",
"turns": [{
"turn": 1,
"user": "Is provider_z_score up to date for every segment?",
"expected_intent": "FRESHNESS",
"expected_skill": "skill_freshness",
"expected_facts": {
"feature_name": "provider_z_score",
"entity_name": "provider_id",
"domain": "claims_anomaly_detection"
}
}]
}
],
"meta": {}
}
merged = tdfs4ds.merge_scenarios(scenarios, hand, mode="append")
tdfs4ds.save_scenarios(merged, "golden_scenarios.json")
mode="append" unions by id, incoming wins on conflict.
mode="replace" returns the incoming set untouched.
Step 5 — run the agent and judge every turn
Each scenario runs on its own thread, turns in order. Each turn is judged immediately, with the agent's own context graph passed to the judge.
results = tdfs4ds.run_evaluation(
merged,
mcp_enabled=False, # True to also exercise ALGORITHM / DATA_QUERY
progress=print, # emits 'PROGRESS k/N' lines
)
Or the whole pipeline from a file in one call:
out = tdfs4ds.evaluate_agent(
"golden_scenarios.json",
out_path="agent_eval_report.html",
fmt="html",
results_path="agent_eval_results.json",
progress=print,
)
print(out["report_path"], out["results_path"])
What lands in the results file
- per turn
- question, the agent's answer, expected vs actual intent,
fact_hits/fact_misses, the five scores, the judge's rationale per metric, the trace and the context graph - per scenario
- the five averages plus
avg_score - stamped
- model and provider, tdfs4ds version, scenario version and hash — so a result can always be tied back to what produced it
Every turn is at least two model calls: the agent's, and the judge's. A 17-scenario set
with follow-ups is around 25 turns. Budget accordingly, and iterate on a
skills=[...] subset before running the whole set.
Step 6 — report, then triage
html = tdfs4ds.build_report(results, fmt="html") # or fmt="md"
The report opens with the global averages and the intent-match rate, then breaks down by category and by expected intent, then lists the worst turns, then every turn in full with its trace and judge rationale.
Reading it in the right order
intent_match_rate first
It is deterministic. If it dropped, the classifier moved — and every other metric is downstream of that. Fix routing before you read a single rationale.
Then the per-skill table
A single skill dragging its row down is a prompt or a prerequisite problem. All rows down together is a model or configuration problem.
Then judge_error
A non-empty value means the judge failed to return parsable JSON and the score defaulted to 3. Those turns are not evidence of anything — exclude them and fix the structured-output path.
Then the worst turns, individually
# the eval-inspect skill ships a stdlib reader -- no DB, no LLM
python inspect.py agent_eval_results.json --worst 5
python inspect.py agent_eval_results.json --scenario S17
python inspect.py agent_eval_results.json --intent-mismatchRegeneration discipline
A golden set is only golden if it stays still. Regenerating it against a changed catalog produces a different test, and comparing across that boundary is how a regression gets explained away.
Commit the scenario file
It is the test suite. Version it beside the code, not beside the results.
The scenario_hash is a content fingerprint stable across reordering —
two runs are comparable if and only if it matches.
Regenerate deliberately
When the catalog genuinely changes: regenerate, diff, re-merge your hand-written scenarios, and record that the baseline moved. Never silently.
Change one thing per run
The results stamp the model, the provider and the package version. Move one of them at a time or the report tells you something changed without telling you what.
Keep the hand-written scenarios separate
Generated scenarios are disposable; hand-written ones encode real incidents. Keep them
in their own file and merge_scenarios them in, so a regeneration can never
discard them.
Generate once per catalog change. Run on every prompt or model change. Read
intent_match_rate on every run, the full report when it moves.
What this does not prove — and what to do next
An LLM judges an LLM
The scores are correlated with the judge's own weaknesses. The deterministic signals — intent match, fact hits, count checks — are the part you can trust unconditionally. Weight them accordingly.
Templates are narrow
One or two phrasings per skill. Real users are stranger than that. Hand-written scenarios are not an optional extra; they are where the coverage actually comes from.
Facts are focal, not complete
expected_facts is a spot-check by design. A complete answer can miss a
focal fact and a shallow one can hit it — which is exactly why the verified-names
source exists alongside it.
It measures answers, not outcomes
A five across the board means the agent described the store correctly. Whether the store deserved to be described that way is a different question, and the dashboard and freshness reports are where you ask it.
Next steps for the team
- Run Step 0's four checks against the store you intend to evaluate, and fix what is empty.
- Generate a baseline, read it, and commit it with its hash.
- Write the six missing-intent scenarios by hand — SEARCH, HELP, RECAP, BUILD_DATASET, OUT_OF_SCOPE, and one ALGORITHM if you run tdstone2 features.
- Run it, record the numbers, and treat that as the line every prompt change must hold.
tdfs4ds/agent/evaluation.py
· agent: tdfs4ds/agent/graph.py, consumer_agent.py
· skills: tdfs4ds/skills/ca-*/SKILL.md