feat(cortex): no-show casus — intent flow, brief-rescript en document artifact
Cortex handelt een no-show af vanuit één chatcommando: afspraak annuleren (declarabiliteits-nudge), en de openstaande concept- huisartsbrief wordt via LLM herschreven en ter review aangeboden in een document artifact (human-in-the-loop, PATCH dispatch zet status op verzendklaar). - API-routes: context, rescript, cancel, dispatch - NoShowDocumentBlock: review/edit UI met origineel-vergelijk - Mock-data voor concept huisartsbrief - PRD, FO, bouwplan en epics in docs/intent/noshow-case/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
51
app/api/cortex/noshow/cancel/route.ts
Normal file
51
app/api/cortex/noshow/cancel/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockAppointment } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
const CancelNoShowSchema = z.object({
|
||||
appointmentId: z.string().min(1, 'appointmentId is verplicht'),
|
||||
patientId: z.string().min(1, 'patientId is verplicht'),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof CancelNoShowSchema>;
|
||||
try {
|
||||
body = CancelNoShowSchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { appointmentId, patientId } = body;
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockAppointment(appointmentId)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
appointmentId,
|
||||
newStatus: 'cancelled_no_show',
|
||||
message: 'Afspraak geregistreerd als no show (demo)',
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path — 'cancelled' is de dichtstbijzijnde geldige DB status
|
||||
// In productie zou je een aparte no_show kolom of notitieveld gebruiken
|
||||
const { error } = await supabase
|
||||
.from('encounters')
|
||||
.update({ status: 'cancelled' as 'cancelled' })
|
||||
.eq('id', appointmentId)
|
||||
.eq('patient_id', patientId);
|
||||
|
||||
if (error) {
|
||||
console.error('[noshow/cancel] DB error:', error);
|
||||
return NextResponse.json({ error: 'Annuleren mislukt' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, appointmentId, newStatus: 'cancelled_no_show' });
|
||||
}
|
||||
62
app/api/cortex/noshow/context/route.ts
Normal file
62
app/api/cortex/noshow/context/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockPatient, MOCK_CONCEPT_BRIEF } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const patientId = searchParams.get('patientId');
|
||||
|
||||
if (!patientId) {
|
||||
return NextResponse.json({ error: 'patientId is verplicht' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockPatient(patientId)) {
|
||||
return NextResponse.json({
|
||||
hasConceptBrief: true,
|
||||
document: {
|
||||
id: MOCK_CONCEPT_BRIEF.id,
|
||||
title: MOCK_CONCEPT_BRIEF.title,
|
||||
content: MOCK_CONCEPT_BRIEF.content,
|
||||
type: MOCK_CONCEPT_BRIEF.type,
|
||||
createdAt: MOCK_CONCEPT_BRIEF.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path
|
||||
const { data, error } = await supabase
|
||||
.from('reports')
|
||||
.select('id, content, structured_data, type, created_at')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'huisartsbrief')
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ hasConceptBrief: false });
|
||||
}
|
||||
|
||||
const content = (data.structured_data as Record<string, unknown>)?.content as string
|
||||
?? data.content
|
||||
?? '';
|
||||
|
||||
return NextResponse.json({
|
||||
hasConceptBrief: true,
|
||||
document: {
|
||||
id: data.id,
|
||||
title: 'Huisartsbrief',
|
||||
content,
|
||||
type: data.type,
|
||||
createdAt: data.created_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
54
app/api/cortex/noshow/dispatch/route.ts
Normal file
54
app/api/cortex/noshow/dispatch/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockDocument } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
const DispatchSchema = z.object({
|
||||
documentId: z.string().min(1),
|
||||
finalContent: z.string().min(1, 'Definitieve inhoud is verplicht'),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof DispatchSchema>;
|
||||
try {
|
||||
body = DispatchSchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { documentId, finalContent } = body;
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockDocument(documentId)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
documentId,
|
||||
newStatus: 'ready_for_dispatch',
|
||||
message: 'Brief verzendklaar gemaakt (demo)',
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path
|
||||
const { error } = await supabase
|
||||
.from('reports')
|
||||
.update({
|
||||
status: 'ready_for_dispatch',
|
||||
structured_data: { content: finalContent },
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', documentId)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('[noshow/dispatch] DB error:', error);
|
||||
return NextResponse.json({ error: 'Opslaan mislukt' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, documentId, newStatus: 'ready_for_dispatch' });
|
||||
}
|
||||
90
app/api/cortex/noshow/rescript/route.ts
Normal file
90
app/api/cortex/noshow/rescript/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
|
||||
const RescriptSchema = z.object({
|
||||
documentId: z.string().min(1),
|
||||
originalContent: z.string().min(1, 'Originele inhoud is verplicht'),
|
||||
patientId: z.string().min(1),
|
||||
});
|
||||
|
||||
const RESCRIPT_SYSTEM_PROMPT = `Je bent een klinisch schrijver in een GGZ instelling.
|
||||
Je taak is een bestaande conceptbrief aanpassen zodat de no-show van de patiënt
|
||||
professioneel en contextbewust is verwerkt.
|
||||
|
||||
Regels:
|
||||
- Integreer de no-show in de lopende tekst — voeg het NIET als losse zin achteraan toe
|
||||
- Gebruik formele GGZ-briefstijl: "de patiënt is helaas niet verschenen op het geplande consult"
|
||||
- Bewaar alle bestaande informatie in de brief volledig
|
||||
- Voeg een zin toe over het verzetten van het vervolgcontact
|
||||
- De brief moet leesbaar en coherent blijven
|
||||
- Reageer UITSLUITEND met de herschreven brieftekst — geen uitleg, geen inleiding`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof RescriptSchema>;
|
||||
try {
|
||||
body = RescriptSchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { documentId, originalContent } = body;
|
||||
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'API key ontbreekt' }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
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-haiku-4-5-20251001',
|
||||
max_tokens: 1500,
|
||||
system: RESCRIPT_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Herschrijf de volgende conceptbrief zodat de no-show van vandaag professioneel is verwerkt:\n\n${originalContent}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic API fout: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rescriptedContent = data.content?.[0]?.text ?? originalContent;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
documentId,
|
||||
rescriptedContent,
|
||||
originalContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[noshow/rescript] LLM error:', error);
|
||||
|
||||
// Graceful degradation: originele inhoud teruggeven
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
documentId,
|
||||
rescriptedContent: originalContent,
|
||||
originalContent,
|
||||
warning: 'AI herschrijving mislukt — originele tekst wordt getoond',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user