chat suggestions, SEo integration, and more
This commit is contained in:
263
lib/docs/client-context-loader.ts
Normal file
263
lib/docs/client-context-loader.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
170
lib/docs/client-prompt-builder.ts
Normal file
170
lib/docs/client-prompt-builder.ts
Normal 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.
|
||||
|
||||
---`
|
||||
}
|
||||
154
lib/docs/question-type-detector.ts
Normal file
154
lib/docs/question-type-detector.ts
Normal 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'
|
||||
}
|
||||
Reference in New Issue
Block a user