Teradata · tdfs4ds Consumer Agent Field Guide 1 / 31

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.

00Route
01Scope

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.

01Boundaries

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.

Consequence for evaluation

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.

01Pipeline

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→ detect_domain→ recognize_entity→ route→ skill_*→ synthesize→ END
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_DOMAIN owns 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 to skill_compound, which runs each sub-intent in order; EXPLAIN passes through pre_skill_explain first.
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.

01Skills

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.

IntentAnswersReads
SEARCH"find me features about X"vector index + feature catalog
DEFINITIONwhat a feature, column or dataset meansbusiness dictionary
EXPLAINhow a feature is computed (the SQL formula)SHOW VIEW DDL
FRESHNESSis it up to date, did it failFS_FOLLOW_UP
LINEAGEwhere it comes from, what depends on itFS_LINEAGE_GRAPH
SUMMARYinventory of a domain or datasetfeature catalog
DATA_DOMAINSwhich projects exist, how bigfeature catalog
DATASETwhich datasets expose a featuredataset catalog
USAGEpaste-ready SELECT per datasetdataset + feature catalog
FEASIBILITY"can I build X with what exists?"catalog + dictionary
BUILD_DATASETcreates a dataset for a use casewrites a dataset view
HELPthe store's own conceptsnothing — fixed knowledge
RECAPwhat we discussed this conversationconversation memory only
OUT_OF_SCOPEdeclines politely—
ALGORITHMthe ML model behind a featuretdstone2 repositories
DATA_QUERYactual rows and aggregationsMCP server tools
14 ship as SKILL.md RECAP + OUT_OF_SCOPE injected in build_graph ALGORITHM + DATA_QUERY need MCP
01Certainty

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.

How this reaches the judge

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.

01Memory

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.
Why this matters twice

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.

01Surfaces

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.

Before any of them
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.

02Map

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.

SkillNeedsPopulated 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
The same table read backwards

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.

02Log

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.

02Valid-time

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.

Why an agent needs this

"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.

02Dictionary

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.

LevelTableKeyCarries
0FS_BUSINESS_DICTIONARY_OBJECTS database, objectobject type T/V/D, business description
1FS_BUSINESS_DICTIONARY_SECTIONS database, object, sectionsection content — OVERVIEW, ENTITY, FEATURE_THEMES, BUSINESS_QUESTIONS, INTENDED_AUDIENCE
2FS_BUSINESS_DICTIONARY_COLUMNS database, table, columnbusiness description — this is where a feature's definition lives
The lookup that surprises people

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').

02tdstone2

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.

feature→ process view DDL→ TDS_STO_ uuid→ TDS_MAPPER_REPOSITORY→ trained model→ TDS_MODEL_REPOSITORY→ CODE
algorithm
e.g. IsolationForest, named from the model metadata
hyperparameters
parsed from the ARGUMENTS JSON
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).

03Anatomy

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.

03Portability

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".

04Why

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.

The design choice

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.

04Dimensions

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.

faithfulness

Grounded in real catalog facts, with no fabricated names. The dimension that matters most, and the one with a deterministic backstop underneath it.

completeness

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.

relevance

Does the answer address the question that was asked, rather than an adjacent one it knows more about.

robustness

Correct intent, and handles the phrasing — a pronoun, a typo, a French question. A wrong intent caps this at 2, deterministically.

efficiency

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.

04Grounding

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.

The rule

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_count and 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.
04Single turn

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 lists feature_names and lineage_neighbors.
expected_intent
Drives the deterministic intent-match signal — get this wrong and you have written a robustness failure, not a test.
04Multi turn

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:

DEFINITION → FRESHNESS EXPLAIN → DEFINITION SUMMARY → FRESHNESS FEASIBILITY → DEFINITION

Set use_pronouns=False to regenerate the same pairs with the name restated — run both and the difference isolates memory from retrieval.

04Fact-first

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.

And it carries the certainty caveat

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
05Step 0

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 thisGeneration still returnsBut the run measures
Registered featurestemplates filled with the literal word feature nothing — the agent correctly cannot find it
Registered processesscenarios with no process_id or view_name EXPLAIN and LINEAGE against a void
Business dictionary rowszero fact-first scenarios DEFINITION answering from nothing
Execution historyFRESHNESS scenarios "never executed", every time
Registered datasetsDATASET scenarios with an empty dataset_names an unanswerable question
Lineage edgesfact-first scenarios with no neighbours LINEAGE with no ground truth to hit
Check before you generate, not after
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.

05Step 1

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.

1a

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)
1b

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"
1c

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.

05Step 2

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.
Nothing here calls the agent

Generation is catalog reads only. It is cheap, repeatable, and safe to run against production — no LLM tokens are spent until Step 5.

05Step 3

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 feature means the feature catalog read came back empty. Stop and go back to Step 0.
  • Repetition. The same feature in every scenario means per_skill=1 and 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.

05Step 4

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.

DEFINITIONEXPLAINFRESHNESS SUMMARYDATA_DOMAINSFEASIBILITY LINEAGEUSAGEDATASET SEARCHHELPRECAP BUILD_DATASETOUT_OF_SCOPE ALGORITHMDATA_QUERY

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.

05Step 5

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
Cost

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.

05Step 6

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

1

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.

2

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.

3

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.

4

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-mismatch
06Versioning

Regeneration 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.

A practical rhythm

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.

06Limits

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

  1. Run Step 0's four checks against the store you intend to evaluate, and fix what is empty.
  2. Generate a baseline, read it, and commit it with its hash.
  3. Write the six missing-intent scenarios by hand — SEARCH, HELP, RECAP, BUILD_DATASET, OUT_OF_SCOPE, and one ALGORITHM if you run tdstone2 features.
  4. Run it, record the numbers, and treat that as the line every prompt change must hold.
tdfs4ds 0.3.1.208  ·  evaluation harness: tdfs4ds/agent/evaluation.py  ·  agent: tdfs4ds/agent/graph.py, consumer_agent.py  ·  skills: tdfs4ds/skills/ca-*/SKILL.md