Files
triqura-ecd/lib/cortex/action-parser.ts
colinislit 52e07aaf80 fix(cortex): update date handling in API and UI components
- Adjusted date handling in the agenda API to ensure optional parameters for start, end, and label are correctly processed.
- Enhanced chat input and action parser to support optional date labels for relative dates.
- Updated agenda components to handle date ranges and loading states more effectively.
- Improved error handling and loading indicators in the agenda block for better user experience.

This commit ensures consistency in date handling across the application, aligning with the new requirements for relative date inputs.
2026-01-02 09:21:37 +01:00

383 lines
10 KiB
TypeScript

/**
* Action Parser for Cortex Assistent
*
* Parses JSON action objects from AI responses and validates them.
*
* Epic: E3 (Chat API & Cortex Assistent)
* Story: E3.S4 (Intent detection in response)
*/
import { z } from 'zod';
import type { ChatAction, CortexIntent, BlockType } from '@/stores/cortex-store';
// Validation schema for action objects
const ActionSchema = z.object({
type: z.literal('action'),
intent: z.enum([
'dagnotitie',
'zoeken',
'overdracht',
'agenda_query',
'create_appointment',
'cancel_appointment',
'reschedule_appointment',
'unknown',
]),
entities: z.object({
patientName: z.string().optional(),
patientId: z.string().optional(),
category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(),
content: z.string().optional(),
query: z.string().optional(), // For zoeken intent
dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
label: z.string(),
})
.optional(),
datetime: z
.object({
date: z.string().optional(),
time: z.string().optional(),
label: z.string().optional(), // Voor relatieve datums: vandaag, morgen, etc.
})
.optional(),
appointmentType: z.string().optional(),
location: z.string().optional(),
date: z.string().optional(),
time: z.string().optional(),
identifier: z
.union([
z.string(),
z.object({
encounterId: z.string().optional(),
patientName: z.string().optional(),
time: z.string().optional(),
date: z.string().optional(),
}),
])
.optional(),
newDatetime: z
.object({
date: z.string().optional(),
time: z.string().optional(),
label: z.string().optional(), // Voor relatieve datums
})
.optional(),
}),
confidence: z.number().min(0).max(1),
artifact: z
.object({
type: z.enum([
'dagnotitie',
'zoeken',
'overdracht',
'agenda_query',
'create_appointment',
'cancel_appointment',
'reschedule_appointment',
'fallback',
'patient-dashboard',
]),
prefill: z.record(z.string(), z.any()),
})
.optional(),
});
export interface ParsedActionResult {
action: ChatAction | null;
textContent: string; // Text without JSON block
rawJson?: string; // Raw JSON string if found
}
/**
* Extract JSON code block from markdown text
* Looks for ```json ... ``` blocks
*/
function extractJsonBlock(text: string): string | null {
// Match ```json ... ``` blocks (with newlines)
const jsonBlockRegex = /```json\s*\n([\s\S]*?)\n```/;
const match = text.match(jsonBlockRegex);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
/**
* Remove JSON code blocks from text
*/
function removeJsonBlocks(text: string): string {
return text.replace(/```json\s*\n[\s\S]*?\n```/g, '').trim();
}
/**
* Parse action object from AI response
*
* Extracts and validates JSON action objects from markdown code blocks.
*
* @param responseText - Full AI response text
* @returns Parsed action (if valid), cleaned text content, and raw JSON
*/
export function parseActionFromResponse(responseText: string): ParsedActionResult {
// Extract JSON block from markdown
const jsonString = extractJsonBlock(responseText);
if (!jsonString) {
return {
action: null,
textContent: responseText,
};
}
// Try to parse JSON
let parsedJson: unknown;
try {
parsedJson = JSON.parse(jsonString);
} catch (error) {
console.error('Failed to parse JSON action:', error);
return {
action: null,
textContent: responseText,
rawJson: jsonString,
};
}
// Validate against schema
const validation = ActionSchema.safeParse(parsedJson);
if (!validation.success) {
console.error('Action validation failed:', validation.error);
return {
action: null,
textContent: removeJsonBlocks(responseText),
rawJson: jsonString,
};
}
// Normalize entities - convert complex identifier to string
const normalizedEntities = {
...validation.data.entities,
// Convert object identifier to string if needed
identifier: typeof validation.data.entities.identifier === 'object'
? validation.data.entities.identifier.encounterId || JSON.stringify(validation.data.entities.identifier)
: validation.data.entities.identifier,
};
// Valid action found
const action: ChatAction = {
intent: validation.data.intent,
entities: normalizedEntities,
confidence: validation.data.confidence,
artifact: validation.data.artifact,
};
return {
action,
textContent: removeJsonBlocks(responseText),
rawJson: jsonString,
};
}
/**
* Check if confidence is high enough to open artifact
*/
export function shouldOpenArtifact(confidence: number): boolean {
return confidence >= 0.7;
}
/**
* Get user-friendly confidence label
*/
export function getConfidenceLabel(confidence: number): string {
if (confidence >= 0.9) return 'Zeer zeker';
if (confidence >= 0.7) return 'Redelijk zeker';
if (confidence >= 0.5) return 'Onzeker';
return 'Zeer onzeker';
}
/**
* Generate a default confirmation message for an intent when AI response has no text
*/
export function getDefaultConfirmationMessage(intent: CortexIntent, entities: Record<string, any>): string {
const dateLabel = entities?.dateRange?.label || entities?.datetime?.label;
const patientName = entities?.patientName;
switch (intent) {
case 'agenda_query':
if (dateLabel) {
return `Ik toon je de afspraken voor ${dateLabel}.`;
}
return 'Ik toon je de agenda.';
case 'create_appointment':
if (patientName && dateLabel) {
return `Ik open het afspraakformulier voor ${patientName} op ${dateLabel}.`;
}
if (patientName) {
return `Ik open het afspraakformulier voor ${patientName}.`;
}
return 'Ik open het afspraakformulier.';
case 'cancel_appointment':
return 'Ik help je met het annuleren van een afspraak.';
case 'reschedule_appointment':
return 'Ik help je met het verzetten van een afspraak.';
case 'dagnotitie':
if (patientName) {
return `Ik open de dagnotitie voor ${patientName}.`;
}
return 'Ik open het dagnotitie formulier.';
case 'zoeken':
const query = entities?.query || entities?.patientName;
if (query) {
return `Ik zoek naar "${query}".`;
}
return 'Ik open de zoekfunctie.';
case 'overdracht':
return 'Ik bereid de overdracht voor.';
default:
return 'Ik help je verder.';
}
}
/**
* Validate that artifact type matches intent
*/
export function validateArtifactType(intent: CortexIntent, artifactType?: BlockType): boolean {
if (!artifactType) return true; // No artifact is valid
if (artifactType === 'patient-dashboard') return true;
// Intent should match artifact type (except for 'unknown' and 'fallback')
if (intent === 'unknown') return artifactType === 'fallback';
// For agenda intents, all map to agenda block types
const agendaIntents: CortexIntent[] = [
'agenda_query',
'create_appointment',
'cancel_appointment',
'reschedule_appointment',
];
if (agendaIntents.includes(intent)) {
return agendaIntents.includes(artifactType as CortexIntent);
}
return intent === artifactType;
}
/**
* Route intent to appropriate artifact configuration
*
* Maps intents (especially agenda intents) to the correct artifact type with prefill data.
* Implements Epic 5.S1 routing logic.
*
* @param intent - The classified intent
* @param entities - Extracted entities from user input
* @param confidence - Intent classification confidence (0-1)
* @returns Artifact configuration or null if confidence too low or required data missing
*/
export function routeIntentToArtifact(
intent: CortexIntent,
entities: Record<string, any>,
confidence: number
): { type: BlockType; prefill: Record<string, any>; title: string } | null {
// Confidence threshold: return null if too low
// This triggers fallback/clarification question in UI
if (confidence < 0.7) {
return null;
}
// Route agenda intents to AgendaBlock with appropriate configuration
switch (intent) {
case 'agenda_query':
return {
type: 'agenda_query',
title: 'Agenda',
prefill: {
dateRange: entities.dateRange,
},
};
case 'create_appointment':
// Require patient for create
if (!entities.patientName && !entities.patientId) {
return null; // Missing required entity - trigger clarification
}
return {
type: 'create_appointment',
title: 'Nieuwe afspraak',
prefill: {
patientName: entities.patientName,
patientId: entities.patientId,
datetime: entities.datetime,
appointmentType: entities.appointmentType,
location: entities.location,
},
};
case 'cancel_appointment':
return {
type: 'cancel_appointment',
title: 'Afspraak annuleren',
prefill: {
identifier: entities.identifier,
},
};
case 'reschedule_appointment':
// Require identifier to know which appointment
if (!entities.identifier) {
return null; // Missing required entity - trigger clarification
}
return {
type: 'reschedule_appointment',
title: 'Afspraak verzetten',
prefill: {
identifier: entities.identifier,
newDatetime: entities.newDatetime,
},
};
// Non-agenda intents - direct mapping
case 'dagnotitie':
return {
type: 'dagnotitie',
title: 'Dagnotitie',
prefill: entities,
};
case 'zoeken':
return {
type: 'zoeken',
title: 'Patiënt zoeken',
prefill: entities,
};
case 'overdracht':
return {
type: 'overdracht',
title: 'Overdracht',
prefill: entities,
};
case 'unknown':
// Unknown intent - show fallback picker
return {
type: 'fallback',
title: 'Keuze maken',
prefill: entities,
};
default:
return null;
}
}