Files
triqura-ecd/app/api/behandelplan/generate/route.ts
colinislit ac98070fe4 | E2.S1 | Prompt engineering | System prompt + user prompt templates, evidence-based mapping
| E2.S2 | Generate endpoint | `POST /api/behandelplan/generate` retourneert JSON, < 8 sec response
| E2.S3 | AI event logging | Calls loggen naar `ai_events` tabel

new file:   app/api/behandelplan/generate/route.ts
modified:   docs/specs/behandelplan/bouwplan-behandelplan-v1.md
modified:   docs/templates/bouwplan_template.md
new file:   lib/ai/behandelplan-prompt.ts
new file:   lib/ai/intervention-mapping.ts
modified:   lib/supabase/database.types.ts
2025-12-04 09:27:12 +01:00

328 lines
8.5 KiB
TypeScript

/**
* Behandelplan Generate API
*
* POST /api/behandelplan/generate
* Genereert een behandelplan met Claude AI
*/
import { createClient } from '@/lib/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
BEHANDELPLAN_SYSTEM_PROMPT,
buildUserPrompt,
validatePlanContext,
type PlanContext,
} from '@/lib/ai/behandelplan-prompt';
import { type Severity } from '@/lib/ai/intervention-mapping';
import {
GeneratedPlanSchema,
type GeneratedPlan,
} from '@/lib/types/behandelplan';
import { createDefaultLifeDomainScores, type LifeDomainScore } from '@/lib/types/leefgebieden';
// Input validation schema
const GenerateInputSchema = z.object({
patientId: z.string().uuid('Patient ID moet een geldige UUID zijn'),
intakeId: z.string().uuid('Intake ID moet een geldige UUID zijn'),
conditionId: z.string().uuid().optional(),
extraInstructions: z.string().max(500).optional(),
});
/**
* Map severity_code to Severity type
*/
function mapSeverity(severityCode: string | null | undefined): Severity {
if (!severityCode) return 'middel';
const code = severityCode.toLowerCase();
if (code.includes('mild') || code.includes('laag') || code.includes('light')) {
return 'laag';
}
if (code.includes('severe') || code.includes('hoog') || code.includes('ernstig')) {
return 'hoog';
}
return 'middel';
}
/**
* Load context from database
*/
async function loadPlanContext(
supabase: Awaited<ReturnType<typeof createClient>>,
patientId: string,
intakeId: string,
conditionId?: string
): Promise<PlanContext> {
// 1. Load intake
const { data: intake, error: intakeError } = await supabase
.from('intakes')
.select('*')
.eq('id', intakeId)
.eq('patient_id', patientId)
.single();
if (intakeError || !intake) {
throw new Error(`Intake niet gevonden: ${intakeError?.message || 'niet gevonden'}`);
}
// 2. Load condition (latest for patient, or specific one)
let conditionQuery = supabase
.from('conditions')
.select('*')
.eq('patient_id', patientId);
if (conditionId) {
conditionQuery = conditionQuery.eq('id', conditionId);
} else {
conditionQuery = conditionQuery.order('recorded_date', { ascending: false }).limit(1);
}
const { data: conditions } = await conditionQuery;
const condition = conditions?.[0];
// 3. Build intake notes from available data
const intakeNotes = buildIntakeNotes(intake);
// 4. Get life domains (from intake or default)
const lifeDomains: LifeDomainScore[] =
(intake.life_domains as LifeDomainScore[] | null) ||
createDefaultLifeDomainScores();
// 5. Build context
return {
patientId,
intakeNotes,
dsmCategory: condition?.category || condition?.code_display || 'overig',
severity: mapSeverity(condition?.severity_code),
lifeDomains,
extraInstructions: undefined,
};
}
/**
* Build intake notes from intake data
*/
function buildIntakeNotes(intake: Record<string, unknown>): string {
const parts: string[] = [];
if (intake.notes) {
parts.push(String(intake.notes));
}
if (intake.treatment_advice) {
const advice = intake.treatment_advice as Record<string, unknown>;
if (advice.content) {
parts.push(`Behandeladvies: ${String(advice.content)}`);
}
}
if (intake.kindcheck_data) {
const kindcheck = intake.kindcheck_data as Record<string, unknown>;
if (kindcheck.observations) {
parts.push(`Observaties: ${String(kindcheck.observations)}`);
}
}
return parts.join('\n\n') || 'Geen intake notities beschikbaar.';
}
/**
* Call Claude API
*/
async function callClaudeAPI(context: PlanContext): Promise<GeneratedPlan> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY ontbreekt in environment');
}
const userPrompt = buildUserPrompt(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: 4096,
temperature: 0.3,
system: BEHANDELPLAN_SYSTEM_PROMPT,
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 = GeneratedPlanSchema.parse(parsed);
return validated;
}
/**
* Log AI event to database
*/
async function logAIEvent(
supabase: Awaited<ReturnType<typeof createClient>>,
kind: string,
patientId: string,
input: Record<string, unknown>,
output: Record<string, unknown>,
durationMs: number
) {
try {
await supabase.from('ai_events').insert({
kind,
patient_id: patientId,
input_data: input,
output_data: output,
duration_ms: durationMs,
});
} catch (error) {
// Log but don't fail the request
console.error('Failed to log AI event:', error);
}
}
/**
* POST /api/behandelplan/generate
*/
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body = await request.json();
// Validate input
const result = GenerateInputSchema.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, intakeId, conditionId, extraInstructions } = result.data;
const supabase = await createClient();
// Load context from database
const context = await loadPlanContext(supabase, patientId, intakeId, conditionId);
// Add extra instructions if provided
if (extraInstructions) {
context.extraInstructions = extraInstructions;
}
// Validate context
const validation = validatePlanContext(context);
if (!validation.valid) {
return NextResponse.json(
{
error: 'Onvoldoende context voor behandelplan generatie',
details: validation.errors,
},
{ status: 400 }
);
}
// Call Claude API
const plan = await callClaudeAPI(context);
const durationMs = Date.now() - startTime;
// Log AI event
await logAIEvent(
supabase,
'behandelplan_generate',
patientId,
{ intakeId, conditionId, extraInstructions },
{ goalCount: plan.doelen.length, interventionCount: plan.interventies.length },
durationMs
);
return NextResponse.json({
success: true,
plan,
meta: {
generatedAt: new Date().toISOString(),
durationMs,
context: {
dsmCategory: context.dsmCategory,
severity: context.severity,
highPriorityDomains: context.lifeDomains
.filter((d) => d.priority === 'hoog')
.map((d) => d.domain),
},
},
});
} catch (error) {
console.error('Error generating behandelplan:', error);
const errorMessage = error instanceof Error ? error.message : 'Onbekende fout';
// Check for specific error types
if (errorMessage.includes('Intake 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 behandelplan',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
},
{ status: 500 }
);
}
}