From 7b5fabe46cd4c3d8c8530e1aeb970fc2ebbdff1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Apr 2026 12:48:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(insights):=20QA=20fixes=20=E2=80=94=20paire?= =?UTF-8?q?d=20BP=20targets,=20stdDev,=20pulse=20pressure,=20personal=20to?= =?UTF-8?q?ne,=20schema=20enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix pctInTarget to use paired-reading logic (both sys AND dia must be in range simultaneously) - Add sdSys30/sdDia30 (BP standard deviation) and pulsePressure30 (arterial stiffness marker) - Add mood scale descriptor for AI context - Extend medication data with compliance90, 30-day streak window, and category field - Rewrite base system prompt: personal advisor tone, second person, positive-first, "One Thing" pattern - Add conditional correlation instructions to all domain prompts (only mention when |r| > 0.4) - Enrich InsightResult schema with insightType, classificationLabel, primaryRecommendation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/ai/prompts/base-system.ts | 30 +++++++--- src/lib/ai/prompts/blood-pressure.ts | 6 +- src/lib/ai/prompts/general-status.ts | 2 +- src/lib/ai/prompts/medication-compliance.ts | 5 +- src/lib/ai/prompts/mood.ts | 2 +- src/lib/ai/prompts/pulse.ts | 6 +- src/lib/ai/prompts/weight.ts | 2 +- src/lib/ai/types.ts | 3 + src/lib/insights/features.ts | 61 +++++++++++++++++---- 9 files changed, 89 insertions(+), 28 deletions(-) diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index be6cf1f43..8271c6071 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -1,4 +1,15 @@ -export const BASE_SYSTEM_PROMPT = `Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM). +export const BASE_SYSTEM_PROMPT = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers. + +TONALITÄT UND ANSPRACHE: +- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht". +- Beginne mit positiven Befunden, bevor du auf Bedenken eingehst. +- Beziehe dich auf die eigene Baseline des Nutzers, nicht auf Bevölkerungsnormen (z.B. "dein systolischer Wert liegt 5 mmHg unter deinem 90-Tage-Durchschnitt" statt "unter dem Bevölkerungsdurchschnitt"). +- Formuliere eine zentrale Handlungsempfehlung ("One Thing") als wichtigste nächste Aktion. + +DENKSCHRITTE (intern anwenden, nicht im Output zeigen): +1. Was hat sich verändert? (Vergleiche 7d vs. 30d vs. 90d vs. allTime) +2. Warum? (Korrelationen, Medikation, Stimmung, saisonale Muster) +3. Was tun? (Eine primäre Empfehlung + ergänzende Vorschläge) GRUNDREGELN: - Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit bei Bewertungen. @@ -14,9 +25,11 @@ GRUNDREGELN: KORRELATIONSANALYSE: - Du erhältst vorberechnete Pearson-Korrelationen zwischen Metriken. +- Nur erwähnen wenn die Korrelation (r-Wert) im Snapshot vorhanden und |r| > 0.4 ist. +- Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. - r > 0.7: starke Korrelation — klinisch relevant, detailliert kommentieren - r 0.4-0.7: moderate Korrelation — erwähnen, vorsichtig interpretieren -- r < 0.4: schwache/keine Korrelation — nicht überinterpretieren +- r < 0.4: schwache/keine Korrelation — nicht erwähnen - Korrelation ≠ Kausalität: immer als "Zusammenhang" formulieren, nicht als "Ursache" HISTORISCHER VERGLEICH: @@ -28,8 +41,8 @@ HISTORISCHER VERGLEICH: * Puls: ±5 bpm relevant STIMMUNGSDATEN: -- Stimmung (1-5 Skala: 1=LAUSIG, 5=SUPER_GUT) als kontextuellen Faktor einbeziehen -- Korrelation mit Vitalwerten nur erwähnen wenn statistisch auffällig (r > 0.3) +- Stimmung (1-5 Skala: 1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT) als kontextuellen Faktor einbeziehen +- Korrelation mit Vitalwerten nur erwähnen wenn im Snapshot vorhanden und |r| > 0.4 - Stress/Stimmung beeinflusst nachweislich Blutdruck und Herzfrequenz TEMPORALE SCHICHTEN: @@ -39,11 +52,14 @@ TEMPORALE SCHICHTEN: AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema: { - "summary": "2-3 Sätze Gesamtbewertung", + "insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general", + "summary": "2-3 Sätze Gesamtbewertung (in zweiter Person, positiv zuerst)", "classification": "optimal|gut|grenzwertig|erhoht|kritisch", + "classificationLabel": "Menschenlesbare deutsche Bezeichnung (z.B. 'Adipositas Grad II', 'Hochnormal', 'Bradykardie')", "findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}], "correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}], - "recommendations": ["..."], + "primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung (max 20 Wörter)", + "recommendations": ["2-3 ergänzende Vorschläge"], "dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"}, - "disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultieren Sie Ihren Arzt." + "disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt." }`; diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index cf5297257..9a6ae0f59 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -17,9 +17,9 @@ FACHSPEZIFISCH — BLUTDRUCK: - Pulsdruck: (Systolisch - Diastolisch) > 60 mmHg als Marker für arterielle Steifigkeit bewerten. - Medikamenten-Korrelation: Einnahmetreue von Antihypertensiva mit Blutdruckverlauf korrelieren. - Gewichts-Korrelation: Gewichtstrend mit Blutdruckverlauf vergleichen (pro kg Gewichtsverlust ca. 1 mmHg Senkung erwartet). -- Stimmungs-Korrelation: Falls Stimmungsdaten verfügbar, Zusammenhang mit Blutdruckvariabilität prüfen. -- Analysiere die weightVsSystolic-Korrelation: Pro kg Gewichtsreduktion kann 1 mmHg systolische Senkung erwartet werden. Bewerte ob der berechnete Zusammenhang diesem klinischen Modell entspricht. -- Analysiere die weightVsDiastolic-Korrelation analog und bewerte die klinische Plausibilität. +- Stimmungs-Korrelation: Nur erwähnen wenn moodVsSystolicCorrelation im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren. +- weightVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion kann 1 mmHg systolische Senkung erwartet werden. +- weightVsDiastolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. - Vergleiche avgSys30 und avgSys90 sowie allTimeAvg um langfristige Trends zu erkennen. - Nutze historicalComparison.systolic und historicalComparison.diastolic: Bei ≥5 mmHg systolischer bzw. ≥3 mmHg diastolischer Veränderung klinisch bewerten.`; } diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index e95be069a..9ed0e8fec 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -11,7 +11,7 @@ FACHSPEZIFISCH — GESAMTBEWERTUNG: - Medikamenten-Adhärenz: Gesamteinnahmetreue bewerten und Zusammenhang mit Vitalwerten benennen. - Stimmung: Falls vorhanden, als kontextuellen Faktor einbeziehen (nicht übergewichten). - Positive Entwicklungen explizit hervorheben — Motivation ist therapeutisch relevant. -- Nutze die correlations-Daten um Zusammenhänge zwischen Gewicht, Blutdruck, Puls und Stimmung zu identifizieren. Vergleiche die aktuellen 7-Tage-Werte mit den Langzeit-Durchschnitten (avg90, allTime). +- Korrelationen nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls ein Korrelationsfeld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. Vergleiche die aktuellen 7-Tage-Werte mit den Langzeit-Durchschnitten (avg90, allTime). - Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen. - Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.`; } diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index 5d8b9a271..a5a0f60b1 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -17,8 +17,9 @@ FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ: - Verpasste Dosen: Konsequenzen differenzieren (Antihypertensiva-Rebound vs. Statine = weniger zeitkritisch). - Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen. - Vergleiche Perioden hoher Adhärenz (>90%) mit den zugehörigen Vitalwerten. Zeige konkret: "In Wochen mit >90% Einnahmetreue war der systolische RR X mmHg niedriger." -- Nutze die vorberechneten Korrelationen (correlations) und historicalComparison um den Einfluss der Adhärenz auf Blutdruck und Puls zu quantifizieren. -- Falls Stimmungsdaten verfügbar: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.`; +- Korrelationen nur erwähnen wenn der r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. +- Nutze historicalComparison um den Einfluss der Adhärenz auf Blutdruck und Puls zu quantifizieren, sofern die Daten im Snapshot vorhanden sind. +- Falls Stimmungsdaten verfügbar und Korrelation |r| > 0.4: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.`; } export function getMedicationComplianceUserPrompt(snapshotJson: string, todayKey: string): string { diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index daf620d1b..8b59b0d11 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -9,7 +9,7 @@ FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN: - Anhaltende Phasen: Mehr als 3 Tage unter 2.5 oder uber 4.5 sind auffallig. - Zusammenhange: Stimmung korreliert haufig mit Schlaf, Aktivitat, Blutdruck und Medikamenten-Compliance. - Tags: Falls Stimmungs-Tags vorhanden sind, prufe ob bestimmte Tags mit Stimmungslevels korrelieren. -- Cross-Metrik: Falls Kontext zu Gewicht, Blutdruck oder Puls vorhanden ist und eine auffaellige Korrelation zeigt, kurz erwahnen. +- Cross-Metrik: Korrelationen zu Gewicht, Blutdruck oder Puls nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. - Erzwinge keine Querverweise wenn kein klares Muster erkennbar ist.`; } diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index f8d998506..f55c08de5 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -14,9 +14,9 @@ FACHSPEZIFISCH — PULS/HERZFREQUENZ: - Erholung: Sinkender Ruhepuls über Wochen deutet auf verbesserte kardiovaskuläre Fitness hin. - Medikamenten-Einfluss: Betablocker senken die Herzfrequenz — Compliance-Korrelation prüfen. - Blutdruck-Korrelation: Puls × systolischen Druck = Doppelprodukt als Herzbelastungsindikator. -- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen. -- Nutze die moodVsPulse-Korrelation um Stress-Einfluss auf den Ruhepuls einzuschätzen. Bei r > 0.3 (negative Korrelation = schlechtere Stimmung → höherer Puls) den Zusammenhang klinisch einordnen. -- Nutze die pulseVsSystolic-Korrelation für die Bewertung der hämodynamischen Kopplung. +- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen. Nur erwähnen wenn moodVsPulse-Korrelation im Snapshot vorhanden und |r| > 0.4. +- moodVsPulse-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren. +- pulseVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Bewertung der hämodynamischen Kopplung. - Vergleiche avg7 vs avg30 vs avg90 vs allTimeAvg um kurzfristige Abweichungen von der Langzeit-Baseline zu erkennen. - Nutze historicalComparison.pulse: Bei ≥5 bpm Veränderung gegenüber der Baseline klinisch bewerten.`; } diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 82e6f2745..890dee87e 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -9,7 +9,7 @@ FACHSPEZIFISCH — GEWICHT: - Trend-Analyse: 7-Tage, 30-Tage und 90-Tage gleitende Durchschnitte vergleichen. - Plateau-Erkennung: Gewichtsveränderung < ±0.5 kg über > 14 Tage als Plateau identifizieren. - Realistische Zielprojektion: Maximal 0.5-1.0 kg/Woche als nachhaltiger Gewichtsverlust (DGE-Empfehlung). -- Gewichts-Blutdruck-Korrelation: Pro kg Gewichtsreduktion ca. 1 mmHg systolische Senkung. +- Gewichts-Blutdruck-Korrelation: Nur erwähnen wenn weightVsSystolic im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion ca. 1 mmHg systolische Senkung. - Medikamenten-Einfluss: Gewichtsrelevante Medikamente identifizieren (z.B. Betablocker, Cortison). - Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal). - Vergleiche das aktuelle 7-Tage-Mittel (historicalComparison.weight.current7dAvg) mit dem 30-Tage-Baseline (previous30dAvg). Bei >2 kg Differenz: klinisch bewerten und Ursachen diskutieren. diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 244695e91..d2f230998 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -22,10 +22,13 @@ export const insightDataQualitySchema = z.object({ }); export const insightResultSchema = z.object({ + insightType: z.string().optional(), summary: z.string(), classification: z.enum(["optimal", "gut", "grenzwertig", "erhoht", "kritisch"]), + classificationLabel: z.string().optional(), findings: z.array(insightFindingSchema), correlations: z.array(insightCorrelationSchema), + primaryRecommendation: z.string().optional(), recommendations: z.array(z.string()), dataQuality: insightDataQualitySchema, disclaimer: z.string(), diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 6f690f1cf..ec59a367c 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -13,6 +13,7 @@ import { pearsonCorrelation, type CorrelationResult, } from "@/lib/analytics/correlations"; +import { getMedicationCategories } from "@/lib/medication-category"; interface DataCoverage { count: number; @@ -49,6 +50,9 @@ export interface AggregatedFeatures { allTimeMaxDia: number | null; slopeSys30: number | null; slopeDia30: number | null; + sdSys30: number | null; + sdDia30: number | null; + pulsePressure30: number | null; pctInTarget: number | null; coverage: DataCoverage; }; @@ -70,6 +74,7 @@ export interface AggregatedFeatures { coverage: DataCoverage; }; mood?: { + scale: string; avg7: number | null; avg30: number | null; latest: number | null; @@ -109,8 +114,10 @@ export interface AggregatedFeatures { medications?: Array<{ name: string; dose: string; + category: string; compliance7: number; compliance30: number; + compliance90: number; streak: number; missedLast7: number; }>; @@ -134,6 +141,13 @@ export interface RawFeatures extends AggregatedFeatures { }>; } +function stdDev(values: number[]): number | null { + if (values.length < 2) return null; + const mean = values.reduce((s, v) => s + v, 0) / values.length; + const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / (values.length - 1); + return Math.round(Math.sqrt(variance) * 10) / 10; +} + function toDataPoints( records: Array<{ value: number; measuredAt: Date }>, ): DataPoint[] { @@ -309,16 +323,19 @@ export async function extractFeatures( let pctInTarget: number | null = null; if (bpTargets) { - const sysInRange = sysData.filter( - (m) => m.value >= bpTargets.sysLow && m.value <= bpTargets.sysHigh, - ).length; - const diaInRange = diaData.filter( - (m) => m.value >= bpTargets.diaLow && m.value <= bpTargets.diaHigh, - ).length; - const total = sysData.length + diaData.length; - if (total > 0) { - pctInTarget = Math.round(((sysInRange + diaInRange) / total) * 100); + const sysByTime = new Map(sysData.map(m => [m.measuredAt.getTime(), m.value])); + let inTargetCount = 0; + let pairedCount = 0; + for (const dia of diaData) { + const sysVal = sysByTime.get(dia.measuredAt.getTime()); + if (sysVal === undefined) continue; + pairedCount++; + if (sysVal >= bpTargets.sysLow && sysVal <= bpTargets.sysHigh && + dia.value >= bpTargets.diaLow && dia.value <= bpTargets.diaHigh) { + inTargetCount++; + } } + pctInTarget = pairedCount > 0 ? Math.round((inTargetCount / pairedCount) * 100) : null; } features.bloodPressure = { @@ -334,6 +351,22 @@ export async function extractFeatures( allTimeMaxDia: diaSummary?.count ? diaSummary.max : null, slopeSys30: sysSummary?.slope30?.slope ?? null, slopeDia30: diaSummary?.slope30?.slope ?? null, + sdSys30: (() => { + const fromMs = now - 30 * 24 * 60 * 60 * 1000; + const vals = sysData.filter(m => m.measuredAt.getTime() >= fromMs).map(m => m.value); + return stdDev(vals); + })(), + sdDia30: (() => { + const fromMs = now - 30 * 24 * 60 * 60 * 1000; + const vals = diaData.filter(m => m.measuredAt.getTime() >= fromMs).map(m => m.value); + return stdDev(vals); + })(), + pulsePressure30: (() => { + const avgSys = sysSummary?.avg30 ?? null; + const avgDia = diaSummary?.avg30 ?? null; + if (avgSys === null || avgDia === null) return null; + return Math.round((avgSys - avgDia) * 10) / 10; + })(), pctInTarget, coverage: computeCoverage( [...sysData, ...diaData].sort( @@ -425,6 +458,7 @@ export async function extractFeatures( : null; features.mood = { + scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT", avg7: avg(last7), avg30: avg(last30), latest: moodEntries[moodEntries.length - 1].score, @@ -487,6 +521,10 @@ export async function extractFeatures( }); if (medications.length > 0) { + const categoryMap = await getMedicationCategories( + medications.map((med) => med.id), + ); + features.medications = []; for (const med of medications) { const events = await prisma.medicationIntakeEvent.findMany({ @@ -500,13 +538,16 @@ export async function extractFeatures( })); const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt); const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt); + const c90 = calculateCompliance(mapped, med.schedules, 90, med.createdAt); features.medications.push({ name: med.name, dose: med.dose, + category: categoryMap[med.id] ?? "OTHER", compliance7: c7.rate, compliance30: c30.rate, - streak: c7.streak, + compliance90: c90.rate, + streak: c30.streak, missedLast7: c7.missed, }); }