diff --git a/docs/intent/bouwplan-cortex-v2.md b/docs/intent/bouwplan-cortex-v2.md index 1333d75..112697d 100644 --- a/docs/intent/bouwplan-cortex-v2.md +++ b/docs/intent/bouwplan-cortex-v2.md @@ -132,7 +132,7 @@ lib/config/ | Epic ID | Titel | Doel | Status | Stories | Story Points | |---------|-------|------|--------|---------|--------------| | **E0** | Foundation & Context | Types, API, feature flags | ✅ Done | 5 | 8 SP | -| **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ⏳ To Do | 4 | 6 SP | +| **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ✅ Done | 4 | 6 SP | | **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ⏳ To Do | 6 | 13 SP | | **E3** | UI Components | ActionChainCard, ClarificationCard | ⏳ To Do | 4 | 8 SP | | **E4** | Nudge MVP (Layer 3) | Proactieve suggesties | ⏳ To Do | 3 | 5 SP | @@ -321,10 +321,10 @@ export const FEATURE_FLAGS = { | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP | |----------|--------------|---------------------|--------|------------------|----| -| E1.S1 | **Pattern matching** implementeren | Regex patterns voor alle intent types met weights | ⏳ | E0.S1 | 2 | -| E1.S2 | **Escalatie triggers** detectie | Multi-intent signals, context signals, relative time | ⏳ | E1.S1 | 2 | -| E1.S3 | **Ambiguity detection** | Top-2 score delta < 0.1 → escaleer | ⏳ | E1.S1 | 1 | -| E1.S4 | **Unit tests** Reflex classifier | Test suite voor simpele en complexe inputs | ⏳ | E1.S1-S3 | 1 | +| E1.S1 | **Pattern matching** implementeren | Regex patterns voor alle intent types met weights | ✅ | E0.S1 | 2 | +| E1.S2 | **Escalatie triggers** detectie | Multi-intent signals, context signals, relative time | ✅ | E1.S1 | 2 | +| E1.S3 | **Ambiguity detection** | Top-2 score delta < 0.1 → escaleer | ✅ | E1.S1 | 1 | +| E1.S4 | **Unit tests** Reflex classifier | Test suite voor simpele en complexe inputs | ✅ | E1.S1-S3 | 1 | **Deliverable:** Simpele commando's werken direct (<20ms) @@ -374,9 +374,9 @@ export const AMBIGUITY_THRESHOLD = 0.1; ``` *Done criteria:* -- [ ] "agenda vandaag" → `{ intent: 'agenda_query', shouldEscalateToAI: false }` -- [ ] Processing time < 20ms -- [ ] Bestaande V1 classifier blijft werken (backward compatible) +- [x] "agenda vandaag" → `{ intent: 'agenda_query', shouldEscalateToAI: false }` +- [x] Processing time < 20ms +- [x] Bestaande V1 classifier blijft werken (backward compatible) --- @@ -397,9 +397,9 @@ const RELATIVE_TIME_SIGNALS = /\b(morgen|overmorgen|volgende week|over \d+ dagen - Return `escalationReason` voor logging *Done criteria:* -- [ ] "Zeg Jan af en maak notitie" → escalates (`multi_intent_detected`) -- [ ] "Maak notitie voor hem" → escalates (`needs_context`) -- [ ] "Plan afspraak morgen" → escalates (`relative_time`) +- [x] "Zeg Jan af en maak notitie" → escalates (`multi_intent_detected`) +- [x] "Maak notitie voor hem" → escalates (`needs_context`) +- [x] "Plan afspraak morgen" → escalates (`relative_time`) --- @@ -422,8 +422,8 @@ if (delta < AMBIGUITY_THRESHOLD) { - Delta = 0.04 < 0.1 → escaleer naar AI *Done criteria:* -- [ ] Ambigue input triggert escalatie -- [ ] `secondBestIntent` en `secondBestConfidence` in result +- [x] Ambigue input triggert escalatie +- [x] `secondBestIntent` en `secondBestConfidence` in result --- @@ -454,8 +454,8 @@ describe('Reflex Classifier', () => { ``` *Done criteria:* -- [ ] `pnpm test lib/cortex/__tests__/reflex-classifier.test.ts` slaagt -- [ ] Coverage voor alle escalatie scenarios +- [x] `pnpm tsx lib/cortex/__tests__/reflex-classifier.test.ts` slaagt (25 tests) +- [x] Coverage voor alle escalatie scenarios --- diff --git a/lib/cortex/__tests__/reflex-classifier.test.ts b/lib/cortex/__tests__/reflex-classifier.test.ts new file mode 100644 index 0000000..7c430d4 --- /dev/null +++ b/lib/cortex/__tests__/reflex-classifier.test.ts @@ -0,0 +1,276 @@ +/** + * Reflex Classifier Unit Tests + * + * Tests for the Reflex Arc (Layer 1) classifier. + * Covers: pattern matching, escalation triggers, ambiguity detection. + * + * Run with: pnpm tsx lib/cortex/__tests__/reflex-classifier.test.ts + */ + +import { classifyWithReflex } from '../reflex-classifier'; +import { CONFIDENCE_THRESHOLD, AMBIGUITY_THRESHOLD } from '../types'; + +// Simple test runner for direct execution +let passed = 0; +let failed = 0; +let currentDescribe = ''; + +function describe(name: string, fn: () => void) { + currentDescribe = name; + console.log(`\n${name}`); + fn(); +} + +function it(name: string, fn: () => void) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` ${error instanceof Error ? error.message : error}`); + failed++; + } +} + +function expect(actual: T) { + return { + toBe(expected: T) { + if (actual !== expected) { + throw new Error(`Expected ${expected}, got ${actual}`); + } + }, + toBeLessThan(expected: number) { + if (typeof actual !== 'number' || actual >= expected) { + throw new Error(`Expected ${actual} to be less than ${expected}`); + } + }, + toBeGreaterThanOrEqual(expected: number) { + if (typeof actual !== 'number' || actual < expected) { + throw new Error(`Expected ${actual} to be >= ${expected}`); + } + }, + toBeTruthy() { + if (!actual) { + throw new Error(`Expected truthy, got ${actual}`); + } + }, + toBeFalsy() { + if (actual) { + throw new Error(`Expected falsy, got ${actual}`); + } + }, + toBeUndefined() { + if (actual !== undefined) { + throw new Error(`Expected undefined, got ${actual}`); + } + }, + toBeDefined() { + if (actual === undefined) { + throw new Error(`Expected defined, got undefined`); + } + }, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +console.log('='.repeat(60)); +console.log('Reflex Classifier Tests'); +console.log('='.repeat(60)); + +describe('Simple intents - should NOT escalate', () => { + it('"agenda vandaag" → agenda_query', () => { + const result = classifyWithReflex('agenda vandaag'); + expect(result.intent).toBe('agenda_query'); + expect(result.shouldEscalateToAI).toBe(false); + expect(result.processingTimeMs).toBeLessThan(20); + }); + + it('"zoek marie" → zoeken', () => { + const result = classifyWithReflex('zoek marie'); + expect(result.intent).toBe('zoeken'); + expect(result.shouldEscalateToAI).toBe(false); + }); + + it('"notitie jan medicatie" → dagnotitie', () => { + const result = classifyWithReflex('notitie jan medicatie'); + expect(result.intent).toBe('dagnotitie'); + expect(result.shouldEscalateToAI).toBe(false); + }); + + it('"overdracht" → overdracht', () => { + const result = classifyWithReflex('overdracht'); + expect(result.intent).toBe('overdracht'); + expect(result.shouldEscalateToAI).toBe(false); + }); + + it('"maak afspraak" → create_appointment', () => { + const result = classifyWithReflex('maak afspraak'); + expect(result.intent).toBe('create_appointment'); + expect(result.shouldEscalateToAI).toBe(false); + }); + + it('"annuleer afspraak" → cancel_appointment', () => { + const result = classifyWithReflex('annuleer afspraak'); + expect(result.intent).toBe('cancel_appointment'); + expect(result.shouldEscalateToAI).toBe(false); + }); + + it('"verzet afspraak" → reschedule_appointment', () => { + const result = classifyWithReflex('verzet afspraak'); + expect(result.intent).toBe('reschedule_appointment'); + expect(result.shouldEscalateToAI).toBe(false); + }); +}); + +describe('Multi-intent - SHOULD escalate', () => { + it('"zeg jan af en maak notitie" → multi_intent_detected', () => { + const result = classifyWithReflex('zeg jan af en maak notitie'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('multi_intent_detected'); + }); + + it('"eerst zoeken dan notitie" → multi_intent_detected', () => { + const result = classifyWithReflex('eerst zoeken dan notitie'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('multi_intent_detected'); + }); + + it('"agenda bekijken en daarna overdracht" → multi_intent_detected', () => { + const result = classifyWithReflex('agenda bekijken en daarna overdracht'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('multi_intent_detected'); + }); +}); + +describe('Context-dependent - SHOULD escalate', () => { + it('"maak notitie voor hem" → needs_context', () => { + const result = classifyWithReflex('maak notitie voor hem'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('needs_context'); + }); + + it('"verzet zijn afspraak" → needs_context', () => { + const result = classifyWithReflex('verzet zijn afspraak'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('needs_context'); + }); + + it('"zoek die patiënt" → needs_context', () => { + const result = classifyWithReflex('zoek die patiënt'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('needs_context'); + }); +}); + +describe('Relative time - SHOULD escalate', () => { + it('"plan afspraak morgen" → relative_time', () => { + const result = classifyWithReflex('plan afspraak morgen'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('relative_time'); + }); + + it('"agenda volgende week" → relative_time', () => { + const result = classifyWithReflex('agenda volgende week'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('relative_time'); + }); + + it('"afspraak overmorgen" → relative_time', () => { + const result = classifyWithReflex('afspraak overmorgen'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('relative_time'); + }); +}); + +describe('Low confidence - SHOULD escalate', () => { + it('"xyz123" → low_confidence', () => { + const result = classifyWithReflex('xyz123'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('low_confidence'); + expect(result.confidence).toBeLessThan(CONFIDENCE_THRESHOLD); + }); + + it('empty input → low_confidence', () => { + const result = classifyWithReflex(''); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('low_confidence'); + }); + + it('"Jan" (single name) → low_confidence', () => { + const result = classifyWithReflex('Jan'); + expect(result.shouldEscalateToAI).toBe(true); + expect(result.escalationReason).toBe('low_confidence'); + expect(result.intent).toBe('zoeken'); // Still detected as search + expect(result.confidence).toBeLessThan(CONFIDENCE_THRESHOLD); + }); +}); + +describe('Second best tracking', () => { + it('high confidence match has optional secondBest', () => { + const result = classifyWithReflex('agenda vandaag'); + expect(result.intent).toBe('agenda_query'); + expect(result.confidence).toBeGreaterThanOrEqual(CONFIDENCE_THRESHOLD); + // secondBest may or may not be present + }); + + it('low confidence match includes secondBest info', () => { + const result = classifyWithReflex('xyz unknown input'); + expect(result.secondBestIntent).toBeDefined(); + expect(result.secondBestConfidence).toBeDefined(); + }); +}); + +describe('Performance', () => { + it('all classifications complete in <20ms', () => { + const inputs = [ + 'agenda', + 'zoek jan', + 'notitie medicatie', + 'overdracht', + 'maak afspraak met jan', + 'annuleer de afspraak', + 'verzet afspraak naar morgen', + ]; + + for (const input of inputs) { + const result = classifyWithReflex(input); + expect(result.processingTimeMs).toBeLessThan(20); + } + }); + + it('batch of 100 classifications in <200ms total', () => { + const start = performance.now(); + for (let i = 0; i < 100; i++) { + classifyWithReflex('agenda vandaag'); + } + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(200); + console.log(` (100 iterations in ${elapsed.toFixed(2)}ms)`); + }); +}); + +describe('Thresholds', () => { + it('CONFIDENCE_THRESHOLD is 0.7', () => { + expect(CONFIDENCE_THRESHOLD).toBe(0.7); + }); + + it('AMBIGUITY_THRESHOLD is 0.1', () => { + expect(AMBIGUITY_THRESHOLD).toBe(0.1); + }); +}); + +// ============================================================================ +// Results +// ============================================================================ + +console.log('\n' + '='.repeat(60)); +console.log(`Results: ${passed} passed, ${failed} failed`); +console.log('='.repeat(60)); + +if (failed > 0) { + process.exit(1); +} diff --git a/lib/cortex/index.ts b/lib/cortex/index.ts index d4217a7..02414e3 100644 --- a/lib/cortex/index.ts +++ b/lib/cortex/index.ts @@ -6,6 +6,7 @@ export * from './types'; export * from './use-cortex-voice'; export * from './intent-classifier'; export * from './intent-classifier-ai'; +export * from './reflex-classifier'; export * from './entity-extractor'; export * from './date-time-parser'; export * from './logger'; diff --git a/lib/cortex/reflex-classifier.ts b/lib/cortex/reflex-classifier.ts new file mode 100644 index 0000000..afbd7e0 --- /dev/null +++ b/lib/cortex/reflex-classifier.ts @@ -0,0 +1,348 @@ +/** + * Reflex Classifier (Layer 1) + * + * Fast, local pattern matching for Cortex V2. + * Target: <20ms response time with escalation-aware logic. + * + * Key differences from V1 intent-classifier.ts: + * - Returns LocalClassificationResult with escalation info + * - Tracks top-2 matches for ambiguity detection + * - Detects escalation triggers before pattern matching + */ + +import type { + CortexIntent, + LocalClassificationResult, + EscalationReason, +} from './types'; +import { CONFIDENCE_THRESHOLD, AMBIGUITY_THRESHOLD } from './types'; + +interface PatternConfig { + pattern: RegExp; + weight: number; +} + +// Intent patterns - copied from V1 intent-classifier.ts for SOC +// Weight 1.0 = exact match, 0.8 = strong match, 0.6 = partial match +const INTENT_PATTERNS: Record, PatternConfig[]> = { + dagnotitie: [ + // Exact commands + { pattern: /^dagnotitie\b/i, weight: 1.0 }, + { pattern: /^notitie\b/i, weight: 1.0 }, + { pattern: /^nieuwe?\s+notitie\b/i, weight: 1.0 }, + + // Pattern: "notitie [naam]" or "[naam] notitie" + { pattern: /^notitie\s+\w+/i, weight: 0.95 }, + { pattern: /^\w+\s+notitie\b/i, weight: 0.85 }, + + // Pattern: "[naam] [categorie]" (e.g., "jan medicatie") + { pattern: /^\w+\s+(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 0.9 }, + + // Pattern: "notitie [naam] [categorie]" + { pattern: /^notitie\s+\w+\s+(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 1.0 }, + + // Categorie-first patterns + { pattern: /^(medicatie|adl|gedrag|incident|observatie)\s+\w+/i, weight: 0.85 }, + { pattern: /^(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 0.7 }, + + // Schrijf patterns + { pattern: /^schrijf\b/i, weight: 0.8 }, + { pattern: /^rapporteer\b/i, weight: 0.8 }, + { pattern: /^registreer\b/i, weight: 0.8 }, + ], + + zoeken: [ + // Exact commands + { pattern: /^zoek\b/i, weight: 1.0 }, + { pattern: /^vind\b/i, weight: 1.0 }, + { pattern: /^zoeken\b/i, weight: 1.0 }, + + // Question patterns + { pattern: /^wie\s+is\b/i, weight: 1.0 }, + { pattern: /^waar\s+is\b/i, weight: 0.9 }, + { pattern: /^welke\s+pati[eë]nt/i, weight: 0.9 }, + + // Pattern: "zoek [naam]" + { pattern: /^zoek\s+\w+/i, weight: 1.0 }, + { pattern: /^vind\s+\w+/i, weight: 1.0 }, + + // Info requests + { pattern: /^info\s+\w+/i, weight: 0.8 }, + { pattern: /^gegevens\s+\w+/i, weight: 0.8 }, + { pattern: /^dossier\s+\w+/i, weight: 0.85 }, + + // Partial name lookups (single word that could be a name) + { pattern: /^[A-Z][a-z]+$/i, weight: 0.5 }, + ], + + overdracht: [ + // Exact commands + { pattern: /^overdracht\b/i, weight: 1.0 }, + { pattern: /^dienst\s*overdracht\b/i, weight: 1.0 }, + + // Dienst patterns + { pattern: /^dienst\s+(klaar|afronden|be[eë]indigen)\b/i, weight: 1.0 }, + { pattern: /^einde?\s+dienst\b/i, weight: 1.0 }, + { pattern: /^dienst\s+einde?\b/i, weight: 1.0 }, + + // Question patterns + { pattern: /^wat\s+moet\s+ik\s+weten\b/i, weight: 1.0 }, + { pattern: /^wat\s+is\s+er\s+gebeurd\b/i, weight: 0.9 }, + { pattern: /^updates?\b/i, weight: 0.7 }, + { pattern: /^samenvatting\b/i, weight: 0.85 }, + + // Start dienst + { pattern: /^start\s+dienst\b/i, weight: 0.9 }, + { pattern: /^begin\s+dienst\b/i, weight: 0.9 }, + { pattern: /^nieuwe?\s+dienst\b/i, weight: 0.85 }, + ], + + agenda_query: [ + // Exact commands + { pattern: /^agenda\b/i, weight: 1.0 }, + { pattern: /^mijn\s+agenda\b/i, weight: 0.95 }, + { pattern: /^volgende\s+afspraak\b/i, weight: 0.95 }, + + // Question patterns + { pattern: /^wat\s+zijn\s+(mijn\s+)?afspraken\b/i, weight: 1.0 }, + { pattern: /^(wat|wanneer)\s+is\s+(mijn\s+)?volgende\s+afspraak\b/i, weight: 1.0 }, + + // Date scoped queries + { pattern: /^afspraken\s+(vandaag|deze\s+week)\b/i, weight: 0.95 }, + { pattern: /^(vandaag|deze\s+week)\s+afspraken\b/i, weight: 0.9 }, + { pattern: /^agenda\s+(vandaag|deze\s+week)\b/i, weight: 0.9 }, + { pattern: /^planning\s+(vandaag|deze\s+week)\b/i, weight: 0.85 }, + { pattern: /^afspraken\b(?!\s+(maken|plannen|inplannen|annuleren|verzetten|verplaatsen|verschuiven))/i, weight: 0.85 }, + + // Verb patterns + { pattern: /^toon\s+agenda\b/i, weight: 0.9 }, + { pattern: /^laat\s+(mijn\s+)?agenda\s+zien\b/i, weight: 0.85 }, + ], + + create_appointment: [ + // Exact commands + { pattern: /^maak\s+afspraak\b/i, weight: 1.0 }, + { pattern: /^plan\s+(een\s+)?(afspraak|intake|gesprek)\b/i, weight: 1.0 }, + { pattern: /^afspraak\s+(maken|plannen|inplannen)\b/i, weight: 0.95 }, + { pattern: /^afspraken\s+(maken|plannen|inplannen)\b/i, weight: 0.95 }, + { pattern: /^nieuwe?\s+afspraak\b/i, weight: 0.95 }, + + // Type-first patterns + { pattern: /^maak\s+(een\s+)?(intake|behandeling|gesprek)\b/i, weight: 0.9 }, + { pattern: /^(intake|behandeling|gesprek)\s+(met\s+)?\w+/i, weight: 0.85 }, + { pattern: /^afspraak\s+met\s+\w+/i, weight: 0.9 }, + { pattern: /^plan\s+afspraak\s+met\s+\w+/i, weight: 0.9 }, + ], + + cancel_appointment: [ + // Exact commands + { pattern: /^annuleer\s+(de\s+)?afspraak\b/i, weight: 1.0 }, + { pattern: /^cancel\s+(de\s+)?afspraak\b/i, weight: 1.0 }, + { pattern: /^verwijder\s+afspraak\b/i, weight: 0.95 }, + { pattern: /^afspraak\s+annuleren\b/i, weight: 0.95 }, + { pattern: /^zeg\s+afspraak\s+af\b/i, weight: 0.95 }, + + // Short forms + { pattern: /^annuleer\s+\w+/i, weight: 0.7 }, + ], + + reschedule_appointment: [ + // Exact commands + { pattern: /^verzet\s+(de\s+)?afspraak\b/i, weight: 1.0 }, + { pattern: /^verplaats\s+(de\s+)?afspraak\b/i, weight: 1.0 }, + { pattern: /^verschuif\s+afspraak\b/i, weight: 0.95 }, + { pattern: /^afspraak\s+verzetten\b/i, weight: 0.95 }, + + // Time shifts + { pattern: /^\d{1,2}[:.]\d{2}\s+naar\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 }, + { pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 }, + { pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 }, + ], +}; + +/** + * Escalation trigger patterns (E1.S2) + * + * These patterns detect inputs that require AI processing: + * - multi_intent: Multiple actions in one command + * - needs_context: References to previous context (pronouns) + * - relative_time: Time expressions that need date calculation + */ +const ESCALATION_PATTERNS = { + // Multi-intent indicators: conjunctions that connect actions + // "zeg jan af en maak notitie" → two actions + // "eerst zoeken dan notitie" → sequence of actions + multi_intent: /\b(en\b(?=.*\b(maak|zoek|plan|annuleer|verzet|schrijf|registreer))|daarna|dan\b(?=\s)|eerst\b|vervolgens|ook\s+nog)\b/i, + + // Context-dependent pronouns: need prior context to resolve + // "maak notitie voor hem" → who is "hem"? + // "verzet zijn afspraak" → whose appointment? + needs_context: /\b(hij|zij|hem|haar|zijn|hun|die|deze|dat|dezelfde|diegene|er)\b/i, + + // Relative time expressions: require date calculation + // "morgen", "overmorgen", "volgende week" → need AI to resolve + relative_time: /\b(morgen|overmorgen|volgende\s+week|over\s+\d+\s+dagen?|komende\s+(maandag|dinsdag|woensdag|donderdag|vrijdag|zaterdag|zondag))\b/i, +}; + +/** + * Detect escalation triggers in input. + * Returns the reason if escalation is needed, null otherwise. + */ +function detectEscalationTriggers(input: string): EscalationReason | null { + if (ESCALATION_PATTERNS.multi_intent.test(input)) { + return 'multi_intent_detected'; + } + if (ESCALATION_PATTERNS.needs_context.test(input)) { + return 'needs_context'; + } + if (ESCALATION_PATTERNS.relative_time.test(input)) { + return 'relative_time'; + } + return null; +} + +/** + * Build result for early escalation (before pattern matching). + */ +function buildEscalatedResult( + input: string, + reason: EscalationReason, + startTime: number +): LocalClassificationResult { + // Still try to find a likely intent for context + const quickMatch = findBestMatch(input); + + return { + intent: quickMatch.intent, + confidence: quickMatch.confidence, + matchedPattern: quickMatch.pattern, + processingTimeMs: performance.now() - startTime, + shouldEscalateToAI: true, + escalationReason: reason, + }; +} + +/** + * Find best matching intent (helper for quick lookups). + */ +function findBestMatch(input: string): { intent: CortexIntent; confidence: number; pattern: string } { + let best = { intent: 'unknown' as CortexIntent, confidence: 0, pattern: '' }; + + for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) { + for (const { pattern, weight } of patterns) { + if (pattern.test(input)) { + if (weight > best.confidence) { + best = { intent: intent as CortexIntent, confidence: weight, pattern: pattern.source }; + } + if (weight === 1.0) break; + } + } + if (best.confidence === 1.0) break; + } + + return best; +} + +/** + * Build the final classification result with escalation decision. + */ +function buildResult( + best: { intent: CortexIntent; confidence: number; pattern: string }, + secondBest: { intent: CortexIntent; confidence: number }, + startTime: number +): LocalClassificationResult { + const processingTimeMs = performance.now() - startTime; + + // Low confidence → escalate + if (best.confidence < CONFIDENCE_THRESHOLD) { + return { + intent: best.intent, + confidence: best.confidence, + secondBestIntent: secondBest.intent, + secondBestConfidence: secondBest.confidence, + matchedPattern: best.pattern, + processingTimeMs, + shouldEscalateToAI: true, + escalationReason: 'low_confidence', + }; + } + + // Ambiguous (top-2 too close) → escalate + const delta = best.confidence - secondBest.confidence; + if (delta < AMBIGUITY_THRESHOLD && secondBest.confidence > 0) { + return { + intent: best.intent, + confidence: best.confidence, + secondBestIntent: secondBest.intent, + secondBestConfidence: secondBest.confidence, + matchedPattern: best.pattern, + processingTimeMs, + shouldEscalateToAI: true, + escalationReason: 'ambiguous', + }; + } + + // High confidence, unambiguous → no escalation + return { + intent: best.intent, + confidence: best.confidence, + secondBestIntent: secondBest.confidence > 0 ? secondBest.intent : undefined, + secondBestConfidence: secondBest.confidence > 0 ? secondBest.confidence : undefined, + matchedPattern: best.pattern, + processingTimeMs, + shouldEscalateToAI: false, + }; +} + +/** + * Classify user input using the Reflex Arc (Layer 1). + * + * Fast, local pattern matching with escalation-aware logic. + * Target: <20ms processing time. + * + * @param input - User input string + * @returns LocalClassificationResult with intent, confidence, and escalation info + */ +export function classifyWithReflex(input: string): LocalClassificationResult { + const startTime = performance.now(); + const normalizedInput = input.trim(); + + // Empty input + if (!normalizedInput) { + return { + intent: 'unknown', + confidence: 0, + processingTimeMs: performance.now() - startTime, + shouldEscalateToAI: true, + escalationReason: 'low_confidence', + }; + } + + // 1. Check escalation triggers FIRST + const escalationReason = detectEscalationTriggers(normalizedInput); + if (escalationReason) { + return buildEscalatedResult(normalizedInput, escalationReason, startTime); + } + + // 2. Pattern matching with top-2 tracking + let bestMatch = { intent: 'unknown' as CortexIntent, confidence: 0, pattern: '' }; + let secondBest = { intent: 'unknown' as CortexIntent, confidence: 0 }; + + for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) { + for (const { pattern, weight } of patterns) { + if (pattern.test(normalizedInput)) { + if (weight > bestMatch.confidence) { + // Current best becomes second best + secondBest = { intent: bestMatch.intent, confidence: bestMatch.confidence }; + bestMatch = { intent: intent as CortexIntent, confidence: weight, pattern: pattern.source }; + } else if (weight > secondBest.confidence && intent !== bestMatch.intent) { + // Track second best (different intent) + secondBest = { intent: intent as CortexIntent, confidence: weight }; + } + } + } + } + + // 3. Build result with escalation decision + return buildResult(bestMatch, secondBest, startTime); +}