1258 lines
43 KiB
Markdown
1258 lines
43 KiB
Markdown
# 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`
|