06-ai-tucanwave: W3filled15 citations

TUCAN Intent and Filtering — Query Classification

Audiences: developer, internal

TUCAN Intent and Filtering — Query Classification

Before any clinical tool fires, TUCAN converts the user's prompt into a typed ToolCategory (one of 13 values) and looks up an IntentDescriptor that 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:

  1. Bypasses the LLM entirely for trivial inputs (greetings, acknowledgments, identity probes).
  2. Rewrites the raw user prompt into a structured SessionSummary with a toolCategory: ToolCategory field — done by a cheap Mastra sub-agent.
  3. Looks up an IntentDescriptor for 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 clinical intents) and patient intake responses are correct (retry loop with intake-completeness guard on intake-assessment).
  • Developer / partner: Adding a new intent is two edits — add to ToolCategory union + add to INTENT_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.executionPlan shows the resolved descriptor. SQL on chat_messages.metadata is the audit trail.

Architecture

Rendering diagram…
StageBehavior
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, … }
IntentClassifierStageresolves toolCategoryIntentDescriptor · publishes agent:intent-classified SSE event

Downstream consumers read IntentDescriptor

ConsumerReads
plannermaxReplanAttempts, allowedTaskTypes
intake-guardrunIntakeGuard (true only for intake)
tool-resolvetoolCategories + requiredToolIds
reporterreporterMode (narrative|document|card-list)
workflowfallbackWorkflowId on planner no-route

The 13 ToolCategory values

ts
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

ComponentChoiceWhy
Intent labelToolCategory string union (13 values)One typed enum; compile-time safe; no magic strings.
Descriptor lookupINTENT_DESCRIPTORS: Record<ToolCategory, IntentDescriptor> + UNKNOWN_DESCRIPTOR fallbackTotal function — every intent resolves.
Classifier (rewriter)Mastra sub-agent with its own cheap model configSeparate prompt + separate model so production LLM is not used for cheap rewrites.
Trivial-input fast pathRegex/lookup classifier in trivial-input.classifier.tsSub-100ms; deterministic.
Intent → tool poolgetToolPoolForIntent(intent) reading INTENT_TOOL_POOLSCentralized; auto-tested per category.
Required-tool guardREQUIRED_POOL_TOOL_IDS per intentCatches tool renames that orphan an intent.
SSE surfaceagent:intent-classified event published by IntentClassifierStageFrontend can show "Routing to scheduling…" hint.
Planner consumerreads descriptor.allowedTaskTypes + maxReplanAttemptsPer-intent budgeting, not global.
Reporter consumerreads descriptor.reporterMode (narrative / document / card-list / pass-through)Document-intent gets the document template; clinical gets the narrative HTML.
Workflow fallbackdescriptor.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:

  1. TrivialBypassStage.tryBypass returns bypass: false (not a trivial input) (ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:47).
  2. RewriterStage.run invokes the rewriter sub-agent → SessionSummary { toolCategory: 'scheduling', … }.
  3. IntentClassifierStage.publish resolves 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).
  4. The planner reads allowedTaskTypes and emits a CREATE task for book-appointment (cannot emit WORKFLOW_RUN — not in the whitelist).
  5. Tool resolver calls getToolPoolForIntent('scheduling') — only scheduling + session + fhir tools enter the agent's Mastra catalog.
  6. Dispatcher runs; the LLM cannot pick create-prescription because it is not in the bound catalog.
  7. Reporter narrates in narrative mode per the descriptor.

Implicated Code

  • ddx-api/src/ai/tucan/rewriter/prompt-rewriter.service.ts:51export type ToolCategory = ... (the 13-value union).
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:34interface IntentDescriptor (the per-intent contract).
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:57UNKNOWN_DESCRIPTOR conservative fallback.
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:68INTENT_DESCRIPTORS map.
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.ts:69-100scheduling and clinical example entries.
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.tsresolveIntentDescriptor(toolCategory) helper.
  • ddx-api/src/ai/tucan/intent/intent-descriptor.registry.tsisToolCategory(value) runtime guard.
  • ddx-api/src/ai/tucan/intent/intent-category-map.ts:24UNIVERSAL_TOOLS list (appended to every pool).
  • ddx-api/src/ai/tucan/intent/intent-category-map.ts:39INTENT_TOOL_POOLS derived map.
  • ddx-api/src/ai/tucan/intent/intent-category-map.ts:52REQUIRED_POOL_TOOL_IDS assertions.
  • ddx-api/src/ai/tucan/intent/intent-category-map.tsgetToolPoolForIntent(intent) and isIntentFullCorpus(intent) helpers.
  • ddx-api/src/ai/tucan/dispatch/stages/intent-classifier.ts:24 — class IntentClassifierStage.
  • ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:47 — class TrivialBypassStage.
  • ddx-api/src/ai/tucan/dispatch/stages/trivial-bypass.ts:27type 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

  • ToolCategory is the single source of truth. The 13 values in the union dictate the keys of INTENT_DESCRIPTORS and INTENT_TOOL_POOLS — adding an intent without adding a descriptor entry fails compilation (the Record<ToolCategory, …> enforces exhaustiveness).
  • maxReplanAttempts: 0 is the default. Only intake-assessment opts into the retry loop (because clinical intake completeness is worth one retry on a guard failure). Setting maxReplanAttempts high on other intents bloats per-turn cost without product win.
  • runIntakeGuard is for intake-assessment only. It runs assessIntakeCompleteness() 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.
  • fallbackWorkflowId must 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-data is a hint for "look up this person" intents. When the recent 2-turn history is patient-datapatient-data, the rewriter sticks with the same intent for continuity (prompt-rewriter.service.ts:1001). Avoid silently flipping away.
  • general intent uses the full corpus. Tool-RAG widens its return set and the planner has no allowedTaskTypes restriction. Intentionally permissive for catch-all queries.
  • 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 allowedTaskTypes and maxReplanAttempts.
  • ai-medical-tools — catalog from which INTENT_TOOL_POOLS is 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.