43 KiB
Technisch Ontwerp (TO) – Swift: Contextual UI EPD
Projectnaam: Swift (Contextual UI EPD) Versie: v1.0 Datum: 23-12-2024 Auteur: Claude Code / Development Team
1. Doel en relatie met PRD en FO
Doel van dit document
Het Technisch Ontwerp beschrijft hoe Swift technisch wordt gebouwd. Waar het PRD (swift-prd.md) het wat beschrijft en het FO (swift-fo-ai.md) het hoe functioneel, gaat dit TO over architectuur, componenten, data flows en implementatie details.
Relatie met andere documenten
| Document | Beschrijft | Status |
|---|---|---|
swift-prd.md |
Product visie, user stories, scope | Definitief |
swift-fo-ai.md |
Functionele flows, blocks, intent mapping | Definitief |
swift-ux-v2.1.md |
UI specs, design tokens, layouts | Definitief |
| Dit document (TO) | Technische implementatie | Definitief |
2. Technische Architectuur Overzicht
2.1 High-Level Architectuur
┌─────────────────────────────────────────────────────────────────────────┐
│ SUAVE FRONTEND │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ COMMAND CENTER │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Text Input │ │ Voice Input │ │ Context Bar │ │ │
│ │ │ (cmdk) │ │ (Deepgram) │ │ (user/patient/shift) │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────────────────────┘ │ │
│ │ │ │ │ │
│ │ └────────┬────────┘ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ INTENT CLASSIFIER │ │ │
│ │ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │ │
│ │ │ │ Tier 1: Local │───▶│ Tier 2: AI (Claude) │ │ │ │
│ │ │ │ Regex + Keywords│ │ Fallback bij lage confidence │ │ │ │
│ │ │ │ (<50ms) │ │ (<500ms) │ │ │ │
│ │ │ └─────────────────┘ └─────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ BLOCK CONTAINER (Ephemeral) │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │Dagno- │ │Zoeken │ │Rappor- │ │Over- │ │Fallback│ │ │ │
│ │ │ │titie │ │Block │ │tage │ │dracht │ │Picker │ │ │ │
│ │ │ │Block │ │ │ │Block │ │Block │ │ │ │ │ │
│ │ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ZUSTAND STORE │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Context │ │ Active │ │ Recent │ │ Block State │ │ │
│ │ │ State │ │ Patient │ │ Actions │ │ (pre-fill data) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS API ROUTES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /api/intent │ │ /api/patients│ │ /api/reports │ │
│ │ /classify │ │ /search │ │ (bestaand) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /api/over- │ │ /api/deepgram│ │ /api/docs/ │ │
│ │ dracht │ │ (bestaand) │ │ chat │ │
│ │ (bestaand) │ │ │ │ (bestaand) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ SUPABASE │
├─────────────────────────────────────────────────────────────────────────┤
│ PostgreSQL + RLS + Auth + Realtime │
│ │
│ Tabellen: patients, reports, observations, conditions, care_plans, │
│ intakes, practitioners, ai_events │
└─────────────────────────────────────────────────────────────────────────┘
2.2 Component Hiërarchie
app/
├── epd/
│ └── swift/ # Nieuwe Swift module
│ ├── page.tsx # Main entry point
│ └── layout.tsx # Swift-specific layout
│
components/
├── swift/ # Nieuwe folder
│ ├── command-center/
│ │ ├── command-center.tsx # Main container
│ │ ├── command-input.tsx # Text + voice input
│ │ ├── context-bar.tsx # User/patient/shift info
│ │ └── voice-indicator.tsx # Recording state
│ │
│ ├── blocks/
│ │ ├── block-container.tsx # Animation wrapper
│ │ ├── dagnotitie-block.tsx # Quick note entry
│ │ ├── zoeken-block.tsx # Patient search
│ │ ├── rapportage-block.tsx # Full report
│ │ ├── overdracht-block.tsx # AI summary
│ │ └── fallback-picker.tsx # Manual block selection
│ │
│ ├── shared/
│ │ ├── patient-card.tsx # Compact patient info
│ │ ├── recent-actions.tsx # Recent activity list
│ │ └── keyboard-hints.tsx # Shortcut overlay
│ │
│ └── index.ts # Barrel exports
│
stores/ # Nieuwe folder
│ └── swift-store.ts # Zustand store
│
lib/
├── swift/ # Nieuwe folder
│ ├── intent-classifier.ts # Local classification
│ ├── entity-extractor.ts # Extract patient, category
│ ├── block-registry.ts # Block definitions
│ └── types.ts # Swift-specific types
│
hooks/
│ ├── use-swift-store.ts # Store hook
│ ├── use-intent.ts # Intent classification hook
│ └── use-keyboard-shortcuts.ts # Cmd+K etc.
3. Techstack Selectie
3.1 Bestaande Stack (hergebruiken)
| Component | Technologie | Versie | Status |
|---|---|---|---|
| Framework | Next.js | 14.2.18 | ✅ Bestaand |
| UI Library | React | 18.3.1 | ✅ Bestaand |
| Styling | TailwindCSS | 3.4.18 | ✅ Bestaand |
| Components | shadcn/ui | - | ✅ Bestaand |
| Command Palette | cmdk | 1.1.1 | ✅ Bestaand |
| Animations | framer-motion | 12.23.24 | ✅ Bestaand |
| Database | Supabase | 2.81.1 | ✅ Bestaand |
| Speech-to-Text | Deepgram | 4.11.2 | ✅ Bestaand |
| AI | Claude API | - | ✅ Bestaand |
| Validation | Zod | 4.1.12 | ✅ Bestaand |
| Forms | react-hook-form | 7.66.1 | ✅ Bestaand |
| Icons | lucide-react | 0.553.0 | ✅ Bestaand |
| Dates | date-fns | 4.1.0 | ✅ Bestaand |
3.2 Nieuwe Dependencies (toe te voegen)
| Component | Technologie | Versie | Reden | Alternatieven |
|---|---|---|---|---|
| State Management | Zustand | ^4.5.0 | Lightweight, geen providers, TypeScript-first | Jotai, Redux Toolkit |
Installatie:
pnpm add zustand
Waarom Zustand?
- 3KB gzipped (vs Redux 7KB)
- Geen Provider wrapping nodig
- TypeScript-first met goede inference
- Middleware support (devtools, persist)
- Past bij de "minimal overhead" filosofie van Swift
4. Datamodel
4.1 Bestaande Tabellen (hergebruiken)
-- Patients (bestaand)
patients (
id UUID PRIMARY KEY,
name_given TEXT,
name_family TEXT,
name_prefix TEXT,
birth_date DATE,
gender TEXT,
created_at TIMESTAMP
)
-- Reports (bestaand) - gebruikt voor dagnotities
reports (
id UUID PRIMARY KEY,
patient_id UUID REFERENCES patients(id),
type TEXT, -- 'verpleegkundig' voor dagnotities
content TEXT,
structured_data JSONB, -- { category: 'medicatie'|'adl'|... }
include_in_handover BOOLEAN,
shift_date DATE,
created_at TIMESTAMP,
created_by UUID,
deleted_at TIMESTAMP -- Soft delete
)
-- Observations (bestaand) - vitals
observations (
id UUID PRIMARY KEY,
patient_id UUID REFERENCES patients(id),
code_display TEXT,
value_quantity_value NUMERIC,
value_quantity_unit TEXT,
effective_datetime TIMESTAMP
)
-- Conditions (bestaand) - diagnoses
conditions (
id UUID PRIMARY KEY,
patient_id UUID REFERENCES patients(id),
code_display TEXT,
clinical_status TEXT,
onset_datetime TIMESTAMP
)
4.2 Nieuwe Tabellen
-- Intent Events (nieuw) - voor analytics en training
CREATE TABLE intent_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT NOW(),
user_id UUID REFERENCES auth.users(id),
-- Input
raw_input TEXT NOT NULL,
input_source TEXT NOT NULL, -- 'text' | 'voice'
-- Classification result
intent TEXT NOT NULL, -- 'dagnotitie' | 'zoeken' | ...
confidence NUMERIC(3,2) NOT NULL, -- 0.00 - 1.00
tier TEXT NOT NULL, -- 'local' | 'ai'
-- Extracted entities
entities JSONB, -- { patient_name, category, ... }
-- Outcome
block_opened TEXT, -- Which block was actually opened
action_completed BOOLEAN, -- Did user complete the action?
-- Performance
classification_ms INTEGER, -- Time to classify
-- Context
context JSONB -- { activePatient, shift, ... }
);
-- Index voor analytics queries
CREATE INDEX idx_intent_events_created ON intent_events(created_at);
CREATE INDEX idx_intent_events_intent ON intent_events(intent);
CREATE INDEX idx_intent_events_user ON intent_events(user_id);
-- RLS Policy
ALTER TABLE intent_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can insert own events" ON intent_events
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can read own events" ON intent_events
FOR SELECT USING (auth.uid() = user_id);
4.3 TypeScript Types
// lib/swift/types.ts
// Intent types
export const SUAVE_INTENTS = [
'dagnotitie',
'zoeken',
'rapportage',
'overdracht',
'patient_info',
'agenda',
'onbekend',
] as const;
export type SwiftIntent = (typeof SUAVE_INTENTS)[number];
// Classification result
export interface IntentClassification {
intent: SwiftIntent;
confidence: number; // 0.0 - 1.0
tier: 'local' | 'ai';
entities: ExtractedEntities;
clarificationNeeded?: boolean;
clarificationPrompt?: string;
}
// Extracted entities from input
export interface ExtractedEntities {
patientName?: string;
patientId?: string; // If resolved
category?: VerpleegkundigCategory;
content?: string;
timeRange?: string;
reportType?: ReportType;
}
// Block state
export interface BlockState {
type: SwiftIntent | null;
isOpen: boolean;
prefillData: Partial<ExtractedEntities>;
animationState: 'entering' | 'visible' | 'exiting' | 'hidden';
}
// Context state
export interface SwiftContext {
user: {
id: string;
name: string;
role: string;
} | null;
activePatient: {
id: string;
name: string;
roomNumber?: string;
} | null;
shift: 'nacht' | 'ochtend' | 'middag' | 'avond';
recentPatients: Array<{
id: string;
name: string;
lastAction: string;
timestamp: Date;
}>;
recentActions: Array<{
type: string;
patientName: string;
timestamp: Date;
success: boolean;
}>;
}
// Store state
export interface SwiftStore {
// Context
context: SwiftContext;
setActivePatient: (patient: SwiftContext['activePatient']) => void;
addRecentAction: (action: SwiftContext['recentActions'][0]) => void;
// Block
block: BlockState;
openBlock: (type: SwiftIntent, prefillData?: Partial<ExtractedEntities>) => void;
closeBlock: () => void;
// Input
inputValue: string;
setInputValue: (value: string) => void;
isVoiceActive: boolean;
setVoiceActive: (active: boolean) => void;
// Classification
isClassifying: boolean;
lastClassification: IntentClassification | null;
}
5. API Ontwerp
5.1 Nieuwe Endpoints
POST /api/intent/classify
Classifieert gebruikersinput naar intent + entities.
// Request
interface ClassifyRequest {
input: string;
source: 'text' | 'voice';
context?: {
activePatientId?: string;
shift?: string;
};
}
// Response
interface ClassifyResponse {
intent: SwiftIntent;
confidence: number;
tier: 'local' | 'ai';
entities: ExtractedEntities;
clarificationNeeded?: boolean;
clarificationPrompt?: string;
classificationMs: number;
}
// Example
POST /api/intent/classify
{
"input": "notitie jan medicatie gegeven",
"source": "voice",
"context": { "shift": "ochtend" }
}
// Response
{
"intent": "dagnotitie",
"confidence": 0.95,
"tier": "local",
"entities": {
"patientName": "jan",
"category": "medicatie",
"content": "medicatie gegeven"
},
"classificationMs": 12
}
GET /api/patients/search
Fuzzy search voor patiënten.
// Request
GET /api/patients/search?q=jan&limit=5
// Response
interface SearchResponse {
patients: Array<{
id: string;
name: string;
birthDate: string;
roomNumber?: string;
primaryDiagnosis?: string;
matchScore: number;
}>;
totalCount: number;
}
// Example Response
{
"patients": [
{
"id": "uuid-1",
"name": "Jan de Vries",
"birthDate": "1965-03-15",
"roomNumber": "K204",
"primaryDiagnosis": "F41.1 Gegeneraliseerde angststoornis",
"matchScore": 0.95
},
{
"id": "uuid-2",
"name": "Janneke van den Berg",
"birthDate": "1978-08-22",
"roomNumber": "K108",
"matchScore": 0.72
}
],
"totalCount": 2
}
5.2 Bestaande Endpoints (hergebruiken)
| Endpoint | Method | Gebruik in Swift |
|---|---|---|
/api/reports |
POST | DagnotatieBlock, RapportageBlock |
/api/reports/[id] |
PATCH/DELETE | Edit/delete notities |
/api/overdracht/generate |
POST | OverdrachtBlock AI summary |
/api/overdracht/patients |
GET | Patiënten met updates |
/api/overdracht/[patientId] |
GET | Patient detail data |
/api/deepgram/token |
GET | Streaming voice token |
/api/deepgram/transcribe |
POST | Batch transcription |
5.3 API Route Implementatie
// app/api/intent/classify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/auth/server';
import { z } from 'zod';
import { classifyIntentLocal } from '@/lib/swift/intent-classifier';
import { classifyIntentAI } from '@/lib/swift/intent-classifier-ai';
const ClassifySchema = z.object({
input: z.string().min(1).max(500),
source: z.enum(['text', 'voice']),
context: z.object({
activePatientId: z.string().uuid().optional(),
shift: z.string().optional(),
}).optional(),
});
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body = await request.json();
const result = ClassifySchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Validatiefout', details: result.error.issues },
{ status: 400 }
);
}
const { input, source, context } = result.data;
const supabase = await createClient();
// Check auth
const { data: authData } = await supabase.auth.getUser();
if (!authData?.user) {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
}
// Tier 1: Local classification
const localResult = classifyIntentLocal(input);
let classification = localResult;
let tier: 'local' | 'ai' = 'local';
// Tier 2: AI fallback if confidence < 0.8
if (localResult.confidence < 0.8) {
const aiResult = await classifyIntentAI(input, context);
if (aiResult.confidence > localResult.confidence) {
classification = aiResult;
tier = 'ai';
}
}
const classificationMs = Date.now() - startTime;
// Log event for analytics
await supabase.from('intent_events').insert({
user_id: authData.user.id,
raw_input: input,
input_source: source,
intent: classification.intent,
confidence: classification.confidence,
tier,
entities: classification.entities,
classification_ms: classificationMs,
context,
});
return NextResponse.json({
...classification,
tier,
classificationMs,
});
} catch (error) {
console.error('Intent classification error:', error);
return NextResponse.json(
{ error: 'Classificatie mislukt' },
{ status: 500 }
);
}
}
6. Security & Compliance
6.1 Security Maatregelen
| Maatregel | Implementatie | Status |
|---|---|---|
| Authentication | Supabase Auth (session-based) | ✅ Bestaand |
| Authorization | Row Level Security (RLS) op alle tabellen | ✅ Bestaand |
| Input Validation | Zod schemas op alle endpoints | ✅ Bestaand |
| Data Encryption | At rest (Supabase), in transit (HTTPS) | ✅ Bestaand |
| CORS | Restrictive origins | ✅ Bestaand |
| Rate Limiting | Vercel Edge (100 req/min) | ✅ Bestaand |
6.2 RLS Policies voor Nieuwe Tabel
-- intent_events: alleen eigen events
ALTER TABLE intent_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users insert own events" ON intent_events
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users read own events" ON intent_events
FOR SELECT USING (auth.uid() = user_id);
-- Admins kunnen alle events lezen (voor analytics)
CREATE POLICY "Admins read all events" ON intent_events
FOR SELECT USING (
EXISTS (
SELECT 1 FROM practitioners
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
6.3 Privacy & AVG
| Aspect | Implementatie |
|---|---|
| Data Minimalisatie | Alleen noodzakelijke velden in intent_events |
| Voice Data | Wordt NIET opgeslagen - alleen transcript |
| Patient Data | Bestaande RLS policies van toepassing |
| Logging | Audit trail via ai_events en intent_events |
| Consent | Gebruiker activeert voice expliciet (push-to-talk) |
7. AI/LLM Integratie
7.1 Intent Classification (Tier 2)
// lib/swift/intent-classifier-ai.ts
const INTENT_CLASSIFICATION_PROMPT = `
Je bent een intent classifier voor een Nederlands EPD systeem.
Analyseer de gebruikersinput en bepaal:
1. De intent (dagnotitie, zoeken, rapportage, overdracht, patient_info, agenda, onbekend)
2. Geëxtraheerde entities (patiëntnaam, categorie, content, etc.)
3. Confidence score (0.0 - 1.0)
Categorieën voor dagnotities: medicatie, adl, gedrag, incident, observatie
Antwoord ALLEEN in JSON format:
{
"intent": "string",
"confidence": number,
"entities": {
"patientName": "string of null",
"category": "string of null",
"content": "string of null"
},
"clarificationNeeded": boolean,
"clarificationPrompt": "string of null"
}
`;
export async function classifyIntentAI(
input: string,
context?: { activePatientId?: string; shift?: string }
): Promise<IntentClassification> {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022', // Snelste model voor classificatie
max_tokens: 256,
temperature: 0.1,
system: INTENT_CLASSIFICATION_PROMPT,
messages: [{
role: 'user',
content: `Input: "${input}"\nContext: ${JSON.stringify(context || {})}`
}],
}),
});
// Parse response...
}
7.2 Model Selectie
| Taak | Model | Reden |
|---|---|---|
| Intent Classification | claude-3-5-haiku | Snel (<200ms), goedkoop, voldoende accuraat |
| Overdracht Summary | claude-sonnet-4 | Complexer, betere Nederlandse output |
| Report Structuring | claude-3-5-haiku | Snel, kosten-effectief |
7.3 Cost Management
// Token limits en caching
const AI_CONFIG = {
intentClassification: {
model: 'claude-3-5-haiku-20241022',
maxTokens: 256,
cacheKey: (input: string) => `intent:${hash(input.toLowerCase())}`,
cacheTTL: 300, // 5 min - zelfde input = zelfde intent
},
overdrachtSummary: {
model: 'claude-sonnet-4-20250514',
maxTokens: 2048,
cacheKey: (patientId: string, period: string) =>
`overdracht:${patientId}:${period}:${today()}`,
cacheTTL: 3600, // 1 uur - data verandert niet snel
},
};
8. Performance & Scalability
8.1 Performance Targets
| Metric | Target | Meetmethode |
|---|---|---|
| Command Center render | <100ms | React DevTools |
| Local intent classification | <50ms | API response time |
| AI intent classification | <500ms | API response time |
| Block animation | 60fps | Chrome DevTools |
| Voice-to-text | <1s | Deepgram streaming |
| Patient search | <200ms | API response time |
8.2 Optimalisatie Strategieën
// 1. Debounced input voor classificatie
const debouncedClassify = useMemo(
() => debounce(classifyIntent, 150),
[]
);
// 2. Optimistic UI voor notities
const saveNotitie = async (data: NotitieData) => {
// Direct toevoegen aan UI
addOptimisticAction({
type: 'dagnotitie',
patientName: data.patientName,
status: 'saving',
});
// Async opslaan
try {
await api.reports.create(data);
markActionComplete();
} catch (error) {
rollbackAction();
showError();
}
};
// 3. Prefetching voor patient search
const prefetchPatients = () => {
// Prefetch recent patients bij mount
queryClient.prefetchQuery({
queryKey: ['patients', 'recent'],
queryFn: fetchRecentPatients,
});
};
// 4. Code splitting voor blocks
const DagnotatieBlock = dynamic(
() => import('@/components/swift/blocks/dagnotitie-block'),
{ loading: () => <BlockSkeleton /> }
);
8.3 Bundle Size Impact
| Component | Estimated Size | Mitigation |
|---|---|---|
| Zustand | ~3KB gzipped | Minimal overhead |
| Swift components | ~15KB gzipped | Code splitting |
| Intent classifier | ~2KB gzipped | - |
| Total Swift | ~20KB gzipped | Lazy loaded |
9. Deployment & CI/CD
9.1 Feature Flag Strategie
// lib/feature-flags.ts
export const FEATURES = {
swift: {
enabled: process.env.NEXT_PUBLIC_SUAVE_ENABLED === 'true',
allowedUsers: ['admin', 'developer'], // Role-based rollout
},
};
// Usage in layout
export default function EPDLayout({ children }) {
const { user } = useAuth();
if (FEATURES.swift.enabled &&
FEATURES.swift.allowedUsers.includes(user?.role)) {
return <SwiftLayout>{children}</SwiftLayout>;
}
return <TraditionalLayout>{children}</TraditionalLayout>;
}
9.2 Migration Plan
FASE 1: Parallel (Week 1-2)
├── Swift naast bestaande EPD
├── Toggle via feature flag
└── Alleen voor developers
FASE 2: Beta (Week 3-4)
├── Swift default voor geselecteerde users
├── Fallback naar traditioneel EPD
└── Feedback verzamelen
FASE 3: Rollout (Week 5+)
├── Swift default voor alle users
├── Traditioneel EPD als fallback optie
└── Metrics monitoren
9.3 Database Migration
# Nieuwe migration file
supabase/migrations/20241223_add_intent_events.sql
10. Monitoring & Logging
10.1 Metrics Dashboard
// Swift-specifieke metrics
const SUAVE_METRICS = {
// Intent accuracy
intentAccuracy: {
query: `
SELECT
intent,
tier,
AVG(confidence) as avg_confidence,
COUNT(*) as count,
SUM(CASE WHEN action_completed THEN 1 ELSE 0 END)::float / COUNT(*) as completion_rate
FROM intent_events
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY intent, tier
`,
threshold: { completion_rate: 0.8 },
},
// Voice adoption
voiceAdoption: {
query: `
SELECT
input_source,
COUNT(*) as count
FROM intent_events
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY input_source
`,
target: { voice_percentage: 0.4 },
},
// Fallback usage
fallbackUsage: {
query: `
SELECT
COUNT(*) FILTER (WHERE intent = 'onbekend') as fallback_count,
COUNT(*) as total_count
FROM intent_events
WHERE created_at > NOW() - INTERVAL '24 hours'
`,
threshold: { fallback_rate: 0.15 },
},
// Performance
classificationLatency: {
query: `
SELECT
tier,
percentile_cont(0.5) WITHIN GROUP (ORDER BY classification_ms) as p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY classification_ms) as p95
FROM intent_events
WHERE created_at > NOW() - INTERVAL '1 hour'
GROUP BY tier
`,
threshold: { local_p95: 50, ai_p95: 500 },
},
};
10.2 Error Tracking
// Sentry integration voor Swift
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
new Sentry.BrowserTracing({
tracePropagationTargets: ['/api/intent', '/api/patients'],
}),
],
beforeSend(event) {
// Tag Swift-specifieke errors
if (event.request?.url?.includes('/swift')) {
event.tags = { ...event.tags, module: 'swift' };
}
return event;
},
});
11. Risico's & Technische Mitigatie
| Risico | Impact | Waarschijnlijkheid | Mitigatie |
|---|---|---|---|
| Voice recognition accuracy | Hoog | Middel | Deepgram NL model testen, fallback naar tekst |
| Intent misclassificatie | Hoog | Middel | Two-tier systeem, fallback picker altijd zichtbaar |
| AI API latency | Middel | Laag | Local-first classificatie, Haiku model voor snelheid |
| Claude API outage | Hoog | Laag | Graceful degradation naar local-only, error messages |
| Browser compatibility (voice) | Middel | Laag | Feature detection, fallback messaging |
| User adoption resistance | Middel | Middel | Feature flag, dual-mode, training |
| Performance regression | Middel | Laag | Lazy loading, code splitting, monitoring |
11.1 Fallback Strategieën
// Intent classification fallback chain
const classifyWithFallbacks = async (input: string) => {
try {
// 1. Try local first
const local = classifyIntentLocal(input);
if (local.confidence >= 0.9) return local;
// 2. Try AI if available
if (isAIAvailable()) {
try {
const ai = await classifyIntentAI(input);
return ai.confidence > local.confidence ? ai : local;
} catch (error) {
console.warn('AI classification failed, using local:', error);
return local;
}
}
// 3. Return local result or unknown
return local.confidence >= 0.5
? local
: { intent: 'onbekend', confidence: 0, tier: 'local' };
} catch (error) {
// 4. Ultimate fallback
return { intent: 'onbekend', confidence: 0, tier: 'local' };
}
};
12. Implementatie Volgorde
Sprint 1: Foundation (Week 1)
- Zustand store setup
- Command Center layout
- Basic text input
- Local intent classifier
- Fallback picker
Sprint 2: Core Blocks (Week 2)
- DagnotatieBlock
- ZoekenBlock +
/api/patients/search - Patient pre-fill logic
- Voice input integratie
Sprint 3: Advanced (Week 3)
- AI intent fallback
- OverdrachtBlock (AI summary)
- RapportageBlock
- Keyboard shortcuts
Sprint 4: Polish (Week 4)
- Animaties & microinteracties
- Metrics dashboard
- Error handling refinement
- Demo scenario's
13. Navigatie & Onboarding Flow
13.1 User Journey Overzicht
De toegang tot Swift verloopt via een duidelijke keuze op de login pagina, na storytelling op de landingspagina.
┌─────────────────────────────────────────────────────────────────┐
│ LANDINGSPAGINA │
│ ───────────── │
│ │
│ • Storytelling (probleem → oplossing) │
│ • Interactive demo (probeer zonder login) │
│ • Side-by-side vergelijking (12 klikken vs 1 zin) │
│ • Video demo │
│ • CTA: "Aan de slag" → gaat naar /login │
│ │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ LOGIN PAGINA │
│ ─────────── │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [Bestaand login formulier] │ │
│ │ │ │
│ │ ───────────────────────────────────────────────── │ │
│ │ │ │
│ │ Kies je werkwijze: │ │
│ │ │ │
│ │ ○ ✨ Swift — Spreek of typ, het systeem begrijpt │ │
│ │ ○ 📋 Klassiek — Vertrouwde menu's en formulieren │ │
│ │ │ │
│ │ ☐ Onthoud mijn keuze │ │
│ │ │ │
│ │ [Inloggen] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ /epd/swift │ │ /epd/dashboard│
│ Command │ │ Klassiek EPD │
│ Center │ │ met sidebar │
└───────────────┘ └───────────────┘
13.2 Route Structuur
| Route | Beschrijving | Layout |
|---|---|---|
/ |
Landingspagina met storytelling | Marketing layout |
/login |
Login + interface keuze | Auth layout |
/epd/swift |
Swift Command Center | Swift layout (geen sidebar) |
/epd/dashboard |
Klassiek EPD dashboard | EPD layout (met sidebar) |
/epd/patients/... |
Klassiek patiënt routes | EPD layout (met sidebar) |
13.3 Interface Keuze Logica
// lib/auth/interface-preference.ts
export type InterfacePreference = 'swift' | 'classic';
export interface UserPreferences {
preferred_interface: InterfacePreference;
remember_choice: boolean;
}
// Opslaan in Supabase user_metadata
export async function saveInterfacePreference(
supabase: SupabaseClient,
preference: InterfacePreference,
remember: boolean
) {
const { error } = await supabase.auth.updateUser({
data: {
preferred_interface: preference,
remember_interface_choice: remember,
},
});
if (error) throw error;
}
// Ophalen bij login
export async function getInterfacePreference(
supabase: SupabaseClient
): Promise<InterfacePreference | null> {
const { data: { user } } = await supabase.auth.getUser();
if (!user?.user_metadata?.remember_interface_choice) {
return null; // Toon keuze scherm
}
return user.user_metadata.preferred_interface || null;
}
13.4 Login Form Uitbreiding
// app/login/components/login-form.tsx
interface LoginFormData {
email: string;
password: string;
interface_choice: 'swift' | 'classic';
remember_choice: boolean;
}
// Na succesvolle login
const handleLoginSuccess = async (data: LoginFormData) => {
// Sla preference op
await saveInterfacePreference(
supabase,
data.interface_choice,
data.remember_choice
);
// Redirect naar gekozen interface
if (data.interface_choice === 'swift') {
router.push('/epd/swift');
} else {
router.push('/epd/dashboard');
}
};
13.5 Redirect Middleware
// middleware.ts
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check of user ingelogd is
const supabase = createMiddlewareClient({ req: request });
const { data: { session } } = await supabase.auth.getSession();
if (!session && pathname.startsWith('/epd')) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Als user naar /epd gaat (zonder specifiek pad)
if (session && pathname === '/epd') {
const preference = session.user.user_metadata?.preferred_interface;
if (preference === 'swift') {
return NextResponse.redirect(new URL('/epd/swift', request.url));
} else if (preference === 'classic') {
return NextResponse.redirect(new URL('/epd/dashboard', request.url));
}
// Geen preference opgeslagen → naar dashboard (default)
return NextResponse.redirect(new URL('/epd/dashboard', request.url));
}
return NextResponse.next();
}
13.6 Admin Interface Management
Alleen admins kunnen de interface preference van andere users aanpassen.
// app/api/admin/users/[userId]/interface/route.ts
export async function PATCH(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
const supabase = await createClient();
// Check of huidige user admin is
const { data: currentUser } = await supabase.auth.getUser();
const { data: practitioner } = await supabase
.from('practitioners')
.select('role')
.eq('user_id', currentUser?.user?.id)
.single();
if (practitioner?.role !== 'admin') {
return NextResponse.json({ error: 'Geen toegang' }, { status: 403 });
}
// Update target user's preference
const body = await request.json();
const { interface_preference } = body;
// Admin API call om user metadata te updaten
// (vereist service_role key)
await supabaseAdmin.auth.admin.updateUserById(params.userId, {
user_metadata: { preferred_interface: interface_preference },
});
return NextResponse.json({ success: true });
}
13.7 Geen Toggle in Interface
Belangrijk: Er is geen toggle om te wisselen tussen Swift en Klassiek binnen de interfaces zelf. Dit voorkomt verwarring en zorgt voor een consistente ervaring.
| Waar | Toggle aanwezig? |
|---|---|
| Landingspagina | N.v.t. (storytelling) |
| Login pagina | Ja (eenmalige keuze) |
| Swift interface | Nee |
| Klassiek EPD | Nee |
| Admin panel | Ja (per user instelling) |
Om van interface te wisselen moet de gebruiker:
- Uitloggen
- Opnieuw inloggen
- Andere keuze maken (als "onthoud" uit stond)
Of een admin kan de preference aanpassen.
14. Bijlagen & Referenties
Projectdocumenten
- swift-prd.md - Product Requirements
- swift-fo-ai.md - Functioneel Ontwerp
- swift-ux-v2.1.md - UX/UI Specificaties
- taken-en-vragen-analyse.md - Intent Mapping
Tech Documentatie
- Next.js: https://nextjs.org/docs
- Supabase: https://supabase.com/docs
- Zustand: https://zustand-demo.pmnd.rs/
- cmdk: https://cmdk.paco.me/
- Deepgram: https://developers.deepgram.com/docs
- Claude API: https://docs.anthropic.com/
Bestaande Codebase Referenties
- Command component:
components/ui/command.tsx - Speech streaming:
components/speech-recorder-streaming.tsx - Overdracht API:
app/api/overdracht/generate/route.ts - Report types:
lib/types/report.ts