Skip to content

Lesson 12 — intents

What you will build

Routing: a request goes to a handler, and the model decides only the part that needs deciding. There is no intent classifier in the package; there is structured, and a classification is what you build with it — plus the rules that answer first because they are cheaper and more certain.

What you need

Nothing new.

examples/learn_ai/ai12_intents.py
"""Lesson 12 — intents: a classification, a routing table, and the rules that need no model.

Run it with:

    python -m learn_ai.ai12_intents
"""

from __future__ import annotations

import asyncio
import re
from collections.abc import Mapping

from xyberos.ai import (
    AIFacade,
    ChatRequest,
    FakeModelProvider,
    Message,
    ModelCapabilities,
    ModelReply,
)
from xyberos.errors import AIError

INTENT_SCHEMA = {
    "type": "object",
    "properties": {
        "intent": {"type": "string", "enum": ["refund", "status", "complaint", "other"]},
        "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
    },
    "required": ["intent", "confidence"],
    "additionalProperties": False,
}

TICKET_PATTERN = re.compile(r"\bT-\d{3}\b", re.IGNORECASE)

CLASSIFY_PROMPT = "Classify the request into one intent. Answer with the schema's values only."


def route(intent: str) -> str:
    """Return the handler an intent belongs to. The table is application code."""
    return {
        "refund": "billing.refunds",
        "status": "support.tickets",
        "complaint": "support.quality",
    }.get(intent, "support.triage")


async def classify(assistant: AIFacade, request: str) -> Mapping[str, object] | None:
    """Ask the model for an intent, or return None when the call could not be honoured."""
    try:
        structured = await assistant.structured(
            ChatRequest(
                messages=(Message.system(CLASSIFY_PROMPT), Message.user(request)),
                response_schema=INTENT_SCHEMA,
            )
        )
    except AIError:
        return None
    return structured if isinstance(structured, Mapping) else None


async def route_request(assistant: AIFacade, request: str) -> tuple[str, str]:
    """Return the route and why, using the cheapest rule that can decide."""
    found = TICKET_PATTERN.search(request)
    if found is not None:
        return "support.tickets", f"matched {found.group(0).upper()} without a model"

    classified = await classify(assistant, request)
    if classified is None:
        return "support.triage", "the model could not classify it"
    intent = str(classified["intent"])
    confidence = str(classified["confidence"])
    if confidence == "low":
        return "support.triage", f"low confidence for {intent}"
    return route(intent), f"{intent} ({confidence})"


async def main() -> None:
    """Three requests: one a rule answers, one the model answers, one nobody is sure about."""
    provider = FakeModelProvider(
        [
            ModelReply.json_payload({"intent": "refund", "confidence": "high"}),
            ModelReply.json_payload({"intent": "complaint", "confidence": "low"}),
        ],
        capabilities=ModelCapabilities(structured_output=True),
    )
    assistant = AIFacade(provider)

    for request in (
        "Ticket t-101 is still broken",
        "How long does a refund take?",
        "This is getting ridiculous",
    ):
        where, why = await route_request(assistant, request)
        print(f"{request!r} -> {where} ({why})")
    print("model calls:", provider.calls)


if __name__ == "__main__":
    asyncio.run(main())

The order in route_request is the lesson, and it is the order most production routers converge on:

  1. A rule, if a rule can decide. A ticket id in the text routes to tickets with no model call, no tokens, and no latency.
  2. structured, if it cannot. The schema is closed (refund, status, complaint, other), so the model answers with one of four values rather than a sentence to parse.
  3. Confidence, then triage. low is escalated to a human queue instead of being acted on. The enum makes the model answer; the confidence makes the application decide whether to trust it.

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_ai.ai12_intents
PYTHONPATH=src:examples python -m learn_ai.ai12_intents

What it prints

'Ticket t-101 is still broken' -> support.tickets (matched T-101 without a model)
'How long does a refund take?' -> billing.refunds (refund (high))
'This is getting ridiculous' -> support.triage (low confidence for complaint)
model calls: 2

Note the last line: three requests, two model calls. The rule answered one for free, which is the number to watch when an intent table grows — every classification the model does not have to make is latency and money the application keeps.

Common mistakes

  • Classifying what a condition can decide. An order id, a recipient address, a keyword the business already knows: rules are faster, cheaper, and auditable.
  • Free-text intents. "Refund, probably" is a string nobody can branch on; an enum in the schema is what makes routing a dictionary lookup.
  • Acting on low confidence. A low-confidence classification is the model telling you it guessed. Route it to a person, or ask a second, different question.
  • One intent per request forever. Real requests carry two; the first version should pick the dominant one and say so, rather than inventing a hierarchy.
  • Letting the router grow into a policy engine. The routing table is application code: put it in one place, test it without a model, and let lesson 16 measure whether the model is helping.

Next

Lesson 13 makes the sequence explicit: steps, edges, branches, deadlines, and a graph you can render and review without running it.