feat(cortex): Epic 0 - Foundation & Context voor V2 architectuur

Implementeert de basis voor de Cortex V2 three-layer architecture.

E0.S1 - CortexContext Types (lib/cortex/types.ts):
- EscalationReason type voor Reflex → Orchestrator
- LocalClassificationResult met ambiguity detection
- CortexContext voor AI classificatie context
- IntentChain en IntentAction voor multi-intent flows
- NudgeSuggestion voor proactieve suggesties
- CONFIDENCE_THRESHOLD (0.7) en AMBIGUITY_THRESHOLD (0.1)
- getCurrentShift() utility (DRY - herbruikbaar)

E0.S2 - Context API (app/api/cortex/context/route.ts):
- GET endpoint voor huidige context
- Haalt agenda vandaag op uit database
- Retourneert CortexContext object

E0.S3 - Feature Flags (lib/config/feature-flags.ts):
- CORTEX_V2_ENABLED, CORTEX_MULTI_INTENT
- CORTEX_NUDGE, CORTEX_LOGGING
- isFeatureEnabled() helper
- Dev mode: default true

E0.S4 - CortexStore V2 (stores/cortex-store.ts):
- Types geïmporteerd uit lib/cortex (DRY)
- ChatEntities type voor AI responses (string-based)
- V2 state: context, activeChain, chainHistory, suggestions
- V2 actions: chain management, nudge management, clarification

E0.S5 - Classification Logging (lib/cortex/logger.ts):
- PII sanitization (BSN, tel, namen, email)
- logClassification(), logEscalation(), logNudge()
- logPerformance() met threshold checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-31 12:46:35 +01:00
parent 06287d49f4
commit cf26022583
6 changed files with 697 additions and 44 deletions

View File

@@ -0,0 +1,46 @@
/**
* Feature Flags for Cortex V2
*
* Controls gradual rollout of new features.
* In development, all flags default to true for testing.
*/
/** Feature flag definitions */
export const FEATURE_FLAGS = {
/** Enable Cortex V2 architecture (Reflex + Orchestrator) */
CORTEX_V2_ENABLED: process.env.NEXT_PUBLIC_CORTEX_V2 === 'true',
/** Enable multi-intent detection and ActionChainCard */
CORTEX_MULTI_INTENT: process.env.NEXT_PUBLIC_CORTEX_MULTI_INTENT === 'true',
/** Enable proactive nudge suggestions */
CORTEX_NUDGE: process.env.NEXT_PUBLIC_CORTEX_NUDGE === 'true',
/** Enable classification logging (dev only) */
CORTEX_LOGGING: process.env.NEXT_PUBLIC_CORTEX_LOGGING === 'true',
} as const;
/** Type for feature flag keys */
export type FeatureFlagKey = keyof typeof FEATURE_FLAGS;
/**
* Check if a feature flag is enabled
*/
export function isFeatureEnabled(flag: FeatureFlagKey): boolean {
// In development, default to true if env var not set
if (process.env.NODE_ENV === 'development') {
const envValue = process.env[`NEXT_PUBLIC_${flag}`];
// Only return false if explicitly set to 'false'
return envValue !== 'false';
}
return FEATURE_FLAGS[flag];
}
/**
* Get all enabled features (useful for debugging)
*/
export function getEnabledFeatures(): FeatureFlagKey[] {
return (Object.keys(FEATURE_FLAGS) as FeatureFlagKey[]).filter(
(flag) => isFeatureEnabled(flag)
);
}

View File

@@ -8,3 +8,4 @@ export * from './intent-classifier';
export * from './intent-classifier-ai';
export * from './entity-extractor';
export * from './date-time-parser';
export * from './logger';

160
lib/cortex/logger.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Cortex Classification Logger
*
* Logging utility with PII sanitization for classification results.
* In development: full logging for debugging.
* In production: sanitized logging (no names, BSN, phone numbers).
*/
import { isFeatureEnabled } from '@/lib/config/feature-flags';
import type { LocalClassificationResult, IntentChain } from './types';
// PII patterns to sanitize in production
const PII_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
// BSN (9 digits)
{ pattern: /\b\d{9}\b/g, replacement: '[BSN]' },
// Dutch phone numbers
{ pattern: /\b0[1-9]\d{8}\b/g, replacement: '[TEL]' },
{ pattern: /\b06[-\s]?\d{8}\b/g, replacement: '[TEL]' },
{ pattern: /\+31[-\s]?\d{9}\b/g, replacement: '[TEL]' },
// Dates (dd-mm-yyyy format)
{ pattern: /\b\d{2}[-/]\d{2}[-/]\d{4}\b/g, replacement: '[DATUM]' },
// Email addresses
{ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, replacement: '[EMAIL]' },
// Common Dutch first names (case insensitive)
{
pattern:
/\b(Jan|Piet|Klaas|Marie|Anna|Lisa|Eva|Emma|Sophie|Thomas|Lucas|Daan|Sem|Liam|Noah|Julia|Sara|Lotte|Henk|Willem|Pieter|Johan|Marieke|Sandra|Linda|Monique|Peter|Hans|Jeroen|Bart|Mark|Erik|Rob|Kees|Cor|Arie|Gerrit|Hendrik|Johannes)\b/gi,
replacement: '[NAAM]',
},
// Common Dutch family name prefixes + word after
{
pattern: /\b(van|de|den|der|het|ter|ten)\s+[A-Z][a-z]+\b/gi,
replacement: '[NAAM]',
},
];
/**
* Sanitize input string by removing PII
* In development: returns original input
* In production: replaces PII with placeholders
*/
export function sanitizeForLogging(input: string): string {
if (process.env.NODE_ENV === 'development') {
return input;
}
let sanitized = input;
for (const { pattern, replacement } of PII_PATTERNS) {
sanitized = sanitized.replace(pattern, replacement);
}
return sanitized;
}
/**
* Log a classification result (Reflex or Orchestrator)
* Only logs if CORTEX_LOGGING feature flag is enabled
*/
export function logClassification(
input: string,
result: LocalClassificationResult | IntentChain
): void {
if (!isFeatureEnabled('CORTEX_LOGGING')) {
return;
}
const sanitizedInput = sanitizeForLogging(input);
const timestamp = new Date().toISOString();
if ('shouldEscalateToAI' in result) {
// LocalClassificationResult (Reflex Arc)
const logData = {
input: sanitizedInput,
intent: result.intent,
confidence: result.confidence.toFixed(2),
escalate: result.shouldEscalateToAI,
reason: result.escalationReason || null,
timeMs: result.processingTimeMs,
};
if (result.shouldEscalateToAI) {
console.log(`[Cortex:Reflex→AI] ${timestamp}`, logData);
} else {
console.log(`[Cortex:Reflex] ${timestamp}`, logData);
}
} else {
// IntentChain (Orchestrator)
console.log(`[Cortex:Orchestrator] ${timestamp}`, {
input: sanitizedInput,
actionCount: result.actions.length,
intents: result.actions.map((a) => a.intent),
source: result.meta.source,
timeMs: result.meta.processingTimeMs,
reasoning: result.meta.aiReasoning
? sanitizeForLogging(result.meta.aiReasoning).slice(0, 100)
: null,
});
}
}
/**
* Log an escalation event (Reflex → Orchestrator)
*/
export function logEscalation(
input: string,
reason: LocalClassificationResult['escalationReason'],
reflexResult: LocalClassificationResult
): void {
if (!isFeatureEnabled('CORTEX_LOGGING')) {
return;
}
console.log(`[Cortex:Escalation] ${new Date().toISOString()}`, {
input: sanitizeForLogging(input),
reason,
reflexIntent: reflexResult.intent,
reflexConfidence: reflexResult.confidence.toFixed(2),
secondBest: reflexResult.secondBestIntent || null,
secondBestConfidence: reflexResult.secondBestConfidence?.toFixed(2) || null,
});
}
/**
* Log a nudge suggestion event
*/
export function logNudge(
triggeredBy: string,
suggestionMessage: string,
accepted: boolean
): void {
if (!isFeatureEnabled('CORTEX_LOGGING')) {
return;
}
console.log(`[Cortex:Nudge] ${new Date().toISOString()}`, {
trigger: triggeredBy,
suggestion: sanitizeForLogging(suggestionMessage),
accepted,
});
}
/**
* Log performance metrics
*/
export function logPerformance(
layer: 'reflex' | 'orchestrator',
durationMs: number,
success: boolean
): void {
if (!isFeatureEnabled('CORTEX_LOGGING')) {
return;
}
const emoji = success ? '✓' : '✗';
const threshold = layer === 'reflex' ? 20 : 800;
const status = durationMs <= threshold ? 'OK' : 'SLOW';
console.log(
`[Cortex:Perf] ${layer} ${emoji} ${durationMs}ms [${status}]`
);
}

View File

@@ -22,6 +22,15 @@ export type BlockType = Exclude<CortexIntent, 'unknown'> | 'patient-dashboard';
// Shift types
export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond';
/** Calculate current shift based on time of day */
export function getCurrentShift(): ShiftType {
const hour = new Date().getHours();
if (hour >= 0 && hour < 7) return 'nacht';
if (hour >= 7 && hour < 12) return 'ochtend';
if (hour >= 12 && hour < 17) return 'middag';
return 'avond';
}
// Intent classification result
export interface IntentClassificationResult {
intent: CortexIntent;
@@ -145,3 +154,161 @@ export interface RecentAction {
timestamp: Date;
patientName?: string;
}
// =============================================================================
// Cortex V2 Types — Three-Layer Architecture
// =============================================================================
// -----------------------------------------------------------------------------
// Layer 1: Reflex Arc Types
// -----------------------------------------------------------------------------
/** Reasons why Reflex Arc escalates to Orchestrator */
export type EscalationReason =
| 'low_confidence'
| 'ambiguous'
| 'multi_intent_detected'
| 'needs_context'
| 'relative_time';
/** Local classification result from Reflex Arc */
export interface LocalClassificationResult {
intent: CortexIntent;
confidence: number;
/** Second best match for ambiguity detection */
secondBestIntent?: CortexIntent;
secondBestConfidence?: number;
matchedPattern?: string;
processingTimeMs: number;
shouldEscalateToAI: boolean;
escalationReason?: EscalationReason;
}
/** Confidence threshold for high-confidence local classification */
export const CONFIDENCE_THRESHOLD = 0.7;
/** Threshold for ambiguity detection (delta between top-2 scores) */
export const AMBIGUITY_THRESHOLD = 0.1;
// -----------------------------------------------------------------------------
// Layer 2: Orchestrator Types
// -----------------------------------------------------------------------------
/** Context provided to AI for intelligent classification */
export interface CortexContext {
activePatient: {
id: string;
name: string;
recentNotes?: string[];
upcomingAppointments?: { date: Date; type: string }[];
} | null;
currentView: 'dashboard' | 'patient-detail' | 'agenda' | 'reports' | 'chat';
shift: ShiftType;
currentTime: Date;
agendaToday: {
time: string;
patientName: string;
patientId: string;
type: string;
}[];
recentIntents: {
intent: CortexIntent;
patientName?: string;
timestamp: Date;
}[];
userPreferences?: {
confirmationLevel: 'always' | 'destructive' | 'never';
frequentIntents: CortexIntent[];
};
}
/** Action status in an intent chain */
export type IntentActionStatus =
| 'pending'
| 'confirming'
| 'executing'
| 'success'
| 'failed'
| 'skipped';
/** Single action in a multi-intent chain */
export interface IntentAction {
id: string;
sequence: number;
intent: CortexIntent;
confidence: number;
entities: ExtractedEntities;
status: IntentActionStatus;
requiresConfirmation: boolean;
confirmationMessage?: string;
error?: {
code: string;
message: string;
recoverable: boolean;
};
startedAt?: Date;
completedAt?: Date;
}
/** Chain status */
export type IntentChainStatus =
| 'pending'
| 'executing'
| 'completed'
| 'partial'
| 'failed';
/** Multi-intent container from single user input */
export interface IntentChain {
id: string;
originalInput: string;
createdAt: Date;
actions: IntentAction[];
status: IntentChainStatus;
meta: {
source: 'local' | 'ai';
processingTimeMs: number;
aiReasoning?: string;
};
}
// -----------------------------------------------------------------------------
// Layer 3: Nudge Types
// -----------------------------------------------------------------------------
/** Nudge suggestion priority */
export type NudgePriority = 'low' | 'medium' | 'high';
/** Nudge suggestion status */
export type NudgeStatus = 'pending' | 'accepted' | 'dismissed' | 'expired';
/** Proactive suggestion after action completion */
export interface NudgeSuggestion {
id: string;
trigger: {
actionId: string;
intent: CortexIntent;
entities: ExtractedEntities;
};
suggestion: {
intent: CortexIntent;
entities: Partial<ExtractedEntities>;
message: string;
rationale: string;
};
status: NudgeStatus;
priority: NudgePriority;
expiresAt?: Date;
createdAt: Date;
}
// -----------------------------------------------------------------------------
// Clarification Types
// -----------------------------------------------------------------------------
/** Clarification request when AI needs user input */
export interface ClarificationRequest {
question: string;
options: string[];
originalInput: string;
}