Skip to content

Intent classifier

Source: agent/src/accounting_agent/intent_classifier/.

Used by the chat agent (/ask) to route the operator's question to the right tools without making the main agent do multi-part-query decomposition. Built on DSPy because the problem is exactly what DSPy is for: a small classifier with labelled examples + a metric.

What it does

Given a user message, return:

IntentClassification(
  intent: "ask" | "decompose" | "choices" | "general",
  parts: list[str] | None,   # populated for decompose
  options: list[str] | None, # populated for choices
)
  • ask — one-shot question with a clear answer the main agent can produce: "what did I spend in May?"
  • decompose — multi-part query that needs to be split: "show me my expenses last quarter AND tell me the VAT" → two sub-questions answered separately, then merged.
  • choices — the user is picking between known options: "should I book recon #44 or #45?" → spawns an agent_ask HITL with the options.
  • general — small talk / off-topic. The main agent answers directly without tools.

Where the dataset lives

intent_classifier_examples Turso table. ~25 seed examples shipped with the agent (intent_classifier/examples.py). Operator can add more via /teach <message> <intent> or by correcting the classifier when it's wrong (each correction lands as a new dataset row).

When the seed count + corrections cross 25 uncompiled examples, the classifier emits a "consider /recompile" log line. /recompile runs DSPy's compiler against the dataset and writes the new prompt + few-shot demos to the DB.

Why DSPy

  • Classification metrics are clear (precision/recall on labelled examples)
  • Small enough that a 25-example training set is meaningful
  • DSPy's optimizer beats hand-tuning prompts at the margin
  • Provider-agnostic via OpenRouter (default model: openai/gpt-4o-mini for cheap classification)

What it does NOT do

  • It does not answer the user's question. Just labels it.
  • It does not pause the main agent — classification is a fast, blocking call inside the orchestrator.
  • It does not run on every Discord message. Only on /ask and on operator-DMs to the bot.