TUCAN Intent and Filtering — Query Classification
Audiences: developer, internal
Before any clinical tool fires, TUCAN converts the user's prompt into a typed
ToolCategory(one of 13 values) and looks up anIntentDescriptorthat drives planner budget, intake guards, tool pool filtering, reporter mode, and workflow fallback. One central registry — every downstream decision reads it.
Business Purpose
The 350+ tools in the catalog are not all relevant to every prompt. A patient asking "when is my next appointment?" needs scheduling + session + FHIR tools — never the prescriptions catalog. Conversely, "prescribe amoxicillin" must NEVER reach scheduling tools.
Intent filtering is the typed, centralized funnel that:
- Bypasses the LLM entirely for trivial inputs (greetings, acknowledgments, identity probes).
- Rewrites the raw user prompt into a structured
SessionSummarywith atoolCategory: ToolCategoryfield — done by a cheap Mastra sub-agent. - Looks up an
IntentDescriptorfor the chosen category. The descriptor encodes everything the orchestrator needs to know: tool categories, required tool IDs, allowed task types, intake guard on/off, replan budget, reporter mode, and the workflow fallback for the planner.
This is the layer that makes the system deterministic per category even though the LLM picks the category. The LLM picks one of 13 options; everything after that is table-driven.
Audiences
- Investor: Per-intent budgets and guards are why clinical
responses are fast (no retries on
clinicalintents) and patient intake responses are correct (retry loop with intake-completeness guard onintake-assessment). - Developer / partner: Adding a new intent is two edits — add to
ToolCategoryunion + add toINTENT_DESCRIPTORS. Compilation fails if you forget either side. The orchestrator's tool resolver, guards, and reporter all read the descriptor — they do not branch on intent strings. - Internal (ops / support): When a turn picks the wrong tools,
session.metadata.executionPlanshows the resolved descriptor. SQL onchat_messages.metadatais the audit trail.
Architecture
| Stage | Behavior |
|---|---|
TrivialBypassStage (pre-rewriter phase) | trivial-input.classifier → 'greeting'|'ack'|'identity_probe' · short-circuit: emit synthesized response, skip LLM |
RewriterStage (Mastra sub-agent, cheap model) | rewriter/prompt-rewriter.service.ts · emits SessionSummary { toolCategory, … } |
IntentClassifierStage | resolves toolCategory → IntentDescriptor · publishes agent:intent-classified SSE event |
Downstream consumers read IntentDescriptor
| Consumer | Reads |
|---|---|
planner | maxReplanAttempts, allowedTaskTypes |
intake-guard | runIntakeGuard (true only for intake) |
tool-resolve | toolCategories + requiredToolIds |
reporter | reporterMode (narrative|document|card-list) |
workflow | fallbackWorkflowId on planner no-route |
The 13 ToolCategory values
export type ToolCategory = | 'scheduling' | 'clinical' | 'patient-data' | 'visit-workflow' | 'intake-assessment' | 'search' | 'navigation' | 'communication' | 'documentation' | 'billing-insurance' | 'administration' | 'q-and-a' | 'general';
general is the fallback. UNKNOWN_DESCRIPTOR covers any rewriter
output that does not match the union — conservative defaults: guard
OFF, no retries, full RAG corpus.
INTENT_TOOL_POOLS and UNIVERSAL_TOOLS
For each intent, a tool pool is derived from the catalog by category.
UNIVERSAL_TOOLS (e.g. get-current-time, voice/reply-to-operator
when voice) are appended to every pool. REQUIRED_POOL_TOOL_IDS
assert that specific tool IDs MUST be RAG-reachable from the pool —
catches the case where a tool gets renamed and silently disappears
from the intent's pool.
getToolPoolForIntent(intent) and isIntentFullCorpus(intent) are
the helper functions every downstream consumer goes through —
direct iteration on INTENT_TOOL_POOLS is discouraged.
TrivialBypassStage — the pre-LLM funnel
TrivialBypassStage runs before the rewriter and again after
(post-rewriter phase, in case the rewrite revealed a trivial intent).
It classifies into:
greeting— "hi", "hello", "guten tag", "bonjour"ack— "ok", "thanks", "got it"identity_probe— "who are you", "what can you do"
On hit, the stage emits a synthesized response (template, locale-aware) without ever invoking the rewriter or the dispatcher's LLM call. This keeps trivial turns at <100 ms and saves ~99% of the per-turn cost.
The classifier (dispatch/trivial-input.classifier.ts) is regex +
lookup; a Mastra sub-agent (agents/trivial-bypass.agent.ts) is
registered but the production path uses the deterministic classifier
for latency.
Tech Stack & Choices
| Component | Choice | Why |
|---|---|---|
| Intent label | ToolCategory string union (13 values) | One typed enum; compile-time safe; no magic strings. |
| Descriptor lookup | INTENT_DESCRIPTORS: Record<ToolCategory, IntentDescriptor> + UNKNOWN_DESCRIPTOR fallback | Total function — every intent resolves. |
| Classifier (rewriter) | Mastra sub-agent with its own cheap model config | Separate prompt + separate model so production LLM is not used for cheap rewrites. |
| Trivial-input fast path | Regex/lookup classifier in trivial-input.classifier.ts | Sub-100ms; deterministic. |
| Intent → tool pool | getToolPoolForIntent(intent) reading INTENT_TOOL_POOLS | Centralized; auto-tested per category. |
| Required-tool guard | REQUIRED_POOL_TOOL_IDS per intent | Catches tool renames that orphan an intent. |
| SSE surface | agent:intent-classified event published by IntentClassifierStage | Frontend can show "Routing to scheduling…" hint. |
| Planner consumer | reads descriptor.allowedTaskTypes + maxReplanAttempts | Per-intent budgeting, not global. |
| Reporter consumer | reads descriptor.reporterMode (narrative / document / card-list / pass-through) | Document-intent gets the document template; clinical gets the narrative HTML. |
| Workflow fallback | descriptor.fallbackWorkflowId ('patient-intake' / 'patient-search' / 'free-chat') | When the planner emits no workflow, the dispatcher falls back to a known workflow ID. |
Alternatives rejected:
- Free-form intent strings — would lose compile-time safety and let new intents leak into production without descriptor coverage.
- Per-stage intent string branches — exactly what the descriptor pattern replaces; scattered branches made guards inconsistent.
- LLM-driven routing per turn —
[[project_tucan_no_guided_consumers]].
Data Flow
Business outcome: A receptionist types "book Mrs. Garcia for next Tuesday at 10am with Dr. Huber." The turn picks the scheduling tool pool only; no clinical or document tools enter the prompt.
Technical mechanism:
TrivialBypassStage.tryBypassreturnsbypass: false(not a trivial input) (ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:47).RewriterStage.runinvokes the rewriter sub-agent →SessionSummary { toolCategory: 'scheduling', … }.IntentClassifierStage.publishresolves the descriptor:INTENT_DESCRIPTORS.scheduling={ toolCategories: ['scheduling','session','fhir'], allowedTaskTypes: ['SEARCH','CREATE','UPDATE','NOTIFY'], maxReplanAttempts: 0, runIntakeGuard: false, ... }(ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:69).- The planner reads
allowedTaskTypesand emits aCREATEtask forbook-appointment(cannot emitWORKFLOW_RUN— not in the whitelist).- Tool resolver calls
getToolPoolForIntent('scheduling')— only scheduling + session + fhir tools enter the agent's Mastra catalog.- Dispatcher runs; the LLM cannot pick
create-prescriptionbecause it is not in the bound catalog.- Reporter narrates in
narrativemode per the descriptor.
Implicated Code
ddx-api/src/ai/tucan/rewriter/prompt-rewriter.service.ts:51—export type ToolCategory = ...(the 13-value union).ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:34—interface IntentDescriptor(the per-intent contract).ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:57—UNKNOWN_DESCRIPTORconservative fallback.ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:68—INTENT_DESCRIPTORSmap.ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:69-100—schedulingandclinicalexample entries.ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts—resolveIntentDescriptor(toolCategory)helper.ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts—isToolCategory(value)runtime guard.ddx-api/src/ai/tucan/intent/intent-category-map.ts:24—UNIVERSAL_TOOLSlist (appended to every pool).ddx-api/src/ai/tucan/intent/intent-category-map.ts:39—INTENT_TOOL_POOLSderived map.ddx-api/src/ai/tucan/intent/intent-category-map.ts:52—REQUIRED_POOL_TOOL_IDSassertions.ddx-api/src/ai/tucan/intent/intent-category-map.ts—getToolPoolForIntent(intent)andisIntentFullCorpus(intent)helpers.ddx-api/src/ai/tucan/dispatch/stages/intent-classifier.ts:24— classIntentClassifierStage.ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:47— classTrivialBypassStage.ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:27—type TrivialCategory = 'greeting' | 'ack' | 'identity_probe'.ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:176— reference to first-class Mastra trivial-bypass agent (registered but the deterministic classifier path is the production hot path).ddx-api/src/ai/tucan/dispatch/trivial-input.classifier.ts— regex/lookup classifier behind TrivialBypassStage.
Operational Notes
ToolCategoryis the single source of truth. The 13 values in the union dictate the keys ofINTENT_DESCRIPTORSandINTENT_TOOL_POOLS— adding an intent without adding a descriptor entry fails compilation (theRecord<ToolCategory, …>enforces exhaustiveness).maxReplanAttempts: 0is the default. Onlyintake-assessmentopts into the retry loop (because clinical intake completeness is worth one retry on a guard failure). SettingmaxReplanAttemptshigh on other intents bloats per-turn cost without product win.runIntakeGuardis forintake-assessmentonly. It runsassessIntakeCompleteness()after the dispatcher returns to check that all required intake forms have been touched; on miss, the planner gets one replan attempt. Don't enable for other intents — the guard is intake-shape-specific.fallbackWorkflowIdmust reference a registered workflow. Allowed values:'patient-intake','patient-search','free-chat'. Adding a new fallback requires adding a workflow with that ID to the workflow registry.- Trivial bypass is locale-aware. German + English greetings are both in the classifier. New language = update both the classifier list and the synthesized response templates.
- The rewriter has its own cheap model. Per-turn cost lives mostly in the dispatcher's Mastra Agent call, not the rewriter. Don't optimize the rewriter to skip; it pays for itself by reducing the dispatcher's input prompt size.
patient-datais a hint for "look up this person" intents. When the recent 2-turn history ispatient-data→patient-data, the rewriter sticks with the same intent for continuity (prompt-rewriter.service.ts:1001). Avoid silently flipping away.generalintent uses the full corpus. Tool-RAG widens its return set and the planner has noallowedTaskTypesrestriction. Intentionally permissive for catch-all queries.
Related Topics
- tucan-dispatcher — runs the intent classifier as Stage 7 of the pipeline.
- tucan-assistant — orchestration entry point that runs the pipeline.
- tucan-orchestrator — planner that reads
allowedTaskTypesandmaxReplanAttempts. - ai-medical-tools — catalog from which
INTENT_TOOL_POOLSis derived. - tucan-rag-tools — uses the intent's tool pool as a Qdrant filter when ragEnabled is true on the agent template.
- agent-templates — YAML templates declare their own category whitelist; intent classifier narrows further per-turn.
- tucan-context-builder — supplies prior intent history that the rewriter uses for continuity.