Files
triqura-ecd/app/api/overdracht/generate/route.ts
colinislit adf6c3fb96 feat(swift): implementeer linked evidence UI voor overdracht (E5.S2)
E5.S2 compleet: Bronnotitie links met hover preview in OverdrachtBlock.
Voortgang: 66 SP / 82 SP (80%) - Epic 5 40% compleet!

Implementatie:
- Bronverwijzingen nu klikbaar met hover popover voor volledige content
- Source data enrichment in API response
- Type-specific content display (observaties, rapportages, risico's)
- Visual feedback met icons en kleuren per bron type

Components & Types:
- LinkedEvidence component (160 regels) - Reusable popover voor alle bron types
- Aandachtspunt type extended met optionele sourceData field
- enrichWithSourceData() functie in API route (48 regels)

LinkedEvidence Features:
- Hover popover met volledige bron content
- Type-specific icons: Activity (observatie), FileText (rapportage), AlertTriangle (risico)
- Click/hover trigger met underline-dotted styling
- ExternalLink icon voor visual affordance
- Popover positioning: top-start voor beste UX

Source Data per Type:
- Observaties: value + unit + interpretation (HH/H/N/L/LL) met kleuren
- Rapportages/Verpleegkundig: content + createdBy
- Risico's: riskLevel + rationale met severity kleuren

API Enrichment Logic:
- enrichWithSourceData() lookup in context (vitals, reports, risks)
- Source data toegevoegd aan aandachtspunten vóór response
- Type-safe matching op bron.id en bron.type
- Backwards compatible: werkt met/zonder sourceData

UI/UX Improvements:
- Color-coded interpretations: Red (HH/LL kritiek), Amber (H/L), Teal (N)
- Color-coded risk levels: Red (zeer hoog/hoog), Amber (gemiddeld), Teal (laag)
- Formatted text display met bg-slate-50 border
- Responsive popover met max-width 96 (384px)
- Fallback: geen sourceData = plain text zonder popover

Integration in OverdrachtBlock:
- AandachtspuntItem gebruikt LinkedEvidence component
- Replaced: "bron.label • bron.datum" plain text
- Now: LinkedEvidence met hover preview

Files Changed:
- lib/types/overdracht.ts: +12 lines (sourceData interface)
- app/api/overdracht/generate/route.ts: +58 lines (enrichWithSourceData)
- components/swift/blocks/overdracht-block.tsx: +1/-3 lines (LinkedEvidence import/usage)
- components/swift/shared/linked-evidence.tsx: +160 lines (NEW)

Technical Details:
- Popover from shadcn/ui (@/components/ui/popover)
- Icons from lucide-react (Activity, FileText, AlertTriangle, ExternalLink)
- Type guards voor safe property access (sourceData?.value)
- Underline decoration-dotted cursor-help voor accessibility

Testing:
- Build succesvol (pnpm build)
- /epd/swift: 143 kB (+24 kB door LinkedEvidence component)
- Geen nieuwe type errors
- Popover trigger werkt met keyboard (accessible)

Voortgang: 66 SP / 82 SP (80%) - E5.S2 compleet (3 SP)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:38:45 +01:00

402 lines
11 KiB
TypeScript

/**
* Overdracht Generate API
*
* POST /api/overdracht/generate
* Genereert een overdracht samenvatting met Claude AI
*/
import { createClient } from '@/lib/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
buildSystemPrompt,
buildOverdrachtUserPrompt,
calculateAge,
formatPatientName,
type OverdrachtContext,
} from '@/lib/ai/overdracht-prompt';
import {
GenerateOverdrachtSchema,
type AISamenvatting,
type Aandachtspunt,
} from '@/lib/types/overdracht';
import { VERPLEEG_REPORT_TYPES } from '@/lib/types/report';
// Zod schema for AI response validation
const AandachtspuntSchema = z.object({
tekst: z.string(),
urgent: z.boolean(),
bron: z.object({
type: z.enum(['observatie', 'rapportage', 'verpleegkundig', 'risico']),
id: z.string(),
datum: z.string(),
label: z.string(),
}),
});
const AIResponseSchema = z.object({
samenvatting: z.string(),
aandachtspunten: z.array(AandachtspuntSchema).max(5),
actiepunten: z.array(z.string()).max(3),
});
type PeriodValue = '1d' | '3d' | '7d' | '14d';
/**
* Calculate start date based on period
*/
function getPeriodStartDate(period: PeriodValue): string {
const days = { '1d': 1, '3d': 3, '7d': 7, '14d': 14 }[period] || 1;
const startDate = new Date();
startDate.setDate(startDate.getDate() - (days - 1));
return startDate.toISOString().split('T')[0] + 'T00:00:00.000Z';
}
/**
* Load context from database
*/
async function loadOverdrachtContext(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string,
period: PeriodValue
): Promise<OverdrachtContext> {
const periodStart = getPeriodStartDate(period);
// Parallel queries - reports now includes verpleegkundig type
const [
patientResult,
vitalsResult,
reportsResult,
risksResult,
conditionsResult,
] = await Promise.all([
supabase
.from('patients')
.select('id, name_given, name_family, name_prefix, birth_date, gender')
.eq('id', patientId)
.single(),
supabase
.from('observations')
.select('id, code_display, value_quantity_value, value_quantity_unit, interpretation_code, effective_datetime')
.eq('patient_id', patientId)
.eq('category', 'vital-signs')
.gte('effective_datetime', periodStart)
.order('effective_datetime', { ascending: false }),
// Reports now includes verpleegkundig type (was nursing_logs)
supabase
.from('reports')
.select('id, type, content, created_at, created_by, structured_data, include_in_handover, shift_date')
.eq('patient_id', patientId)
.in('type', [...VERPLEEG_REPORT_TYPES])
.gte('created_at', periodStart)
.is('deleted_at', null)
.order('created_at', { ascending: false }),
supabase
.from('risk_assessments')
.select('id, risk_type, risk_level, rationale, created_at, intakes!inner(patient_id)')
.eq('intakes.patient_id', patientId),
supabase
.from('conditions')
.select('id, code_display, clinical_status, onset_datetime')
.eq('patient_id', patientId)
.eq('clinical_status', 'active'),
]);
if (patientResult.error || !patientResult.data) {
throw new Error('Patiënt niet gevonden');
}
const patient = patientResult.data;
return {
patientId,
patientName: formatPatientName(
patient.name_given,
patient.name_family,
patient.name_prefix || undefined
),
age: calculateAge(patient.birth_date),
gender: patient.gender,
conditions: (conditionsResult.data || []).map((c) => ({
id: c.id,
code_display: c.code_display,
clinical_status: c.clinical_status,
onset_datetime: c.onset_datetime || undefined,
})),
vitals: (vitalsResult.data || []).map((v) => ({
id: v.id,
code_display: v.code_display,
value_quantity_value: v.value_quantity_value,
value_quantity_unit: v.value_quantity_unit,
interpretation_code: v.interpretation_code,
effective_datetime: v.effective_datetime,
})),
reports: (reportsResult.data || []).map((r) => ({
id: r.id,
type: r.type,
content: r.content,
created_at: r.created_at,
created_by: r.created_by,
structured_data: r.structured_data,
include_in_handover: r.include_in_handover,
shift_date: r.shift_date,
})),
risks: (risksResult.data || []).map((r) => ({
id: r.id,
risk_type: r.risk_type,
risk_level: r.risk_level,
rationale: r.rationale,
created_at: r.created_at,
})),
};
}
/**
* Call Claude API
*/
async function callClaudeAPI(
context: OverdrachtContext,
role: 'psychiater' | 'verpleegkundige' = 'verpleegkundige'
): Promise<{
samenvatting: string;
aandachtspunten: Aandachtspunt[];
actiepunten: string[];
}> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY ontbreekt in environment');
}
const systemPrompt = buildSystemPrompt(role);
const userPrompt = buildOverdrachtUserPrompt(context);
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
temperature: 0.3,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!response.ok) {
const errorBody = await response.text();
console.error('Claude API error:', errorBody);
throw new Error(`Claude API fout: ${response.status}`);
}
const data = await response.json();
const rawText = data?.content?.[0]?.text;
if (!rawText) {
throw new Error('Geen response van Claude API');
}
// Parse JSON from response (handle potential markdown code blocks)
let jsonText = rawText.trim();
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7);
}
if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3);
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3);
}
const parsed = JSON.parse(jsonText.trim());
// Validate with Zod schema
const validated = AIResponseSchema.parse(parsed);
return validated;
}
/**
* Enrich aandachtspunten with full source data
*/
function enrichWithSourceData(
aandachtspunten: Aandachtspunt[],
context: OverdrachtContext
): Aandachtspunt[] {
return aandachtspunten.map((punt) => {
const { bron } = punt;
let sourceData: Aandachtspunt['sourceData'];
switch (bron.type) {
case 'observatie': {
const vital = context.vitals.find((v) => v.id === bron.id);
if (vital) {
sourceData = {
value: vital.value_quantity_value?.toString() || undefined,
unit: vital.value_quantity_unit || undefined,
interpretation: vital.interpretation_code || undefined,
};
}
break;
}
case 'rapportage':
case 'verpleegkundig': {
const report = context.reports.find((r) => r.id === bron.id);
if (report) {
sourceData = {
content: report.content,
createdBy: report.created_by || undefined,
};
}
break;
}
case 'risico': {
const risk = context.risks.find((r) => r.id === bron.id);
if (risk) {
sourceData = {
riskLevel: risk.risk_level,
rationale: risk.rationale || undefined,
};
}
break;
}
}
return {
...punt,
sourceData,
};
});
}
/**
* Log AI event to database
*/
async function logAIEvent(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string,
context: OverdrachtContext,
result: { samenvatting: string; aandachtspunten: Aandachtspunt[]; actiepunten: string[] },
durationMs: number
) {
try {
// Count verpleegkundige reports separately
const verpleegkundigCount = context.reports.filter(r => r.type === 'verpleegkundig').length;
const otherReportsCount = context.reports.filter(r => r.type !== 'verpleegkundig').length;
await supabase.from('ai_events').insert({
kind: 'overdracht_generate',
patient_id: patientId,
input_data: {
vitalCount: context.vitals.length,
reportCount: otherReportsCount,
verpleegkundigCount,
riskCount: context.risks.length,
conditionCount: context.conditions.length,
},
output_data: {
aandachtspuntenCount: result.aandachtspunten.length,
actiepuntenCount: result.actiepunten.length,
urgentCount: result.aandachtspunten.filter((a) => a.urgent).length,
},
duration_ms: durationMs,
});
} catch (error) {
// Log but don't fail the request
console.error('Failed to log AI event:', error);
}
}
/**
* POST /api/overdracht/generate
*/
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body = await request.json();
// Validate input
const result = GenerateOverdrachtSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validatiefout',
details: result.error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
);
}
const { patientId, period, filterForRole } = 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 });
}
// Load context with period filter
const context = await loadOverdrachtContext(supabase, patientId, period);
// Call Claude API with role-specific filtering
const aiResult = await callClaudeAPI(context, filterForRole);
const durationMs = Date.now() - startTime;
// Enrich aandachtspunten with full source data for linked evidence
const enrichedAandachtspunten = enrichWithSourceData(aiResult.aandachtspunten, context);
// Log AI event
await logAIEvent(supabase, patientId, context, aiResult, durationMs);
// Build response
const response: AISamenvatting = {
samenvatting: aiResult.samenvatting,
aandachtspunten: enrichedAandachtspunten,
actiepunten: aiResult.actiepunten,
generatedAt: new Date().toISOString(),
durationMs,
};
return NextResponse.json(response);
} catch (error) {
console.error('Error generating overdracht:', error);
const errorMessage = error instanceof Error ? error.message : 'Onbekende fout';
if (errorMessage.includes('Patiënt niet gevonden')) {
return NextResponse.json({ error: errorMessage }, { status: 404 });
}
if (errorMessage.includes('ANTHROPIC_API_KEY')) {
return NextResponse.json(
{ error: 'AI service niet geconfigureerd' },
{ status: 503 }
);
}
if (errorMessage.includes('Claude API')) {
return NextResponse.json(
{ error: 'AI service tijdelijk niet beschikbaar', details: errorMessage },
{ status: 503 }
);
}
return NextResponse.json(
{
error: 'Fout bij genereren overdracht',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
},
{ status: 500 }
);
}
}