feat(insights): enrich features with correlations, mood, historical comparisons and improve prompts

Add 90-day averages, all-time stats, mood data, cross-metric Pearson correlations,
historical comparisons (7d vs previous 30d baseline), and age/gender context to the
AI feature extraction. Update all prompts with correlation analysis instructions,
clinical significance thresholds, and temporal layer comparisons.

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:37:39 +02:00
1 parent 360a11523e
commit 64c72d241c
7 files changed
+287 -6

No files matched your search

+26
View File
@@ -10,6 +10,32 @@ GRUNDREGELN:
* Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell."
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich.
- Disclaimer: Immer den Standardhinweis verwenden.
- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden.
KORRELATIONSANALYSE:
- Du erhältst vorberechnete Pearson-Korrelationen zwischen Metriken.
- 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
- Korrelation ≠ Kausalität: immer als "Zusammenhang" formulieren, nicht als "Ursache"
HISTORISCHER VERGLEICH:
- Vergleiche aktuelle 7-Tage-Werte mit dem 30-Tage-Durchschnitt der Vorperiode
- Klinisch relevante Veränderungen benennen:
* Gewicht: ±2 kg relevant
* Systolisch: ±5 mmHg relevant
* Diastolisch: ±3 mmHg relevant
* 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)
- Stress/Stimmung beeinflusst nachweislich Blutdruck und Herzfrequenz
TEMPORALE SCHICHTEN:
- Vergleiche kurzfristig (7d) vs. mittelfristig (30d) vs. langfristig (90d/allTime)
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema:
{
+6 -1
View File
@@ -17,13 +17,18 @@ 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.`;
- 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.
- 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.`;
}
export function getBloodPressureUserPrompt(snapshotJson: string, todayKey: string): string {
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die folgenden Blutdruck-Daten mit Fokus auf Trends, Zielwerterreichung und Medikamentenwirksamkeit.
Berücksichtige die Messzeiträume und Datendichte für die Konfidenzeinschätzung.
Nutze die vorberechneten Korrelationen (correlations) und den historischen Vergleich (historicalComparison) für eine fundierte temporale Analyse.
${snapshotJson}`;
}
+5 -1
View File
@@ -10,7 +10,10 @@ FACHSPEZIFISCH — GESAMTBEWERTUNG:
- Risikostratifizierung: Bewerte das kardiovaskuläre Gesamtrisiko basierend auf der Kombination aller Faktoren.
- 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.`;
- 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).
- Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen.
- Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.`;
}
export function getGeneralStatusUserPrompt(snapshotJson: string, todayKey: string): string {
@@ -32,6 +35,7 @@ export function buildGeneratePrompts(featuresJson: string, privacyMode: string):
userPrompt: `Analysiere die folgenden Gesundheitsdaten.
Datenmodus: ${modeLabel}
Beachte die coverage-Objekte jeder Metrik für Informationen zu Messhäufigkeit und Zeiträumen.
Nutze die correlations-Daten für Kreuzanalysen und historicalComparison für temporale Einordnung.
${featuresJson}`,
};
+5 -1
View File
@@ -15,12 +15,16 @@ FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
- Wirksamkeits-Korrelation: Direkte Verbindung zwischen Einnahmetreue und Vitalwert-Veränderungen herstellen.
* Beispiel: "In Wochen mit > 90% Einnahmetreue war der mittlere systolische RR 5 mmHg niedriger."
- Verpasste Dosen: Konsequenzen differenzieren (Antihypertensiva-Rebound vs. Statine = weniger zeitkritisch).
- Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen.`;
- 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.`;
}
export function getMedicationComplianceUserPrompt(snapshotJson: string, todayKey: string): string {
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Medikamenten-Einnahmetreue mit Fokus auf Muster, Wirksamkeitskorrelation und konkrete Verbesserungsvorschläge.
Nutze die Korrelationsdaten und den historischen Vergleich um den Zusammenhang zwischen Adhärenz und Vitalwerten zu belegen.
${snapshotJson}`;
}
+6 -1
View File
@@ -14,12 +14,17 @@ 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.`;
- 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.
- 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.`;
}
export function getPulseUserPrompt(snapshotJson: string, todayKey: string): string {
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Puls-/Herzfrequenzdaten mit Fokus auf Ruhepulstrend, Variabilität und Zusammenhänge mit Medikation und Stimmung.
Nutze die vorberechneten Korrelationen und den historischen Vergleich für eine fundierte Analyse.
${snapshotJson}`;
}
+5 -1
View File
@@ -11,12 +11,16 @@ FACHSPEZIFISCH — GEWICHT:
- 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.
- Medikamenten-Einfluss: Gewichtsrelevante Medikamente identifizieren (z.B. Betablocker, Cortison).
- Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal).`;
- 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.
- Plateau-Definition: <0.5 kg Veränderung über >14 Tage (prüfe ob avg7 ≈ avg30 ≈ avg90).
- Nutze allTimeMin/allTimeMax/allTimeAvg um den aktuellen Wert historisch einzuordnen.`;
}
export function getWeightUserPrompt(snapshotJson: string, todayKey: string): string {
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Gewichtsentwicklung mit Fokus auf Trends, BMI-Klassifikation und Zusammenhang mit anderen Vitalwerten.
Nutze die temporalen Schichten (avg7, avg30, avg90, allTime) und den historischen Vergleich für eine differenzierte Bewertung.
${snapshotJson}`;
}
+234 -1
View File
@@ -5,8 +5,14 @@
*/
import { prisma } from "@/lib/db";
import { summarize } from "@/lib/analytics/trends";
import type { DataPoint } from "@/lib/analytics/trends";
import { calculateCompliance } from "@/lib/analytics/compliance";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
pairByTimestamp,
pearsonCorrelation,
type CorrelationResult,
} from "@/lib/analytics/correlations";
interface DataCoverage {
count: number;
@@ -21,6 +27,10 @@ export interface AggregatedFeatures {
latest: number;
avg7: number | null;
avg30: number | null;
avg90: number | null;
allTimeAvg: number | null;
allTimeMin: number | null;
allTimeMax: number | null;
slope30: number | null;
outlierCount: number;
bmi: number | null;
@@ -29,13 +39,26 @@ export interface AggregatedFeatures {
bloodPressure?: {
avgSys30: number | null;
avgDia30: number | null;
avgSys90: number | null;
avgDia90: number | null;
allTimeAvgSys: number | null;
allTimeAvgDia: number | null;
allTimeMinSys: number | null;
allTimeMaxSys: number | null;
allTimeMinDia: number | null;
allTimeMaxDia: number | null;
slopeSys30: number | null;
slopeDia30: number | null;
pctInTarget: number | null;
coverage: DataCoverage;
};
pulse?: {
avg7: number | null;
avg30: number | null;
avg90: number | null;
allTimeAvg: number | null;
allTimeMin: number | null;
allTimeMax: number | null;
slope30: number | null;
anomalyCount: number;
coverage: DataCoverage;
@@ -46,6 +69,43 @@ export interface AggregatedFeatures {
slope30: number | null;
coverage: DataCoverage;
};
mood?: {
avg7: number | null;
avg30: number | null;
latest: number | null;
trend30: "improving" | "declining" | "stable" | null;
totalEntries: number;
coverage: DataCoverage;
};
correlations?: {
weightVsSystolic: CorrelationResult | null;
weightVsDiastolic: CorrelationResult | null;
pulseVsSystolic: CorrelationResult | null;
moodVsPulse: CorrelationResult | null;
moodVsSystolic: CorrelationResult | null;
};
historicalComparison?: {
weight?: {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
};
systolic?: {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
};
diastolic?: {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
};
pulse?: {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
};
};
medications?: Array<{
name: string;
dose: string;
@@ -61,6 +121,8 @@ export interface AggregatedFeatures {
dataSpanDays: number;
oldestMeasurementDaysAgo: number | null;
newestMeasurementDaysAgo: number | null;
ageYears: number | null;
gender: string | null;
};
}
@@ -74,7 +136,7 @@ export interface RawFeatures extends AggregatedFeatures {
function toDataPoints(
records: Array<{ value: number; measuredAt: Date }>,
): Array<{ date: Date; value: number }> {
): DataPoint[] {
return records.map((r) => ({ date: r.measuredAt, value: r.value }));
}
@@ -107,6 +169,38 @@ function computeCoverage(
};
}
/** Compute average of values within a time window (days ago from now). */
function avgInWindow(
records: Array<{ value: number; measuredAt: Date }>,
now: number,
fromDaysAgo: number,
toDaysAgo: number = 0,
): number | null {
const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000;
const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000;
const filtered = records.filter((r) => {
const t = r.measuredAt.getTime();
return t >= fromMs && t <= toMs;
});
if (filtered.length === 0) return null;
const sum = filtered.reduce((s, r) => s + r.value, 0);
return Math.round((sum / filtered.length) * 100) / 100;
}
/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */
function computeHistoricalComparison(
records: Array<{ value: number; measuredAt: Date }>,
now: number,
): { current7dAvg: number | null; previous30dAvg: number | null; change: number | null } {
const current7dAvg = avgInWindow(records, now, 7, 0);
const previous30dAvg = avgInWindow(records, now, 37, 7);
const change =
current7dAvg !== null && previous30dAvg !== null
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
: null;
return { current7dAvg, previous30dAvg, change };
}
export async function extractFeatures(
userId: string,
includeRaw: boolean,
@@ -116,6 +210,7 @@ export async function extractFeatures(
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
},
});
@@ -146,6 +241,18 @@ export async function extractFeatures(
)
: 0;
// Compute age
let ageYears: number | null = null;
if (user?.dateOfBirth) {
const dob = user.dateOfBirth;
const today = new Date();
ageYears = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
ageYears--;
}
}
const features: AggregatedFeatures = {
context: {
heightCm: user?.heightCm ?? null,
@@ -162,6 +269,8 @@ export async function extractFeatures(
(now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
)
: null,
ageYears,
gender: user?.gender ?? null,
},
};
@@ -178,6 +287,10 @@ export async function extractFeatures(
latest: summary.latest!,
avg7: summary.avg7,
avg30: summary.avg30,
avg90: avgInWindow(weightData, now, 90),
allTimeAvg: summary.count > 0 ? summary.mean : null,
allTimeMin: summary.count > 0 ? summary.min : null,
allTimeMax: summary.count > 0 ? summary.max : null,
slope30: summary.slope30?.slope ?? null,
outlierCount: summary.anomalyCount,
bmi,
@@ -211,6 +324,14 @@ export async function extractFeatures(
features.bloodPressure = {
avgSys30: sysSummary?.avg30 ?? null,
avgDia30: diaSummary?.avg30 ?? null,
avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null,
avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null,
allTimeAvgSys: sysSummary?.count ? sysSummary.mean : null,
allTimeAvgDia: diaSummary?.count ? diaSummary.mean : null,
allTimeMinSys: sysSummary?.count ? sysSummary.min : null,
allTimeMaxSys: sysSummary?.count ? sysSummary.max : null,
allTimeMinDia: diaSummary?.count ? diaSummary.min : null,
allTimeMaxDia: diaSummary?.count ? diaSummary.max : null,
slopeSys30: sysSummary?.slope30?.slope ?? null,
slopeDia30: diaSummary?.slope30?.slope ?? null,
pctInTarget,
@@ -228,7 +349,12 @@ export async function extractFeatures(
if (pulseData.length > 0) {
const summary = summarize(toDataPoints(pulseData));
features.pulse = {
avg7: summary.avg7,
avg30: summary.avg30,
avg90: avgInWindow(pulseData, now, 90),
allTimeAvg: summary.count > 0 ? summary.mean : null,
allTimeMin: summary.count > 0 ? summary.min : null,
allTimeMax: summary.count > 0 ? summary.max : null,
slope30: summary.slope30?.slope ?? null,
anomalyCount: summary.anomalyCount,
coverage: computeCoverage(pulseData, now),
@@ -247,6 +373,113 @@ export async function extractFeatures(
};
}
// Mood
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
});
if (moodEntries.length > 0) {
const moodNow = Date.now();
const last7 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000,
);
const last30 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000,
);
const avg = (entries: typeof moodEntries) =>
entries.length > 0
? Math.round(
(entries.reduce((s, e) => s + e.score, 0) / entries.length) * 100,
) / 100
: null;
// Compute 30-day trend: compare first half vs second half of last 30 days
let trend30: "improving" | "declining" | "stable" | null = null;
if (last30.length >= 4) {
const firstHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
);
const secondHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 15 * 24 * 60 * 60 * 1000,
);
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
const avgFirst = avg(firstHalf)!;
const avgSecond = avg(secondHalf)!;
const diff = avgSecond - avgFirst;
if (diff > 0.3) trend30 = "improving";
else if (diff < -0.3) trend30 = "declining";
else trend30 = "stable";
}
}
const oldest = moodEntries[0].moodLoggedAt;
const newest = moodEntries[moodEntries.length - 1].moodLoggedAt;
const spanDays = Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
);
const avgDaysBetween =
moodEntries.length > 1
? Math.round((spanDays / (moodEntries.length - 1)) * 10) / 10
: null;
features.mood = {
avg7: avg(last7),
avg30: avg(last30),
latest: moodEntries[moodEntries.length - 1].score,
trend30,
totalEntries: moodEntries.length,
coverage: {
count: moodEntries.length,
spanDays,
avgDaysBetween,
oldestDaysAgo: Math.round(
(moodNow - oldest.getTime()) / (24 * 60 * 60 * 1000),
),
newestDaysAgo: Math.round(
(moodNow - newest.getTime()) / (24 * 60 * 60 * 1000),
),
},
};
}
// Cross-metric correlations
const weightPoints = toDataPoints(weightData);
const sysPoints = toDataPoints(sysData);
const diaPoints = toDataPoints(diaData);
const pulsePoints = toDataPoints(pulseData);
const moodPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
}));
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>
pearsonCorrelation(pairByTimestamp(a, b));
features.correlations = {
weightVsSystolic: computeCorr(weightPoints, sysPoints),
weightVsDiastolic: computeCorr(weightPoints, diaPoints),
pulseVsSystolic: computeCorr(pulsePoints, sysPoints),
moodVsPulse: computeCorr(moodPoints, pulsePoints),
moodVsSystolic: computeCorr(moodPoints, sysPoints),
};
// Historical comparison: current 7d avg vs previous 30d avg (days 7-37)
features.historicalComparison = {};
if (weightData.length > 0) {
features.historicalComparison.weight = computeHistoricalComparison(weightData, now);
}
if (sysData.length > 0) {
features.historicalComparison.systolic = computeHistoricalComparison(sysData, now);
}
if (diaData.length > 0) {
features.historicalComparison.diastolic = computeHistoricalComparison(diaData, now);
}
if (pulseData.length > 0) {
features.historicalComparison.pulse = computeHistoricalComparison(pulseData, now);
}
// Medications
const medications = await prisma.medication.findMany({
where: { userId, active: true },