Files
triqura-ecd/docs/swift/archive/to-swift-v1.md
2025-12-27 13:17:00 +01:00

1258 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
```bash
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)
```sql
-- 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
```sql
-- 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
```typescript
// 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.
```typescript
// 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.
```typescript
// 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
```typescript
// 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
```sql
-- 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```bash
# Nieuwe migration file
supabase/migrations/20241223_add_intent_events.sql
```
---
## 10. Monitoring & Logging
### 10.1 Metrics Dashboard
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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.
```typescript
// 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:
1. Uitloggen
2. Opnieuw inloggen
3. Andere keuze maken (als "onthoud" uit stond)
Of een admin kan de preference aanpassen.
---
## 14. Bijlagen & Referenties
### Projectdocumenten
- [swift-prd.md](./swift-prd.md) - Product Requirements
- [swift-fo-ai.md](./swift-fo-ai.md) - Functioneel Ontwerp
- [swift-ux-v2.1.md](./swift-ux-v2.1.md) - UX/UI Specificaties
- [taken-en-vragen-analyse.md](./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`