35.1 Intent classification and routing

Overview and links for this section of the guide.

Defining the Taxonomy

The foundation of any classification system is a well-defined taxonomy. Rules:

  1. Fixed categories: Never let the model invent new ones
  2. Mutually exclusive: Each email fits exactly one category
  3. Exhaustive: Every possible email can be classified
  4. Actionable: Each category maps to a specific handler or queue
// taxonomy.ts
export const INTENT_CATEGORIES = {
  // Billing-related
  BILLING_REFUND: {
    description: 'Customer wants money back for a purchase',
    handler: 'billing_team',
    autoReplyEligible: false,  // Requires human approval
  },
  BILLING_INVOICE: {
    description: 'Customer needs invoice or receipt',
    handler: 'billing_team',
    autoReplyEligible: true,
  },
  BILLING_PAYMENT_ISSUE: {
    description: 'Payment failed, card declined, etc.',
    handler: 'billing_team',
    autoReplyEligible: false,
  },
  
  // Shipping-related
  SHIPPING_STATUS: {
    description: 'Customer asks where their order is',
    handler: 'shipping_bot',      // Fully automated
    autoReplyEligible: true,
  },
  SHIPPING_ADDRESS_CHANGE: {
    description: 'Customer wants to change delivery address',
    handler: 'shipping_team',
    autoReplyEligible: false,     // Time-sensitive, needs human
  },
  SHIPPING_DAMAGED: {
    description: 'Package arrived damaged',
    handler: 'shipping_team',
    autoReplyEligible: false,
  },
  
  // Technical/Product
  TECHNICAL_LOGIN: {
    description: 'Cannot log in, password issues',
    handler: 'tech_bot',
    autoReplyEligible: true,      // Send password reset link
  },
  TECHNICAL_BUG: {
    description: 'Feature not working as expected',
    handler: 'engineering_queue',
    autoReplyEligible: false,
  },
  
  // Catch-all
  OTHER: {
    description: 'Does not fit any category above',
    handler: 'general_queue',
    autoReplyEligible: false,
  },
} as const;

export type IntentCategory = keyof typeof INTENT_CATEGORIES;
The "Other" Bucket

Always have an OTHER category. If the model is unsure, it should dump tickets there for a human. Never force a square peg into a round hole—misclassification is worse than no classification.

The Classification Prompt

The classification prompt needs to be precise and include examples:

// classifier-prompt.ts
export function buildClassificationPrompt(email: string): string {
  return `You are a customer support triage assistant. Your job is to classify incoming emails into exactly ONE category.

## Categories

${Object.entries(INTENT_CATEGORIES).map(([key, value]) => 
  `- ${key}: ${value.description}`
).join('\n')}

## Rules
1. Choose exactly ONE category that best fits the email
2. If multiple categories could apply, pick the PRIMARY intent
3. If truly uncertain, choose OTHER
4. Provide a confidence score from 0.0 to 1.0
5. Explain your reasoning briefly

## Examples

Email: "Hi, I ordered a laptop last week but haven't received any tracking info"
Output: {"category": "SHIPPING_STATUS", "confidence": 0.95, "reasoning": "Customer is asking about order delivery status"}

Email: "Your app crashes every time I try to upload a photo"
Output: {"category": "TECHNICAL_BUG", "confidence": 0.90, "reasoning": "Customer reporting a software malfunction"}

Email: "I'd like to get a copy of my invoice from January"
Output: {"category": "BILLING_INVOICE", "confidence": 0.95, "reasoning": "Customer requesting billing document"}

## Email to Classify

${email}

## Output

Respond with ONLY a JSON object in this exact format:
{"category": "CATEGORY_NAME", "confidence": 0.0-1.0, "reasoning": "brief explanation"}`;
}

Full Implementation

// classifier.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
import { INTENT_CATEGORIES, IntentCategory } from './taxonomy';

interface ClassificationResult {
  category: IntentCategory;
  confidence: number;
  reasoning: string;
  raw?: string;  // For debugging
}

export class EmailClassifier {
  private model: any;
  
  constructor(apiKey: string) {
    const genAI = new GoogleGenerativeAI(apiKey);
    this.model = genAI.getGenerativeModel({ 
      model: 'gemini-1.5-flash',
      generationConfig: {
        temperature: 0,  // Deterministic for classification
        maxOutputTokens: 256,
      }
    });
  }
  
  async classify(email: string): Promise {
    const prompt = buildClassificationPrompt(email);
    
    const result = await this.model.generateContent(prompt);
    const text = result.response.text();
    
    // Extract JSON from response
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (!jsonMatch) {
      throw new Error(`Failed to parse classification response: ${text}`);
    }
    
    const parsed = JSON.parse(jsonMatch[0]);
    
    // Validate category exists in taxonomy
    if (!(parsed.category in INTENT_CATEGORIES)) {
      console.warn(`Unknown category: ${parsed.category}, defaulting to OTHER`);
      parsed.category = 'OTHER';
      parsed.confidence = 0.5;
    }
    
    return {
      category: parsed.category as IntentCategory,
      confidence: Math.max(0, Math.min(1, parsed.confidence)),
      reasoning: parsed.reasoning,
      raw: text,
    };
  }
  
  // Batch classification for efficiency
  async classifyBatch(emails: string[]): Promise {
    return Promise.all(emails.map(e => this.classify(e)));
  }
}

// Usage
const classifier = new EmailClassifier(process.env.GEMINI_API_KEY!);

const result = await classifier.classify(`
  Hi support,
  
  I placed an order (#12345) three days ago and still haven't received
  any shipping confirmation. Can you tell me when it will arrive?
  
  Thanks,
  John
`);

console.log(result);
// {
//   category: 'SHIPPING_STATUS',
//   confidence: 0.95,
//   reasoning: 'Customer asking about order delivery timeline'
// }

Routing Logic

Once classified, route the ticket to the appropriate handler:

// router.ts
interface Ticket {
  id: string;
  email: string;
  classification: ClassificationResult;
  customerId?: string;
  extractedData?: any;
}

interface RoutingDecision {
  action: 'auto_reply' | 'draft_for_review' | 'route_to_human';
  handler: string;
  priority: 'low' | 'medium' | 'high' | 'urgent';
  reason: string;
}

export function routeTicket(ticket: Ticket): RoutingDecision {
  const categoryConfig = INTENT_CATEGORIES[ticket.classification.category];
  const confidence = ticket.classification.confidence;
  
  // Rule 1: High confidence + auto-reply eligible = fully automated
  if (confidence >= 0.9 && categoryConfig.autoReplyEligible) {
    return {
      action: 'auto_reply',
      handler: categoryConfig.handler,
      priority: 'low',
      reason: `High confidence (${confidence}) classification with auto-reply eligibility`,
    };
  }
  
  // Rule 2: Medium confidence = draft for human review
  if (confidence >= 0.7) {
    return {
      action: 'draft_for_review',
      handler: categoryConfig.handler,
      priority: 'medium',
      reason: `Medium confidence (${confidence}), human should verify before sending`,
    };
  }
  
  // Rule 3: Low confidence = pure human handling
  return {
    action: 'route_to_human',
    handler: categoryConfig.handler,
    priority: 'medium',
    reason: `Low confidence (${confidence}), requires human classification`,
  };
}

// Priority escalation based on content
export function adjustPriority(
  ticket: Ticket, 
  decision: RoutingDecision
): RoutingDecision {
  const email = ticket.email.toLowerCase();
  
  // Urgent keywords
  const urgentKeywords = ['urgent', 'asap', 'emergency', 'immediately'];
  if (urgentKeywords.some(kw => email.includes(kw))) {
    return { ...decision, priority: 'urgent' };
  }
  
  // Legal/escalation keywords - always route to human
  const escalationKeywords = ['lawyer', 'sue', 'legal', 'attorney', 'refund or else'];
  if (escalationKeywords.some(kw => email.includes(kw))) {
    return {
      action: 'route_to_human',
      handler: 'escalation_team',
      priority: 'urgent',
      reason: 'Potential legal escalation detected',
    };
  }
  
  return decision;
}

Handling Multi-Intent Messages

Sometimes customers ask multiple things in one email:

"My order #123 hasn't arrived AND your app keeps crashing. Also, can I get a refund?"

Options for handling this:

Approach Pros Cons
Primary intent only Simple, one handler May miss secondary issues
All intents Complete handling Complex routing, duplicate responses
Split ticket Clean separation Customer gets multiple replies

For most cases, primary intent + mention secondary works best:

// multi-intent-prompt-addition.ts
const MULTI_INTENT_ADDITION = `
If the email contains MULTIPLE distinct requests:
1. Identify the PRIMARY intent (most urgent or first mentioned)
2. Note any SECONDARY intents

Output format:
{
  "category": "PRIMARY_CATEGORY",
  "confidence": 0.0-1.0,
  "reasoning": "...",
  "secondary_intents": ["CATEGORY_2", "CATEGORY_3"]  // optional
}
`;
The Human Handoff

Multi-intent emails often benefit from human handling. The agent can address all issues in one coherent response rather than fragmented automated replies.

Where to go next