fix(insights): QA fixes — paired BP targets, stdDev, pulse pressure, personal tone, schema enrichment

- 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 12:48:28 +02:00
1 parent 64c72d241c
commit 7b5fabe46c
9 files changed
+89 -28

No files matched your search

+23 -7
View File
@@ -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."
}`;
+3 -3
View File
@@ -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.`;
}
+1 -1
View File
@@ -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.`;
}
+3 -2
View File
@@ -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 {
+1 -1
View File
@@ -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.`;
}
+3 -3
View File
@@ -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.`;
}
+1 -1
View File
@@ -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.
+3
View File
@@ -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(),
+51 -10
View File
@@ -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,
});
}