| import { NextRequest } from 'next/server'; |
| import { z } from 'zod'; |
| import { streamWithFallback, type ChatMessage } from '@/lib/providers'; |
| import { triageMessage } from '@/lib/safety/triage'; |
| import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; |
| import { buildRAGContext } from '@/lib/rag/medical-kb'; |
| import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge'; |
| import { authenticateRequest } from '@/lib/auth-middleware'; |
| import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; |
| import { auditLog } from '@/lib/audit'; |
| import { |
| buildPatientContextForUser, |
| stripInjectedPatientContext, |
| } from '@/lib/patient-context.server'; |
|
|
| const RequestSchema = z.object({ |
| messages: z.array( |
| z.object({ |
| role: z.enum(['system', 'user', 'assistant']), |
| content: z.string(), |
| }) |
| ), |
| model: z.string().optional().default('qwen2.5:1.5b'), |
| language: z.string().optional().default('en'), |
| countryCode: z.string().optional().default('US'), |
| }); |
|
|
| export async function POST(request: NextRequest) { |
| const routeStartedAt = Date.now(); |
| const ip = getClientIp(request); |
| const user = authenticateRequest(request); |
|
|
| |
| |
| |
| |
| const limitKey = user ? `chat:user:${user.id}` : `chat:ip:${ip}`; |
| const limitMax = user ? 60 : 20; |
| const limit = checkRateLimit(limitKey, limitMax, 60_000); |
| if (!limit.allowed) { |
| return new Response( |
| JSON.stringify({ |
| error: 'Chat rate limit exceeded. Please slow down.', |
| retryAfterMs: limit.retryAfterMs, |
| }), |
| { |
| status: 429, |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)), |
| }, |
| }, |
| ); |
| } |
|
|
| try { |
| const body = await request.json(); |
| const { messages, model, language, countryCode } = RequestSchema.parse(body); |
|
|
| |
| |
| console.log( |
| `[Chat] route.enter ${JSON.stringify({ |
| userId: user?.id || null, |
| turns: messages.length, |
| model, |
| language, |
| countryCode, |
| userAgent: request.headers.get('user-agent')?.slice(0, 80) || null, |
| })}`, |
| ); |
|
|
| |
| |
| |
| |
| |
| const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); |
| const rawUserContent = lastUserMessage?.content || ''; |
| const cleanUserContent = stripInjectedPatientContext(rawUserContent); |
|
|
| if (lastUserMessage) { |
| const triage = triageMessage(cleanUserContent); |
| console.log( |
| `[Chat] route.triage ${JSON.stringify({ |
| userId: user?.id || null, |
| isEmergency: triage.isEmergency, |
| userChars: cleanUserContent.length, |
| })}`, |
| ); |
|
|
| if (triage.isEmergency) { |
| const emergencyInfo = getEmergencyInfo(countryCode); |
| const emergencyResponse = [ |
| `**EMERGENCY DETECTED**\n\n`, |
| `${triage.guidance}\n\n`, |
| `**Call emergency services NOW:**\n`, |
| `- Emergency: **${emergencyInfo.emergency}** (${emergencyInfo.country})\n`, |
| `- Ambulance: **${emergencyInfo.ambulance}**\n`, |
| emergencyInfo.crisisHotline |
| ? `- Crisis Hotline: **${emergencyInfo.crisisHotline}**\n` |
| : '', |
| `\nDo not delay. Every minute matters.`, |
| ].join(''); |
|
|
| const encoder = new TextEncoder(); |
| const stream = new ReadableStream({ |
| start(controller) { |
| const data = JSON.stringify({ |
| choices: [{ delta: { content: emergencyResponse } }], |
| provider: 'triage', |
| model: 'emergency-detection', |
| isEmergency: true, |
| }); |
| controller.enqueue(encoder.encode(`data: ${data}\n\n`)); |
| controller.enqueue(encoder.encode('data: [DONE]\n\n')); |
| controller.close(); |
| }, |
| }); |
|
|
| if (user) { |
| auditLog({ |
| userId: user.id, |
| action: 'chat', |
| ip, |
| meta: { triage: 'emergency', countryCode, model: 'emergency-detection' }, |
| }); |
| } |
|
|
| return new Response(stream, { |
| headers: { |
| 'Content-Type': 'text/event-stream', |
| 'Cache-Control': 'no-cache', |
| Connection: 'keep-alive', |
| }, |
| }); |
| } |
| } |
|
|
| |
| const ragStart = Date.now(); |
| const ragContext = lastUserMessage ? buildRAGContext(cleanUserContent) : ''; |
| console.log( |
| `[Chat] route.rag ${JSON.stringify({ |
| userId: user?.id || null, |
| chars: ragContext.length, |
| latencyMs: Date.now() - ragStart, |
| })}`, |
| ); |
|
|
| |
| |
| |
| const patientContext = user ? buildPatientContextForUser(user.id) : ''; |
|
|
| |
| |
| |
| const emergencyInfo = getEmergencyInfo(countryCode); |
| const systemPrompt = buildMedicalSystemPrompt({ |
| country: countryCode, |
| language, |
| emergencyNumber: emergencyInfo.emergency, |
| }); |
|
|
| |
| |
| |
| |
| |
| const priorMessages = messages.slice(0, -1).map((m) => |
| m.role === 'user' |
| ? { ...m, content: stripInjectedPatientContext(m.content) } |
| : m, |
| ); |
|
|
| const finalUserContent = [ |
| cleanUserContent, |
| patientContext, |
| ragContext |
| ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}` |
| : '', |
| ].join(''); |
|
|
| const augmentedMessages: ChatMessage[] = [ |
| { role: 'system' as const, content: systemPrompt }, |
| ...priorMessages, |
| { role: 'user' as const, content: finalUserContent }, |
| ]; |
|
|
| |
| console.log( |
| `[Chat] route.provider.dispatch ${JSON.stringify({ |
| userId: user?.id || null, |
| systemPromptChars: systemPrompt.length, |
| patientContextChars: patientContext.length, |
| totalMessages: augmentedMessages.length, |
| preparedInMs: Date.now() - routeStartedAt, |
| })}`, |
| ); |
| const stream = await streamWithFallback(augmentedMessages, model); |
| console.log( |
| `[Chat] route.stream.opened ${JSON.stringify({ |
| userId: user?.id || null, |
| totalMs: Date.now() - routeStartedAt, |
| })}`, |
| ); |
|
|
| if (user) { |
| auditLog({ |
| userId: user.id, |
| action: 'chat', |
| ip, |
| meta: { |
| model, |
| countryCode, |
| turns: messages.length, |
| patientContextChars: patientContext.length, |
| }, |
| }); |
| } |
|
|
| return new Response(stream, { |
| headers: { |
| 'Content-Type': 'text/event-stream', |
| 'Cache-Control': 'no-cache', |
| Connection: 'keep-alive', |
| }, |
| }); |
| } catch (error) { |
| console.error( |
| `[Chat] route.error ${JSON.stringify({ |
| userId: user?.id || null, |
| totalMs: Date.now() - routeStartedAt, |
| name: (error as any)?.name, |
| message: String((error as any)?.message || error).slice(0, 200), |
| })}`, |
| ); |
|
|
| if (error instanceof z.ZodError) { |
| return new Response( |
| JSON.stringify({ error: 'Invalid request', details: error.errors }), |
| { status: 400, headers: { 'Content-Type': 'application/json' } } |
| ); |
| } |
|
|
| return new Response( |
| JSON.stringify({ error: 'Internal server error' }), |
| { status: 500, headers: { 'Content-Type': 'application/json' } } |
| ); |
| } |
| } |
|
|