Series 6 — Part 3 of 10

Intent classification routes an incoming message to the right action handler. For a legal AI agent, getting intent wrong has consequences — a misclassified "I am in danger" message is not a minor UX issue. This article covers the two-layer classification system and confidence thresholds.

Two-Layer Classification

Layer 1 is fast keyword matching for common, unambiguous intents. Layer 2 uses the LLM for ambiguous cases. This avoids LLM latency for straightforward requests.

class IntentClassifier
{
    private const KEYWORD_PATTERNS = [
        'case_status'    => ['/case status/i', '/my case/i', '/matter update/i', '/\bstatus\b/i'],
        'hearings_today' => ['/hearing today/i', '/today.*hearing/i', '/court.*today/i'],
        'set_reminder'   => ['/remind me/i', '/set reminder/i', '/don\'t forget/i'],
        'add_note'       => ['/add note/i', '/note this/i', '/remember that/i'],
        'emergency'      => ['/help me/i', '/danger/i', '/emergency/i', '/urgent.*legal/i'],
    ];

    public function classify(string $text, PersonaInterface $persona): IntentResult
    {
        // Layer 1: keyword matching
        foreach (self::KEYWORD_PATTERNS as $intent => $patterns) {
            foreach ($patterns as $pattern) {
                if (preg_match($pattern, $text)) {
                    return new IntentResult($intent, 1.0, 'keyword');
                }
            }
        }

        // Layer 2: LLM classification for ambiguous text
        return $this->classifyWithLLM($text, $persona);
    }

    private function classifyWithLLM(string $text, PersonaInterface $persona): IntentResult
    {
        $prompt = "Classify the intent of the following message from a " . get_class($persona) . ".\n"
                . "Respond with JSON: {\"intent\": \"...\", \"confidence\": 0.0-1.0}\n"
                . "Valid intents: case_status, hearings_today, set_reminder, add_note, get_document, "
                . "billing_query, appointment_request, general_query, emergency, unknown\n\n"
                . "Message: " . $text;

        $raw    = $this->llm->generate($prompt, temperature: 0.1);
        $parsed = json_decode($raw, true);

        $intent     = $parsed['intent']     ?? 'unknown';
        $confidence = (float)($parsed['confidence'] ?? 0.0);

        // If confidence is below threshold, fall back to general_query
        if ($confidence < 0.6 && $intent !== 'emergency') {
            $intent = 'general_query';
        }

        return new IntentResult($intent, $confidence, 'llm');
    }
}

Routing to Action Handlers

$handlers = [
    'case_status'    => new CaseStatusHandler($pdo, $persona),
    'hearings_today' => new HearingsTodayHandler($pdo, $persona),
    'set_reminder'   => new SetReminderHandler($pdo, $persona),
    'add_note'       => new AddNoteHandler($pdo, $persona),
    'emergency'      => new EmergencyHandler($notifier),
    'general_query'  => new LLMQueryHandler($llm, $persona, $rag),
];

$result   = $classifier->classify($incomingText, $persona);
$handler  = $handlers[$result->intent] ?? $handlers['general_query'];
$response = $handler->handle($incomingText);

What to Watch For

  • Emergency intents must never fall through — If keyword matching catches 'emergency', it must route immediately. Do not pass emergency intents through the LLM classifier — that adds latency when it matters most.
  • Unknown intent vs fallback — When in doubt, ask the user to clarify rather than assuming. "I didn't understand that — could you tell me more about what you need?" is always better than a silent wrong action.
  • Multilingual intent — Keyword patterns in English will miss Hindi and Punjabi messages. For multilingual agents, include patterns in all supported languages or rely primarily on the LLM layer.