E3.S1 - Chat API endpoint skeleton (3 SP)
- /api/swift/chat route met SSE setup
- Zod validation (message, messages, context)
- Authentication check (session required)
- Rate limiting (20 requests per minute)
- Request body schema voor chat messages en context
E3.S2 - Streaming response logic (5 SP)
- Claude API integration (Anthropic Sonnet 4)
- Real-time streaming via Server-Sent Events
- Event parsing (content_block_delta, message_stop, error)
- Simple system prompt met context injection
- Error handling voor API failures
API Route Features:
- Model: claude-sonnet-4-20250514
- Max tokens: 2048, Temperature: 0.7
- Conversation history: max 20 messages
- Rate limiting per user (20 req/min)
- Request cancellation support
- Context: activePatient + shift
SSE Event Format:
- content_block_delta → {"type":"content","text":"..."}
- message_stop → {"type":"done"}
- error → {"type":"error","error":"..."}
Client Helper (lib/swift/chat-api.ts):
- sendChatMessage() function voor frontend
- SSE stream parsing met TextDecoder
- Callbacks: onChunk, onDone, onError
- Error handling en retry logic
ChatPanel Integration:
- Real-time streaming met updateLastMessage()
- isStreaming state voor UI feedback
- Context injection (patient, shift)
- Error messages in chat
System Prompt (Simple):
- Nederlandse medische assistent voor Swift GGZ EPD
- Context-aware (patiënt + dienst)
- Vriendelijk en professioneel
- Medical scribe functionaliteit komt in E3.S3
Files Created:
- app/api/swift/chat/route.ts (279 regels)
- lib/swift/chat-api.ts (113 regels)
Files Updated:
- components/swift/chat/chat-panel.tsx (+52 regels) - Streaming integration
Epic 3 Progress: 2/6 stories compleet (8 SP / 21 SP = 38%)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
308 lines
9.9 KiB
TypeScript
308 lines
9.9 KiB
TypeScript
/**
|
|
* Swift Chat API Route (v3.0)
|
|
*
|
|
* Streaming chat endpoint voor Swift Medical Scribe met Server-Sent Events (SSE).
|
|
*
|
|
* Epic: E3 (Chat API & Medical Scribe)
|
|
* Story: E3.S1 (Chat API endpoint skeleton)
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
import { getSession } from '@/lib/auth/server';
|
|
import type { ChatMessage as ChatMessageType, ChatAction } from '@/stores/swift-store';
|
|
|
|
// Configuration
|
|
const SWIFT_MODEL = process.env.SWIFT_MODEL ?? 'claude-sonnet-4-20250514';
|
|
const MAX_HISTORY_MESSAGES = 20;
|
|
const MAX_USER_MESSAGE_LENGTH = 2000;
|
|
|
|
// Rate limiting configuration
|
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
const RATE_LIMIT_MAX_REQUESTS = 20; // max requests per window
|
|
|
|
// In-memory rate limit store (resets on server restart)
|
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } {
|
|
const now = Date.now();
|
|
const record = rateLimitStore.get(userId);
|
|
|
|
// Clean up expired entries periodically
|
|
if (rateLimitStore.size > 1000) {
|
|
for (const [key, value] of rateLimitStore.entries()) {
|
|
if (value.resetTime < now) {
|
|
rateLimitStore.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!record || record.resetTime < now) {
|
|
// New window
|
|
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });
|
|
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1, resetIn: RATE_LIMIT_WINDOW_MS };
|
|
}
|
|
|
|
if (record.count >= RATE_LIMIT_MAX_REQUESTS) {
|
|
return { allowed: false, remaining: 0, resetIn: record.resetTime - now };
|
|
}
|
|
|
|
record.count++;
|
|
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - record.count, resetIn: record.resetTime - now };
|
|
}
|
|
|
|
// Request validation schemas
|
|
const ChatMessageSchema = z.object({
|
|
id: z.string(),
|
|
type: z.enum(['user', 'assistant', 'system', 'error']),
|
|
content: z.string().min(1),
|
|
timestamp: z.string().or(z.date()),
|
|
action: z.any().optional(), // ChatAction is optional
|
|
});
|
|
|
|
const RequestSchema = z.object({
|
|
message: z.string().min(1).max(MAX_USER_MESSAGE_LENGTH),
|
|
messages: z.array(ChatMessageSchema).optional(),
|
|
context: z.object({
|
|
activePatient: z.object({
|
|
id: z.string(),
|
|
first_name: z.string(),
|
|
last_name: z.string(),
|
|
}).optional().nullable(),
|
|
shift: z.enum(['nacht', 'ochtend', 'middag', 'avond']),
|
|
}).optional(),
|
|
});
|
|
|
|
type RequestData = z.infer<typeof RequestSchema>;
|
|
|
|
/**
|
|
* Build simple system prompt for E3.S2
|
|
* Medical scribe prompt with intent detection will be added in E3.S3
|
|
*/
|
|
function buildSimpleSystemPrompt(context?: RequestData['context']): string {
|
|
const patientContext = context?.activePatient
|
|
? `De actieve patiënt is ${context.activePatient.first_name} ${context.activePatient.last_name}.`
|
|
: 'Er is momenteel geen patiënt geselecteerd.';
|
|
|
|
const shiftContext = context?.shift ? `De huidige dienst is: ${context.shift}.` : '';
|
|
|
|
return `Je bent een behulpzame medische assistent voor Swift, een Nederlands GGZ EPD systeem.
|
|
|
|
Je taak is om zorgmedewerkers te helpen met documentatie en administratieve taken.
|
|
|
|
Context:
|
|
${patientContext}
|
|
${shiftContext}
|
|
|
|
Communicatie:
|
|
- Gebruik Nederlands
|
|
- Wees vriendelijk en professioneel
|
|
- Geef duidelijke en beknopte antwoorden
|
|
- Vraag om verduidelijking bij onduidelijke vragen
|
|
|
|
BELANGRIJK: Dit is een eenvoudige versie. Intent detection en medical scribe functionaliteit komen in de volgende stap.`;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// 1. Authentication check
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json({ error: 'Niet geauthenticeerd' }, { status: 401 });
|
|
}
|
|
|
|
// 2. Rate limit check
|
|
const rateLimit = checkRateLimit(session.user.id);
|
|
if (!rateLimit.allowed) {
|
|
const resetInSeconds = Math.ceil(rateLimit.resetIn / 1000);
|
|
return NextResponse.json(
|
|
{
|
|
error: `Te veel verzoeken. Probeer het over ${resetInSeconds} seconden opnieuw.`,
|
|
resetIn: resetInSeconds,
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'X-RateLimit-Remaining': '0',
|
|
'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + resetInSeconds),
|
|
'Retry-After': String(resetInSeconds),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
// 3. Request body validation
|
|
const json = await request.json();
|
|
const parsed = RequestSchema.safeParse(json);
|
|
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Validatiefout',
|
|
details: parsed.error.issues.map((issue) => ({
|
|
field: issue.path.join('.') || 'message',
|
|
message: issue.message,
|
|
})),
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const { message, messages = [], context } = parsed.data;
|
|
|
|
const trimmedMessage = message.trim();
|
|
if (!trimmedMessage) {
|
|
return NextResponse.json({ error: 'Bericht mag niet leeg zijn' }, { status: 400 });
|
|
}
|
|
|
|
// 4. Prepare conversation history (limit to last N messages)
|
|
const history = messages.slice(-MAX_HISTORY_MESSAGES);
|
|
|
|
// 5. Build system prompt (simple version for E3.S2, medical scribe prompt comes in E3.S3)
|
|
const systemPrompt = buildSimpleSystemPrompt(context);
|
|
|
|
// 6. Check for Claude API key
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) {
|
|
return NextResponse.json({ error: 'ANTHROPIC_API_KEY ontbreekt' }, { status: 500 });
|
|
}
|
|
|
|
// 7. Call Claude API with streaming
|
|
const anthropicResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
Accept: 'text/event-stream',
|
|
},
|
|
body: JSON.stringify({
|
|
model: SWIFT_MODEL,
|
|
max_tokens: 2048,
|
|
temperature: 0.7,
|
|
stream: true,
|
|
system: systemPrompt,
|
|
messages: [
|
|
...history.map((msg) => ({
|
|
role: msg.type === 'user' ? 'user' : 'assistant',
|
|
content: msg.content,
|
|
})),
|
|
{
|
|
role: 'user',
|
|
content: trimmedMessage,
|
|
},
|
|
],
|
|
}),
|
|
signal: request.signal,
|
|
});
|
|
|
|
if (!anthropicResponse.ok || !anthropicResponse.body) {
|
|
const errorText = await anthropicResponse.text().catch(() => undefined);
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Claude API error',
|
|
details: errorText ?? anthropicResponse.statusText,
|
|
},
|
|
{ status: anthropicResponse.status || 500 }
|
|
);
|
|
}
|
|
|
|
// 8. Parse Claude streaming response and forward as SSE
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
const reader = anthropicResponse.body!.getReader();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
// Decode chunk
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Process complete lines
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim() || line.startsWith(':')) continue;
|
|
|
|
// Parse SSE event from Claude
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6);
|
|
|
|
// Skip [DONE] marker
|
|
if (data === '[DONE]') continue;
|
|
|
|
try {
|
|
const event = JSON.parse(data);
|
|
|
|
// Handle different event types from Claude
|
|
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
// Forward text delta
|
|
const sseEvent = `data: ${JSON.stringify({
|
|
type: 'content',
|
|
text: event.delta.text,
|
|
})}\n\n`;
|
|
controller.enqueue(encoder.encode(sseEvent));
|
|
} else if (event.type === 'message_stop') {
|
|
// End of message
|
|
const doneEvent = `data: ${JSON.stringify({ type: 'done' })}\n\n`;
|
|
controller.enqueue(encoder.encode(doneEvent));
|
|
} else if (event.type === 'error') {
|
|
// Claude API error
|
|
const errorEvent = `data: ${JSON.stringify({
|
|
type: 'error',
|
|
error: event.error?.message || 'Unknown error',
|
|
})}\n\n`;
|
|
controller.enqueue(encoder.encode(errorEvent));
|
|
}
|
|
} catch (parseError) {
|
|
console.error('Failed to parse Claude event:', parseError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
controller.close();
|
|
} catch (error) {
|
|
console.error('Stream error:', error);
|
|
const errorEvent = `data: ${JSON.stringify({
|
|
type: 'error',
|
|
error: 'Er ging iets mis bij het streamen van de response',
|
|
})}\n\n`;
|
|
controller.enqueue(encoder.encode(errorEvent));
|
|
controller.close();
|
|
}
|
|
},
|
|
cancel() {
|
|
console.log('Client disconnected from stream');
|
|
},
|
|
});
|
|
|
|
// 9. Return SSE response
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache, no-store, no-transform',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
return NextResponse.json({ error: 'Ongeldige JSON payload' }, { status: 400 });
|
|
}
|
|
|
|
console.error('Swift chat endpoint error:', error);
|
|
return NextResponse.json({ error: 'Onverwachte serverfout' }, { status: 500 });
|
|
}
|
|
}
|