/** * Server-side patient-context builder. * * Reads from the SQLite database scoped to ONE authenticated user. This is the * cross-user-leak fix: the client is never trusted to ship its own EHR for * an authenticated session — the server looks it up by user_id at chat time. * * Output (XML-tagged block the LLM is taught to consume in * `buildMedicalSystemPrompt`): * '\n\nage=47 sex=M\nconditions=Diabetes\nallergies=Penicillin\nmedications=Metformin 500mg\nlifestyle=smoker\n' * * Returns '' when: * - userId is empty (guest chat — client-side context survives) * - the user has no EHR profile and no active medications * * Server-only module: imports better-sqlite3 (native, Node-only). */ import { getDb } from './db'; import { getUserSettings } from './user-settings'; import { decodeHealthPayload } from './health-data-repo'; export function buildPatientContextForUser(userId: string | null | undefined): string { if (!userId) return ''; const settings = getUserSettings(userId); const ehr = (settings.ehr || {}) as Record; const db = getDb(); const medRows = db .prepare( `SELECT data FROM health_data WHERE user_id = ? AND type = 'medication' ORDER BY updated_at DESC`, ) .all(userId) as Array<{ data: string }>; const meds = medRows .map((r) => { try { return decodeHealthPayload>(r.data); } catch { return null; } }) .filter((m) => m && typeof m === 'object' && (m as any).active !== false); const lines: string[] = []; // Demographics — clinical signal for differential weighting. const demo: string[] = []; if (ehr.dateOfBirth) { const t = new Date(ehr.dateOfBirth).getTime(); if (Number.isFinite(t)) { const age = Math.floor((Date.now() - t) / (365.25 * 86400000)); if (age >= 0 && age < 130) demo.push(`age=${age}`); } } if (ehr.gender && ehr.gender !== 'prefer-not-to-say') { demo.push(`sex=${String(ehr.gender)[0].toUpperCase()}`); } if (demo.length) lines.push(demo.join(' ')); if (Array.isArray(ehr.chronicConditions) && ehr.chronicConditions.length) { lines.push(`conditions=${ehr.chronicConditions.join(', ')}`); } if ( Array.isArray(ehr.allergies) && ehr.allergies.length && !ehr.allergies.includes('None known') ) { lines.push(`allergies=${ehr.allergies.join(', ')}`); } if (meds.length > 0) { lines.push( `medications=${meds .map((m: any) => `${m.name || '?'} ${m.dose || ''}`.trim()) .join(', ')}`, ); } const life: string[] = []; if (ehr.smokingStatus === 'current') life.push('smoker'); else if (ehr.smokingStatus === 'former') life.push('ex-smoker'); if (ehr.alcoholUse === 'heavy') life.push('heavy alcohol'); if (life.length) lines.push(`lifestyle=${life.join(', ')}`); if (lines.length === 0) return ''; return `\n\n${lines.join('\n')}\n`; } /** * Defence-in-depth: strip any client-provided patient-context block * from a message before it hits the LLM. Recognises BOTH formats: * * 1. New: `...` (current builder). * 2. Legacy: `[Patient: ...]` (older clients, in-flight conversations * from before the format change). * * Always called on PRIOR turns (anti-stale-leak). For the CURRENT turn * the chat route only strips when the user is authenticated — because * for authenticated users the server re-derives the truth from the DB, * while for guests the client's localStorage-built block is the only * personalization signal available. */ export function stripInjectedPatientContext(content: string): string { if (!content) return ''; return content .replace(/\n?[\s\S]*?<\/patient_context>\s*/gi, '') .replace(/(^|\n)\s*\[Patient:[^\]\n]*\](?=\n|$)/g, '') .replace(/^\n+/, ''); }