Files
triqura-ecd/docs/swift/developer-guide-intent-system.md
colinislit a6b63665e1 feat(swift): implement agenda planning module (Epic 4 UI)
- Add AgendaBlock core component with list, create, cancel, reschedule modes
- Implement AgendaListView with patient/type/location details and actions
- Implement AgendaCreateForm with fuzzy patient search and validation
- Implement AgendaCancelView with disambiguation support
- Implement AgendaRescheduleForm with date/time picker
- Integrate with server actions (create, cancel, reschedule)
- Add radio-group UI component
- Update documentation and status
2025-12-27 22:29:11 +01:00

38 KiB
Raw Blame History

Developer Guide: Swift Intent System

Document: Developer Guide - Intent Classification & Extension Versie: 1.0 Datum: 27-12-2024 Auteur: Colin Lit Audience: Developers die Swift intents willen begrijpen of uitbreiden


📚 Inhoudsopgave

  1. Overzicht
  2. Architectuur
  3. Hoe het werkt
  4. Bestaande Intents
  5. Een nieuw intent toevoegen
  6. Best Practices
  7. Troubleshooting
  8. Testing
  9. Referenties

1. Overzicht

Wat is het Intent System?

Het Swift Intent System is een two-tier classificatie systeem dat natuurlijke taal input van gebruikers analyseert en vertaalt naar concrete acties binnen de applicatie.

Doel:

  • Gebruikers kunnen in natuurlijke taal (Nederlands) communiceren met Swift
  • Systeem herkent de intentie achter de input
  • Opent het juiste artifact (UI component) met de juiste pre-filled data

Voorbeeld:

User input: "notitie jan medicatie"
           ↓
Intent classification: 'dagnotitie'
           ↓
Entities extracted: { patient: "jan", category: "medicatie" }
           ↓
Action: Open DagnotatieBlock met pre-filled patient + category

Waarom Two-Tier?

Tier Methode Snelheid Accuracy Use Case
Tier 1 Local regex patterns <50ms Hoog voor directe matches "notitie jan", "afspraken vandaag"
Tier 2 AI (Claude Haiku) ~400ms Hoog voor complexe/ambigue input "ik wil iets plannen voor volgende week"

Voordelen:

  • Snelheid: 90%+ van queries worden lokaal afgehandeld (<50ms)
  • Kosten: Alleen AI call bij onduidelijke input
  • Betrouwbaarheid: Fallback naar AI als local match faalt
  • Flexibiliteit: Makkelijk om nieuwe patterns toe te voegen

2. Architectuur

System Diagram

┌─────────────────────────────────────────────────────────────┐
│                      USER INPUT                              │
│  "maak afspraak met jan morgen 14:00"                       │
└──────────────────────┬──────────────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────────────┐
│              TIER 1: Local Pattern Classifier                │
│              (lib/swift/intent-classifier.ts)                │
│                                                              │
│  • Regex-based pattern matching                             │
│  • Client-side execution                                    │
│  • Returns: { intent, confidence, processingTimeMs }        │
└──────────────────────┬──────────────────────────────────────┘
                       ↓
                 ┌─────────────┐
                 │ Confidence  │
                 │   >= 0.8?   │
                 └──┬──────┬───┘
                    │ YES  │ NO
                    ↓      ↓
        ┌───────────┘      └──────────────────────────────────┐
        │                                                      │
        ↓                                                      ↓
┌──────────────┐                        ┌─────────────────────────────────┐
│ Use Local    │                        │ TIER 2: AI Fallback Classifier  │
│ Result       │                        │ (lib/swift/intent-classifier-ai │
└──────┬───────┘                        │         .ts)                    │
       │                                │                                 │
       │                                │ • Claude Haiku API call         │
       │                                │ • Server-side only              │
       │                                │ • Advanced entity extraction    │
       │                                └─────────────┬───────────────────┘
       │                                              │
       └──────────────────┬───────────────────────────┘
                          ↓
┌──────────────────────────────────────────────────────────────┐
│                  INTENT ROUTER                               │
│               (swift-store.ts / Chat API)                    │
│                                                              │
│  • Maps intent → artifact type                              │
│  • Applies confidence thresholds                            │
│  • Triggers verduidelijkingsvragen bij lage confidence      │
└──────────────────────┬───────────────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────────────┐
│                  ARTIFACT MANAGER                            │
│                                                              │
│  • Opens appropriate artifact (DagnotatieBlock, etc.)       │
│  • Pre-fills extracted entities                             │
│  • Manages artifact lifecycle                               │
└──────────────────────────────────────────────────────────────┘

Core Files

File Locatie Rol Execution
intent-classifier.ts /lib/swift/ Local pattern matching Client-side
intent-classifier-ai.ts /lib/swift/ AI fallback classifier Server-side
types.ts /lib/swift/ Type definitions Both
swift-store.ts /stores/ State management + routing Client-side
chat/route.ts /app/api/swift/ Chat API met streaming Server-side

3. Hoe het werkt

Step-by-Step Flow

Step 1: User Input

// User types in chat input
const userInput = "notitie jan medicatie";

Step 2: Local Classification (Tier 1)

// lib/swift/intent-classifier.ts
import { classifyIntent } from '@/lib/swift/intent-classifier';

const result = classifyIntent(userInput);
// Returns:
{
  intent: 'dagnotitie',
  confidence: 0.9,
  matchedPattern: '/^notitie\\s+\\w+/',
  processingTimeMs: 12
}

How it works:

const INTENT_PATTERNS = {
  dagnotitie: [
    { pattern: /^notitie\s+\w+/i, weight: 0.95 },
    { pattern: /^dagnotitie\b/i, weight: 1.0 },
    // ... meer patterns
  ],
  zoeken: [
    { pattern: /^zoek\b/i, weight: 1.0 },
    { pattern: /^wie\s+is\b/i, weight: 1.0 },
    // ...
  ],
  // ...
};

// Test all patterns, return highest weight match
for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) {
  for (const { pattern, weight } of patterns) {
    if (pattern.test(userInput)) {
      // Found a match!
      return { intent, confidence: weight };
    }
  }
}

Step 3: Confidence Check

import { isHighConfidence, shouldUseAIFallback } from '@/lib/swift/intent-classifier';

if (isHighConfidence(result)) {
  // confidence >= 0.8
  // → Use local result, skip AI
  return result;
}

if (shouldUseAIFallback(result.confidence)) {
  // confidence < 0.8
  // → Trigger AI classification
  const aiResult = await classifyIntentWithAI(userInput);
  return aiResult;
}

Step 4a: AI Fallback (Tier 2) - If Needed

// lib/swift/intent-classifier-ai.ts (SERVER-SIDE ONLY)
import { classifyIntentWithAI } from '@/lib/swift/intent-classifier-ai';

const aiResult = await classifyIntentWithAI("ik wil een gesprek plannen");
// Returns:
{
  intent: 'create_appointment',
  confidence: 0.75,
  entities: {
    patientName: undefined,  // Missing!
    content: "een gesprek plannen"
  },
  source: 'ai',
  processingTimeMs: 380,
  reasoning: "Gebruiker wil afspraak maken, maar patient ontbreekt"
}

How it works:

  1. Sends user input to Claude Haiku API
  2. Uses structured system prompt with intent definitions
  3. Expects JSON response with intent + entities + confidence
  4. Validates response with Zod schema
const response = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.ANTHROPIC_API_KEY,
    'anthropic-version': '2023-06-01',
  },
  body: JSON.stringify({
    model: 'claude-3-5-haiku-20241022',
    max_tokens: 256,
    temperature: 0,
    system: INTENT_CLASSIFIER_SYSTEM_PROMPT,
    messages: [{ role: 'user', content: `Classificeer: "${input}"` }],
  }),
});

const parsed = JSON.parse(response);
const validated = AIIntentResponseSchema.parse(parsed);

Step 5: Intent Routing

// In swift-store.ts or chat API
function handleIntent(intent: SwiftIntent, entities: ExtractedEntities, confidence: number) {

  // Confidence too low → ask clarification
  if (confidence < 0.7) {
    return {
      type: 'clarification_needed',
      question: generateClarificationQuestion(intent, entities)
    };
  }

  // Route to appropriate artifact
  switch (intent) {
    case 'dagnotitie':
      return openArtifact({
        type: 'DagnotatieBlock',
        mode: 'create',
        prefill: {
          patient: entities.patientName,
          category: entities.category,
          content: entities.content
        }
      });

    case 'zoeken':
      return openArtifact({
        type: 'ZoekenBlock',
        prefill: { query: entities.patientName }
      });

    case 'agenda_query':
      return openArtifact({
        type: 'AgendaBlock',
        mode: 'list',
        prefill: { dateRange: entities.dateRange }
      });

    // ... etc
  }
}

Step 6: Artifact Opens

// Artifact Manager opens the correct component
<AgendaBlock
  mode="list"
  dateRange={{ start: today, end: today, label: 'vandaag' }}
/>

4. Bestaande Intents

Intent Overzicht

Intent Beschrijving Artifacts Entities
dagnotitie Notitie maken voor patiënt DagnotatieBlock patientName, category, content
zoeken Patiënt zoeken ZoekenBlock, PatientContextCard patientName
overdracht Dienst overdracht genereren OverdrachtBlock
agenda_query Afspraken opvragen AgendaBlock (list) dateRange
create_appointment Nieuwe afspraak maken AgendaBlock (create) patient, datetime, type, location
cancel_appointment Afspraak annuleren AgendaBlock (cancel) identifier
reschedule_appointment Afspraak verzetten AgendaBlock (reschedule) identifier, newDatetime
unknown Niet herkend Fallback picker

Pattern Examples

Dagnotitie:

dagnotitie: [
  { pattern: /^dagnotitie\b/i, weight: 1.0 },
  { pattern: /^notitie\b/i, weight: 1.0 },
  { pattern: /^notitie\s+\w+/i, weight: 0.95 },              // "notitie jan"
  { pattern: /^\w+\s+(medicatie|adl|gedrag|incident)/i, weight: 0.9 }, // "jan medicatie"
]

Zoeken:

zoeken: [
  { pattern: /^zoek\b/i, weight: 1.0 },
  { pattern: /^wie\s+is\b/i, weight: 1.0 },                  // "wie is jan"
  { pattern: /^vind\s+\w+/i, weight: 1.0 },                  // "vind marie"
  { pattern: /^[A-Z][a-z]+$/i, weight: 0.5 },               // Single capitalized word
]

Agenda Query:

agenda_query: [
  { pattern: /^afspraken?\b/i, weight: 1.0 },                // "afspraken"
  { pattern: /^agenda\b/i, weight: 1.0 },                    // "agenda"
  { pattern: /^wat\s+zijn\s+(mijn\s+)?afspraken/i, weight: 1.0 },
  { pattern: /^volgende\s+afspraak/i, weight: 0.95 },
]

5. Een nieuw intent toevoegen

Voorbeeld: "metingen" intent

Stel je wilt een nieuw intent toevoegen voor het invoeren van vitale metingen (bloeddruk, temperatuur, etc.).

Gewenste flow:

User: "bloeddruk jan 120/80"
→ Intent: 'metingen'
→ Entities: { patient: "jan", type: "bloeddruk", value: "120/80" }
→ Opens: MetingenBlock met pre-filled data

Step 1: Update Types

File: lib/swift/types.ts

// Add new intent to enum
export type SwiftIntent =
  | 'dagnotitie'
  | 'zoeken'
  | 'overdracht'
  | 'agenda_query'
  | 'create_appointment'
  | 'cancel_appointment'
  | 'reschedule_appointment'
  | 'metingen'           // ← NEW
  | 'unknown';

// Extend ExtractedEntities
export interface ExtractedEntities {
  // Existing fields
  patientName?: string;
  category?: VerpleegkundigCategory;
  content?: string;
  // ... other existing fields

  // NEW: Metingen-specific entities
  metingType?: 'bloeddruk' | 'temperatuur' | 'pols' | 'saturatie' | 'gewicht';
  metingValue?: string;
  metingUnit?: string;
  metingTimestamp?: Date;
}

Step 2: Add Local Patterns

File: lib/swift/intent-classifier.ts

const INTENT_PATTERNS: Record<Exclude<SwiftIntent, 'unknown'>, PatternConfig[]> = {
  // ... existing patterns

  // NEW: Metingen patterns
  metingen: [
    // Exact commands
    { pattern: /^meting\b/i, weight: 1.0 },
    { pattern: /^metingen\b/i, weight: 1.0 },
    { pattern: /^vitale\s+functies\b/i, weight: 1.0 },

    // Type-specific patterns
    { pattern: /^bloeddruk\b/i, weight: 1.0 },
    { pattern: /^temperatuur\b/i, weight: 1.0 },
    { pattern: /^pols\b/i, weight: 0.95 },
    { pattern: /^saturatie\b/i, weight: 0.95 },
    { pattern: /^gewicht\b/i, weight: 0.95 },

    // Pattern: "bloeddruk [naam]" or "[naam] bloeddruk"
    { pattern: /^bloeddruk\s+\w+/i, weight: 0.95 },
    { pattern: /^\w+\s+bloeddruk/i, weight: 0.9 },

    // Pattern: "bloeddruk [naam] [waarde]"
    { pattern: /^bloeddruk\s+\w+\s+\d+/i, weight: 0.98 },

    // Pattern with value patterns
    { pattern: /\d{2,3}\/\d{2,3}/i, weight: 0.7 },  // "120/80" format
    { pattern: /\d+\s*graden?/i, weight: 0.7 },     // "38 graden"
  ],
};

Pattern Weight Guidelines:

  • 1.0 = Exact, unambiguous match ("meting", "bloeddruk")
  • 0.9-0.95 = Strong match with context ("bloeddruk jan")
  • 0.8-0.89 = Good match, minor ambiguity
  • 0.7-0.79 = Partial match, needs additional context
  • <0.7 = Weak match, likely needs AI fallback

Step 3: Update AI System Prompt

File: lib/swift/intent-classifier-ai.ts

const INTENT_CLASSIFIER_SYSTEM_PROMPT = `Je bent een intent classifier voor Swift EPD.

Classificeer de intentie in één van deze categorieën:

**Bestaande intents:**
1. dagnotitie - Notitie maken
2. zoeken - Patiënt zoeken
3. overdracht - Dienst overdracht
4-7. [agenda intents...]

**Nieuwe intent:**
8. **metingen** - Vitale functies invoeren
   Voorbeelden: "bloeddruk jan 120/80", "temperatuur marie 38 graden", "pols invoeren"
   Extraheer: patientName, metingType, metingValue, metingUnit

   Types: bloeddruk, temperatuur, pols, saturatie, gewicht

   Value patterns:
   - Bloeddruk: "120/80" (systolisch/diastolisch)
   - Temperatuur: "38" of "38 graden" of "38.5"
   - Pols: "72" of "72 bpm"
   - Saturatie: "98" of "98%"
   - Gewicht: "75" of "75 kg"

9. unknown - Onduidelijk

**Response format:**
{
  "intent": "...",
  "confidence": 0.0-1.0,
  "entities": {
    "patientName": "jan",
    "metingType": "bloeddruk",
    "metingValue": "120/80",
    "metingUnit": "mmHg"  // optioneel
  }
}
`;

Update Zod Schema:

const AIIntentResponseSchema = z.object({
  intent: z.enum([
    'dagnotitie',
    'zoeken',
    'overdracht',
    'agenda_query',
    'create_appointment',
    'cancel_appointment',
    'reschedule_appointment',
    'metingen',          // ← ADD
    'unknown'
  ]),
  confidence: z.number().min(0).max(1),
  entities: z.object({
    patientName: z.string().optional(),
    category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(),
    content: z.string().optional(),
    // ... existing fields

    // NEW
    metingType: z.enum(['bloeddruk', 'temperatuur', 'pols', 'saturatie', 'gewicht']).optional(),
    metingValue: z.string().optional(),
    metingUnit: z.string().optional(),
  }).optional(),
  reasoning: z.string().optional(),
});

Step 4: Add Intent Routing

File: stores/swift-store.ts or chat API route

function openArtifactForIntent(
  intent: SwiftIntent,
  entities: ExtractedEntities,
  confidence: number
): ArtifactConfig | null {

  // Confidence check
  if (confidence < 0.7) {
    return null; // Trigger clarification
  }

  switch (intent) {
    // ... existing cases

    // NEW
    case 'metingen':
      // Validate required entities
      if (!entities.patientName || !entities.metingType) {
        return null; // Need more info
      }

      return {
        type: 'MetingenBlock',
        mode: 'create',
        prefill: {
          patient: entities.patientName,
          type: entities.metingType,
          value: entities.metingValue,
          unit: entities.metingUnit,
          timestamp: entities.metingTimestamp || new Date()
        }
      };

    default:
      return null;
  }
}

Step 5: Create the Artifact Component

File: components/swift/artifacts/blocks/metingen-block.tsx

interface MetingenBlockProps {
  mode: 'create' | 'view';
  prefill?: {
    patient?: { id: string; name: string };
    type?: MetingType;
    value?: string;
    unit?: string;
    timestamp?: Date;
  };
  onClose?: () => void;
}

export function MetingenBlock({ mode, prefill, onClose }: MetingenBlockProps) {
  const [formData, setFormData] = useState({
    patientId: prefill?.patient?.id || '',
    type: prefill?.type || 'bloeddruk',
    value: prefill?.value || '',
    unit: prefill?.unit || getDefaultUnit(prefill?.type),
    timestamp: prefill?.timestamp || new Date(),
  });

  const handleSubmit = async () => {
    // Call server action to save measurement
    await createMeasurement(formData);
    // Show success toast
    toast.success('Meting opgeslagen!');
    onClose?.();
  };

  return (
    <div className="metingen-block">
      <header>
        <h3>📊 Vitale Functies</h3>
        <button onClick={onClose}>×</button>
      </header>

      <form>
        {/* Patient selector */}
        <PatientSelect
          value={formData.patientId}
          onChange={(id) => setFormData({ ...formData, patientId: id })}
          defaultName={prefill?.patient?.name}
        />

        {/* Type selector */}
        <Select
          label="Type meting"
          value={formData.type}
          onChange={(type) => setFormData({ ...formData, type })}
        >
          <option value="bloeddruk">Bloeddruk</option>
          <option value="temperatuur">Temperatuur</option>
          <option value="pols">Pols</option>
          <option value="saturatie">Saturatie</option>
          <option value="gewicht">Gewicht</option>
        </Select>

        {/* Value input */}
        <Input
          label="Waarde"
          type="text"
          value={formData.value}
          onChange={(e) => setFormData({ ...formData, value: e.target.value })}
          placeholder={getPlaceholder(formData.type)}
        />

        {/* Unit (auto-filled based on type) */}
        <Input
          label="Eenheid"
          value={formData.unit}
          readOnly
        />

        {/* Timestamp */}
        <DateTimePicker
          label="Tijdstip"
          value={formData.timestamp}
          onChange={(timestamp) => setFormData({ ...formData, timestamp })}
        />

        <footer>
          <Button variant="secondary" onClick={onClose}>
            Annuleren
          </Button>
          <Button onClick={handleSubmit}>
            💾 Opslaan
          </Button>
        </footer>
      </form>
    </div>
  );
}

function getDefaultUnit(type?: MetingType): string {
  switch (type) {
    case 'bloeddruk': return 'mmHg';
    case 'temperatuur': return '°C';
    case 'pols': return 'bpm';
    case 'saturatie': return '%';
    case 'gewicht': return 'kg';
    default: return '';
  }
}

Step 6: Register Artifact Type

File: stores/swift-store.ts or artifact registry

export type ArtifactType =
  | 'DagnotatieBlock'
  | 'ZoekenBlock'
  | 'PatientContextCard'
  | 'OverdrachtBlock'
  | 'AgendaBlock'
  | 'MetingenBlock'     // ← ADD
  ;

// Add to artifact component mapping
const ARTIFACT_COMPONENTS = {
  DagnotatieBlock: lazy(() => import('@/components/swift/artifacts/blocks/dagnotitie-block')),
  ZoekenBlock: lazy(() => import('@/components/swift/artifacts/blocks/zoeken-block')),
  // ... existing
  MetingenBlock: lazy(() => import('@/components/swift/artifacts/blocks/metingen-block')), // ← ADD
};

Step 7: Test

Manual Testing Checklist:

// Test cases for 'metingen' intent

// ✅ Local pattern matches (high confidence)
"bloeddruk jan 120/80"            intent: metingen, conf: 0.98
"temperatuur marie"                intent: metingen, conf: 1.0
"pols"                             intent: metingen, conf: 0.95

// ✅ AI fallback (ambiguous)
"jan zijn bloeddruk invoeren"     AI: intent: metingen, conf: 0.85
"ik wil vitale functies noteren"  AI: intent: metingen, conf: 0.75

// ✅ Edge cases
"bloeddruk"                        Missing patient, show clarification
"jan"                              Likely 'zoeken', not 'metingen'
"bloeddruk 120/80"                 Missing patient, show clarification

// ✅ Artifact opens correctly
"bloeddruk jan 120/80"             MetingenBlock opens with prefill
                                    - patient: "jan"
                                    - type: "bloeddruk"
                                    - value: "120/80"
                                    - unit: "mmHg"

// ✅ Voice input works
[Voice] "bloeddruk jan honderdtwintig tachtig"
 Transcript: "bloeddruk jan honderdtwintig tachtig"
 AI parses: value: "120/80"
 Opens MetingenBlock

Unit Tests:

// __tests__/intent-classifier.test.ts
import { classifyIntent } from '@/lib/swift/intent-classifier';

describe('Intent Classifier - Metingen', () => {
  it('should classify "bloeddruk jan 120/80" as metingen', () => {
    const result = classifyIntent('bloeddruk jan 120/80');
    expect(result.intent).toBe('metingen');
    expect(result.confidence).toBeGreaterThanOrEqual(0.9);
  });

  it('should classify "temperatuur" as metingen', () => {
    const result = classifyIntent('temperatuur');
    expect(result.intent).toBe('metingen');
    expect(result.confidence).toBe(1.0);
  });

  it('should have low confidence for "jan bloeddruk"', () => {
    const result = classifyIntent('jan bloeddruk');
    expect(result.intent).toBe('metingen');
    expect(result.confidence).toBeLessThan(0.95);
  });
});

6. Best Practices

Pattern Design

DO:

// Clear, specific patterns first
{ pattern: /^bloeddruk\b/i, weight: 1.0 }
{ pattern: /^temperatuur\b/i, weight: 1.0 }

// Then patterns with context
{ pattern: /^bloeddruk\s+\w+/i, weight: 0.95 }  // "bloeddruk jan"

// Finally, partial matches
{ pattern: /\d{2,3}\/\d{2,3}/i, weight: 0.7 }   // "120/80" format

DON'T:

// Too broad - will match everything
{ pattern: /./i, weight: 1.0 }

// Too specific - will never match natural language
{ pattern: /^bloeddruk\s+jan\s+120\/80$/i, weight: 1.0 }

// Overlapping high-weight patterns (creates ambiguity)
{ pattern: /^notitie\b/i, weight: 1.0 }
{ pattern: /^notitie\s+/i, weight: 1.0 }  // Redundant!

Confidence Weights

Guidelines:

Weight Range Use Case Example
1.0 Exact keyword match, no ambiguity /^bloeddruk\b/i
0.9-0.95 Strong match with minimal context /^bloeddruk\s+\w+/i
0.8-0.89 Good match, some context needed /^\w+\s+bloeddruk/i
0.7-0.79 Partial match, may need AI /\d{2,3}\/\d{2,3}/i
<0.7 Weak match, definitely needs AI

Threshold decisions:

if (confidence >= 0.8) {
  // High confidence → Use local result
  return openArtifact(intent, entities);
}

if (confidence >= 0.7) {
  // Medium confidence → Open artifact + confirmation in chat
  openArtifact(intent, entities);
  return chatResponse("Klopt dit? [intent description]");
}

if (confidence >= 0.5) {
  // Low confidence → Ask clarification
  return chatResponse(generateClarificationQuestion(intent, entities));
}

// confidence < 0.5
// Very low → Show fallback picker or generic help
return showFallbackPicker();

Entity Extraction

Local extraction (basic):

// Extract from regex capture groups
const pattern = /^bloeddruk\s+(\w+)\s+(\d+\/\d+)/i;
const match = pattern.exec(input);

if (match) {
  return {
    intent: 'metingen',
    entities: {
      patientName: match[1],  // "jan"
      metingValue: match[2],   // "120/80"
    }
  };
}

AI extraction (advanced):

Let the AI handle complex parsing:

  • Relative dates: "morgen", "volgende week dinsdag"
  • Number words: "honderdtwintig tachtig" → "120/80"
  • Implicit entities: "deze patiënt" (uses context)

Performance Optimization

DO:

// Pre-compile regex patterns (happens at module load)
const PATTERNS = {
  bloeddruk: /^bloeddruk\b/i,
  temperatuur: /^temperatuur\b/i,
};

// Early exit on perfect match
if (confidence === 1.0) break;

// Cache frequent queries (if applicable)
const cache = new Map<string, ClassificationResult>();

DON'T:

// Don't create regex in loop
for (const input of inputs) {
  const pattern = new RegExp(`^${keyword}\\b`, 'i'); // ❌ Slow!
}

// Don't make unnecessary AI calls
if (confidence >= 0.8) {
  const aiResult = await classifyWithAI(input); // ❌ Waste of time & money
}

Error Handling

Always validate:

// Validate AI response
try {
  const parsed = JSON.parse(aiResponse);
  const validated = AIIntentResponseSchema.parse(parsed);
  return validated;
} catch (error) {
  console.error('AI classification error:', error);
  // Fallback to unknown intent
  return {
    intent: 'unknown',
    confidence: 0,
    source: 'ai',
  };
}

// Validate entity presence before opening artifact
if (!entities.patientName) {
  // Ask clarification instead of opening empty artifact
  return askClarification("Met welke patiënt?");
}

7. Troubleshooting

Common Issues

Issue 1: Intent not being recognized

Symptoms:

User: "bloeddruk jan"
Result: intent: 'unknown', confidence: 0

Debugging:

// 1. Check if pattern exists
console.log(INTENT_PATTERNS.metingen);
// Should show array of patterns

// 2. Test pattern manually
const pattern = /^bloeddruk\b/i;
console.log(pattern.test("bloeddruk jan"));
// Should be true

// 3. Check pattern order (first match wins)
// If pattern is below a more generic pattern, it may never be tested

// 4. Check case sensitivity
const pattern = /^Bloeddruk\b/;  // ❌ Won't match "bloeddruk"
const pattern = /^bloeddruk\b/i; // ✅ Case insensitive

Solution:

// Add or fix pattern
metingen: [
  { pattern: /^bloeddruk\b/i, weight: 1.0 },  // ← Ensure this exists
  // ...
]

Issue 2: Wrong intent detected

Symptoms:

User: "jan bloeddruk"
Result: intent: 'zoeken', confidence: 0.5  // ❌ Should be 'metingen'

Cause: Pattern for 'zoeken' matches first:

zoeken: [
  { pattern: /^[A-Z][a-z]+$/i, weight: 0.5 },  // Matches "jan"
]

Solution: Add more specific patterns with higher weight:

metingen: [
  { pattern: /^\w+\s+bloeddruk/i, weight: 0.9 },  // "jan bloeddruk" → metingen
]

// Or: Lower weight of generic patterns
zoeken: [
  { pattern: /^[A-Z][a-z]+$/i, weight: 0.3 },  // Lower weight
]

Issue 3: AI fallback not triggering

Symptoms:

User: "ik wil de bloeddruk invoeren"
Result: intent: 'unknown', confidence: 0  // AI should have helped

Debugging:

// 1. Check confidence threshold
if (result.confidence < 0.8) {
  // Should trigger AI
  const aiResult = await classifyIntentWithAI(input);
}

// 2. Check API key
console.log(process.env.ANTHROPIC_API_KEY);  // Should be set

// 3. Check AI is only called server-side
// AI classifier should NEVER run in browser

Solution:

// Ensure AI fallback is in server-side code only
// app/api/swift/chat/route.ts (✅ Server-side)
// components/client-component.tsx (❌ Client-side, won't work)

Issue 4: Entities not extracted

Symptoms:

User: "bloeddruk jan 120/80"
Result: intent: 'metingen', entities: {}  // ❌ Should extract patient + value

Cause: Local classifier only does basic regex matching. Complex entity extraction requires AI or custom parsing.

Solutions:

Option A: Add local entity extraction:

// In intent-classifier.ts
const pattern = /^bloeddruk\s+(\w+)\s+(.+)/i;
const match = pattern.exec(input);

if (match) {
  return {
    intent: 'metingen',
    confidence: 0.95,
    entities: {
      patientName: match[1],    // "jan"
      metingValue: match[2],     // "120/80"
    }
  };
}

Option B: Let AI handle entity extraction:

// Trigger AI when entities are crucial
if (result.intent === 'metingen' && !result.entities?.patientName) {
  // Local matched intent, but missing entities → Use AI for entity extraction
  const aiResult = await classifyIntentWithAI(input);
  return {
    intent: result.intent,
    confidence: result.confidence,
    entities: aiResult.entities,  // Use AI-extracted entities
  };
}

Issue 5: Slow performance

Symptoms:

  • Local classification takes >100ms
  • UI feels sluggish

Debugging:

const result = classifyIntent(input);
console.log(`Classification took ${result.processingTimeMs}ms`);

// Target: <50ms
// Acceptable: <100ms
// Slow: >100ms

Common causes:

  1. Too many patterns (>100 patterns total)

    • Solution: Consolidate similar patterns
  2. Complex regex (catastrophic backtracking)

    // ❌ BAD: Can cause exponential backtracking
    { pattern: /^(a+)+b/, weight: 1.0 }
    
    // ✅ GOOD: Simple, efficient patterns
    { pattern: /^a+b/, weight: 1.0 }
    
  3. Not breaking early on perfect match

    // ✅ Add early exit
    if (bestMatch?.confidence === 1.0) break;
    

8. Testing

Unit Tests

File: __tests__/intent-classifier.test.ts

import { classifyIntent } from '@/lib/swift/intent-classifier';

describe('Intent Classifier', () => {
  describe('Dagnotitie intent', () => {
    it('should recognize "notitie jan"', () => {
      const result = classifyIntent('notitie jan');
      expect(result.intent).toBe('dagnotitie');
      expect(result.confidence).toBeGreaterThanOrEqual(0.9);
    });

    it('should recognize "jan medicatie"', () => {
      const result = classifyIntent('jan medicatie');
      expect(result.intent).toBe('dagnotitie');
      expect(result.confidence).toBeGreaterThanOrEqual(0.8);
    });
  });

  describe('Zoeken intent', () => {
    it('should recognize "zoek jan"', () => {
      const result = classifyIntent('zoek jan');
      expect(result.intent).toBe('zoeken');
      expect(result.confidence).toBe(1.0);
    });

    it('should recognize "wie is jan"', () => {
      const result = classifyIntent('wie is jan');
      expect(result.intent).toBe('zoeken');
      expect(result.confidence).toBe(1.0);
    });
  });

  describe('Performance', () => {
    it('should classify in less than 50ms', () => {
      const result = classifyIntent('notitie jan medicatie');
      expect(result.processingTimeMs).toBeLessThan(50);
    });
  });

  describe('Confidence thresholds', () => {
    it('should return high confidence for exact matches', () => {
      const result = classifyIntent('dagnotitie');
      expect(result.confidence).toBe(1.0);
    });

    it('should return lower confidence for partial matches', () => {
      const result = classifyIntent('jan');
      expect(result.confidence).toBeLessThan(0.8);
    });
  });
});

Integration Tests

File: __tests__/intent-flow.test.ts

import { classifyIntent } from '@/lib/swift/intent-classifier';
import { classifyIntentWithAI } from '@/lib/swift/intent-classifier-ai';
import { openArtifactForIntent } from '@/stores/swift-store';

describe('Intent Flow Integration', () => {
  it('should open DagnotatieBlock for "notitie jan medicatie"', async () => {
    // Step 1: Classify
    const result = classifyIntent('notitie jan medicatie');
    expect(result.intent).toBe('dagnotitie');

    // Step 2: Route to artifact
    const artifact = openArtifactForIntent(
      result.intent,
      { patientName: 'jan', category: 'medicatie' },
      result.confidence
    );

    expect(artifact).toEqual({
      type: 'DagnotatieBlock',
      mode: 'create',
      prefill: {
        patient: 'jan',
        category: 'medicatie',
      }
    });
  });

  it('should trigger AI fallback for ambiguous input', async () => {
    // Step 1: Local classification (low confidence)
    const localResult = classifyIntent('ik wil iets noteren');
    expect(localResult.confidence).toBeLessThan(0.8);

    // Step 2: AI fallback
    const aiResult = await classifyIntentWithAI('ik wil iets noteren');
    expect(aiResult.intent).toBe('dagnotitie');
    expect(aiResult.confidence).toBeGreaterThan(0.5);
  });
});

Manual Test Scenarios

Checklist: docs/swift/manual-test-intents.md

# Manual Test Scenarios - Intent System

## Dagnotitie
- [ ] "notitie jan" → Opens DagnotatieBlock with patient "jan"
- [ ] "jan medicatie" → Opens DagnotatieBlock with patient + category
- [ ] "dagnotitie" → Opens DagnotatieBlock empty
- [ ] "schrijf observatie voor marie" → AI fallback, extracts patient + category

## Zoeken
- [ ] "zoek jan" → Opens ZoekenBlock with query "jan"
- [ ] "wie is marie" → Opens ZoekenBlock
- [ ] "Jan" (single word) → Low confidence, shows options

## Agenda
- [ ] "afspraken vandaag" → Opens AgendaBlock (list, today)
- [ ] "maak afspraak jan morgen 14:00" → Opens AgendaBlock (create, prefilled)
- [ ] "annuleer jan" → Opens AgendaBlock (cancel, shows matches)

## Voice Input
- [ ] [Voice] "notitie jan medicatie" → Transcribes correctly, opens artifact
- [ ] [Voice] "bloeddruk jan honderdtwintig tachtig" → AI parses numbers

## Edge Cases
- [ ] Empty input → intent: unknown
- [ ] Gibberish → intent: unknown, triggers fallback picker
- [ ] Mixed language → AI tries to parse, may ask clarification

9. Referenties

Code Locations

Component File Path
Local Classifier /lib/swift/intent-classifier.ts
AI Classifier /lib/swift/intent-classifier-ai.ts
Type Definitions /lib/swift/types.ts
State Management /stores/swift-store.ts
Chat API /app/api/swift/chat/route.ts
Artifacts /components/swift/artifacts/blocks/

External Resources

  • Swift FO v3.0: docs/swift/fo-swift-medical-scribe-v3.md
  • Agenda FO: docs/swift/fo-swift-agenda-planning.md
  • Bouwplan: docs/swift/bouwplan-swift-standalone-module.md
  • Architecture Decision Records: docs/architecture/

📋 Quick Reference Card

Adding a New Intent - Checklist

  • Step 1: Add intent to SwiftIntent type (lib/swift/types.ts)
  • Step 2: Extend ExtractedEntities with new fields
  • Step 3: Add patterns to INTENT_PATTERNS (intent-classifier.ts)
  • Step 4: Update AI system prompt (intent-classifier-ai.ts)
  • Step 5: Update Zod schema for AI response
  • Step 6: Add routing logic (swift-store.ts)
  • Step 7: Create artifact component (components/swift/artifacts/blocks/)
  • Step 8: Register artifact type in component mapping
  • Step 9: Write unit tests
  • Step 10: Manual testing
  • Step 11: Update documentation

Pattern Weight Quick Guide

1.0    // "bloeddruk" - exact match
0.95   // "bloeddruk jan" - strong match with context
0.9    // "jan bloeddruk" - good match, reversed order
0.8    // partial match with good context
0.7    // partial match, may need AI
<0.7   // weak match, likely needs AI fallback

Common Regex Patterns

// Keywords
/^bloeddruk\b/i               // Starts with "bloeddruk"
/\bbloeddruk\b/i              // Contains "bloeddruk" (word boundary)

// With patient name
/^bloeddruk\s+(\w+)/i         // "bloeddruk jan" - captures "jan"
/^(\w+)\s+bloeddruk/i         // "jan bloeddruk" - captures "jan"

// With values
/\d{2,3}\/\d{2,3}/            // "120/80" - blood pressure format
/\d+\s*graden?/i              // "38 graden" - temperature
/\d+\s*%/                     // "98%" - percentage

// Case insensitive
/pattern/i                    // 'i' flag = case insensitive

// Word boundary
/\bword\b/                    // Only matches whole word "word"

Document Version: 1.0 Last Updated: 27-12-2024 Maintainer: Swift Development Team

Questions? Check the troubleshooting section or reach out to the team.