chat suggestions, SEo integration, and more

This commit is contained in:
colinislit
2025-12-02 13:37:45 +01:00
parent 9dc2b4f216
commit aacada2197
17 changed files with 2276 additions and 739 deletions

View File

@@ -0,0 +1,263 @@
/**
* Client Context Loader
*
* Loads client-specific data from Supabase for the AI Client Assistant.
* Uses direct database queries for performance (not HTTP APIs).
*/
import { supabaseAdmin } from '@/lib/supabase/server'
import type { Database } from '@/lib/supabase/database.types'
type Patient = Database['public']['Tables']['patients']['Row']
type Report = Database['public']['Tables']['reports']['Row']
type Intake = Database['public']['Tables']['intakes']['Row']
type Screening = Database['public']['Tables']['screenings']['Row']
type RiskAssessment = Database['public']['Tables']['risk_assessments']['Row']
/**
* Simplified patient info for AI context
*/
export interface ClientPatient {
name: string
birthDate: string
status: string | null
}
/**
* Simplified report for AI context
*/
export interface ClientReport {
type: string
content: string
date: string
}
/**
* Simplified intake for AI context
*/
export interface ClientIntake {
title: string
department: string
status: string
treatmentAdvice: {
advice?: string
outcome?: string
program?: string
department?: string
} | null
notes: string | null
}
/**
* Simplified screening for AI context
*/
export interface ClientScreening {
requestForHelp: string | null
decision: string | null
decisionNotes: string | null
}
/**
* Simplified risk assessment for AI context
*/
export interface ClientRiskAssessment {
type: string
level: string
rationale: string
date: string
}
/**
* Complete client context for AI prompt
*/
export interface ClientContext {
patient: ClientPatient
reports: ClientReport[]
intakes: ClientIntake[]
screening: ClientScreening | null
riskAssessments: ClientRiskAssessment[]
}
/**
* Format patient name from database fields
*/
function formatPatientName(patient: Patient): string {
const givenNames = patient.name_given?.join(' ') || ''
const prefix = patient.name_prefix ? `${patient.name_prefix} ` : ''
return `${givenNames} ${prefix}${patient.name_family}`.trim()
}
/**
* Format date for display (Dutch format)
*/
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/**
* Load patient basic info
*/
async function loadPatient(clientId: string): Promise<ClientPatient | null> {
const { data, error } = await supabaseAdmin
.from('patients')
.select('name_given, name_family, name_prefix, birth_date, status')
.eq('id', clientId)
.single()
if (error || !data) {
console.error('Error loading patient:', error)
return null
}
return {
name: formatPatientName(data as Patient),
birthDate: formatDate(data.birth_date),
status: data.status,
}
}
/**
* Load recent reports (max 5, newest first)
*/
async function loadReports(clientId: string): Promise<ClientReport[]> {
const { data, error } = await supabaseAdmin
.from('reports')
.select('type, content, created_at')
.eq('patient_id', clientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(5)
if (error || !data) {
console.error('Error loading reports:', error)
return []
}
return data.map((report) => ({
type: report.type === 'behandeladvies' ? 'Behandeladvies' : 'Vrije notitie',
content: report.content,
date: formatDate(report.created_at!),
}))
}
/**
* Load recent intakes with treatment advice (max 3, newest first)
*/
async function loadIntakes(clientId: string): Promise<ClientIntake[]> {
const { data, error } = await supabaseAdmin
.from('intakes')
.select('title, department, status, treatment_advice, notes')
.eq('patient_id', clientId)
.order('created_at', { ascending: false })
.limit(3)
if (error || !data) {
console.error('Error loading intakes:', error)
return []
}
return data.map((intake) => ({
title: intake.title,
department: intake.department,
status: intake.status === 'afgerond' ? 'Afgerond' : 'Bezig',
treatmentAdvice: intake.treatment_advice as ClientIntake['treatmentAdvice'],
notes: intake.notes,
}))
}
/**
* Load most recent screening
*/
async function loadScreening(clientId: string): Promise<ClientScreening | null> {
const { data, error } = await supabaseAdmin
.from('screenings')
.select('request_for_help, decision, decision_notes')
.eq('patient_id', clientId)
.order('created_at', { ascending: false })
.limit(1)
.single()
if (error || !data) {
// No screening is a valid state, not an error
return null
}
return {
requestForHelp: data.request_for_help,
decision: data.decision === 'geschikt' ? 'Geschikt' : data.decision === 'niet_geschikt' ? 'Niet geschikt' : null,
decisionNotes: data.decision_notes,
}
}
/**
* Load risk assessments via intakes (max 5, newest first)
*/
async function loadRiskAssessments(clientId: string): Promise<ClientRiskAssessment[]> {
// First get intake IDs for this patient
const { data: intakes, error: intakesError } = await supabaseAdmin
.from('intakes')
.select('id')
.eq('patient_id', clientId)
if (intakesError || !intakes || intakes.length === 0) {
return []
}
const intakeIds = intakes.map((i) => i.id)
// Then get risk assessments for those intakes
const { data, error } = await supabaseAdmin
.from('risk_assessments')
.select('risk_type, risk_level, rationale, assessment_date')
.in('intake_id', intakeIds)
.order('assessment_date', { ascending: false })
.limit(5)
if (error || !data) {
console.error('Error loading risk assessments:', error)
return []
}
return data.map((ra) => ({
type: ra.risk_type,
level: ra.risk_level,
rationale: ra.rationale,
date: formatDate(ra.assessment_date),
}))
}
/**
* Load complete client context for AI assistant
* Loads all data in parallel for performance
*
* @param clientId - UUID of the patient
* @returns ClientContext or null if patient not found
*/
export async function loadClientContext(clientId: string): Promise<ClientContext | null> {
// Load all data in parallel
const [patient, reports, intakes, screening, riskAssessments] = await Promise.all([
loadPatient(clientId),
loadReports(clientId),
loadIntakes(clientId),
loadScreening(clientId),
loadRiskAssessments(clientId),
])
// Patient must exist
if (!patient) {
return null
}
return {
patient,
reports,
intakes,
screening,
riskAssessments,
}
}

View File

@@ -0,0 +1,170 @@
/**
* Client Prompt Builder
*
* Builds the system prompt for client-specific questions.
* Includes patient context, reports, intakes, screenings, and risk assessments.
*/
import type { ClientContext } from './client-context-loader'
/**
* Base system prompt for client questions
*/
const CLIENT_BASE_PROMPT = `Je bent een EPD-assistent die vragen beantwoordt over een specifieke cliënt in het Mini-ECD systeem.
## Belangrijke regels
1. Beantwoord ALLEEN op basis van de gegeven cliëntgegevens hieronder
2. Als informatie ontbreekt, zeg dit eerlijk (bijv. "Er zijn nog geen rapportages voor deze cliënt")
3. Geef NOOIT medisch advies, diagnoses of behandelsuggesties
4. Verzin NOOIT informatie die niet in de context staat
5. Antwoord beknopt en professioneel
## Jouw publiek
Zorgprofessionals (behandelaars, verpleegkundigen) die het EPD gebruiken.
## Stijl
- Schrijf in het Nederlands
- Wees beknopt maar volledig
- Gebruik bullet points voor overzichten
- Vermeld datums waar relevant`
/**
* Format reports for prompt context
*/
function formatReports(reports: ClientContext['reports']): string {
if (reports.length === 0) {
return 'Geen rapportages beschikbaar.'
}
return reports
.map((report, index) => {
const truncatedContent =
report.content.length > 500 ? report.content.substring(0, 500) + '...' : report.content
return `${index + 1}. [${report.date}] ${report.type}\n${truncatedContent}`
})
.join('\n\n')
}
/**
* Format intakes for prompt context
*/
function formatIntakes(intakes: ClientContext['intakes']): string {
if (intakes.length === 0) {
return 'Geen intakes beschikbaar.'
}
return intakes
.map((intake, index) => {
let text = `${index + 1}. ${intake.title}\n`
text += ` - Afdeling: ${intake.department}\n`
text += ` - Status: ${intake.status}`
if (intake.treatmentAdvice) {
const ta = intake.treatmentAdvice
if (ta.advice) text += `\n - Advies: ${ta.advice.replace(/<[^>]*>/g, '')}`
if (ta.program) text += `\n - Programma: ${ta.program}`
if (ta.outcome) text += `\n - Uitkomst: ${ta.outcome}`
}
if (intake.notes) {
const truncatedNotes =
intake.notes.length > 200 ? intake.notes.substring(0, 200) + '...' : intake.notes
text += `\n - Notities: ${truncatedNotes}`
}
return text
})
.join('\n\n')
}
/**
* Format screening for prompt context
*/
function formatScreening(screening: ClientContext['screening']): string {
if (!screening) {
return 'Geen screening beschikbaar.'
}
let text = ''
if (screening.requestForHelp) {
text += `Hulpvraag: ${screening.requestForHelp}\n`
} else {
text += 'Hulpvraag: Niet ingevuld\n'
}
if (screening.decision) {
text += `Beslissing: ${screening.decision}`
if (screening.decisionNotes) {
text += ` - ${screening.decisionNotes}`
}
} else {
text += 'Beslissing: Nog niet genomen'
}
return text
}
/**
* Format risk assessments for prompt context
*/
function formatRiskAssessments(riskAssessments: ClientContext['riskAssessments']): string {
if (riskAssessments.length === 0) {
return 'Geen risico-assessments beschikbaar.'
}
return riskAssessments
.map((ra, index) => {
return `${index + 1}. ${ra.type} - Niveau: ${ra.level} (${ra.date})\n Onderbouwing: ${ra.rationale}`
})
.join('\n\n')
}
/**
* Build the complete system prompt with client context
*
* @param context - The loaded client context
* @returns The complete system prompt string
*/
export function buildClientPrompt(context: ClientContext): string {
const sections = [
CLIENT_BASE_PROMPT,
'---',
`## Cliënt: ${context.patient.name}`,
`Geboortedatum: ${context.patient.birthDate}`,
context.patient.status ? `Status: ${context.patient.status}` : '',
'',
'---',
'## Rapportages (laatste 5)',
formatReports(context.reports),
'',
'---',
'## Intakes & Behandeladvies',
formatIntakes(context.intakes),
'',
'---',
'## Screening / Hulpvraag',
formatScreening(context.screening),
'',
'---',
"## Risico-assessments",
formatRiskAssessments(context.riskAssessments),
'---',
]
return sections.filter(Boolean).join('\n')
}
/**
* Build a fallback prompt when client context fails to load
*/
export function buildClientErrorPrompt(): string {
return `${CLIENT_BASE_PROMPT}
---
Er is een fout opgetreden bij het laden van de cliëntgegevens.
Vraag de gebruiker om de pagina te verversen of later opnieuw te proberen.
---`
}

View File

@@ -0,0 +1,154 @@
/**
* Question Type Detector
*
* Detects whether a user question is about:
* - 'client': Questions about the active patient/client
* - 'documentation': Questions about how to use the EPD system
* - 'ambiguous': Unclear, defaults to documentation
*/
export type QuestionType = 'client' | 'documentation' | 'ambiguous'
/**
* Keywords that indicate a client-related question
*/
const CLIENT_KEYWORDS = [
// Direct client references
'rapportage',
'rapportages',
'rapportage',
'notitie',
'notities',
'risico',
"risico's",
'risicoassessment',
'behandeladvies',
'behandeladviezen',
'screening',
'hulpvraag',
'samenvatting',
'dossier',
'deze cliënt',
'deze client',
'deze patiënt',
'deze patient',
// Client data questions
'wat staat er',
'wat is er genoteerd',
'laatste',
'recente',
'actuele',
'huidige status',
'zijn risico',
'haar risico',
'zijn behandeling',
'haar behandeling',
// Actions on client data
'geef een overzicht',
'vat samen',
'samenvatten',
'wat weten we',
]
/**
* Keywords that indicate a documentation/system question
*/
const DOC_KEYWORDS = [
// How-to questions
'hoe',
'hoe maak ik',
'hoe kan ik',
'hoe werkt',
'hoe doe ik',
// System references
'waar',
'waar vind ik',
'waar kan ik',
'wat is',
'wat betekent',
'wat doet',
// UI elements
'knop',
'menu',
'scherm',
'tab',
'tabblad',
'pagina',
'formulier',
// Feature references
'functie',
'functionaliteit',
'feature',
'optie',
'instelling',
// Documentation terms
'tutorial',
'handleiding',
'uitleg',
'instructie',
'help',
// System references
'systeem',
'epd',
'applicatie',
'software',
'spraakherkenning',
'spraak',
]
/**
* Count keyword matches in a question
*/
function countKeywordMatches(question: string, keywords: string[]): number {
const lowerQuestion = question.toLowerCase()
return keywords.filter((keyword) => lowerQuestion.includes(keyword.toLowerCase())).length
}
/**
* Detect the type of question based on keywords and context
*
* @param question - The user's question
* @param hasClientContext - Whether a client is currently active
* @returns The detected question type
*/
export function detectQuestionType(question: string, hasClientContext: boolean): QuestionType {
// If no client context, always treat as documentation question
if (!hasClientContext) {
return 'documentation'
}
const clientScore = countKeywordMatches(question, CLIENT_KEYWORDS)
const docScore = countKeywordMatches(question, DOC_KEYWORDS)
// Clear winner
if (clientScore > docScore) {
return 'client'
}
if (docScore > clientScore) {
return 'documentation'
}
// Tie or no matches - check for implicit client references
const lowerQuestion = question.toLowerCase()
// Short questions in client context are often about the client
if (hasClientContext && question.length < 50) {
// Check for implicit client questions
const implicitClientPatterns = [
/^wat zijn/i,
/^wat is de/i,
/^geef/i,
/^toon/i,
/^overzicht/i,
/\?$/,
]
if (implicitClientPatterns.some((pattern) => pattern.test(lowerQuestion))) {
return 'ambiguous' // Let the system handle ambiguity gracefully
}
}
// Default to documentation for safety
return 'ambiguous'
}