mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(insights): AI insights remember the previous analysis + hero finding card (#129)
The AI insights surface had two long-standing weaknesses:
1. **Every analysis read like the user's first one.** The general-status
and blood-pressure prompts saw only the latest 30 days of data, not
the previous analysis the user already read last week. So the model
restated the same status every day instead of saying "your BP is down
4 mmHg from your last check" or "same elevated trend as last week".
2. **All findings looked the same.** The card listed every finding in a
uniform compact row. The model is instructed to put the most
clinically relevant finding first, but the UI didn't honour that
ordering with any visual weight.
This PR closes both for the two highest-impact insight surfaces (general
status and blood pressure). Same pattern lifts to the other five
generators in a follow-up.
**`src/lib/insights/memory.ts`** — new helper that reads the user's
previous cached insight from the existing `audit_logs` cache rows
(scope-keyed `insights.{scope}.{locale}`), returns it as a typed
`PreviousInsightContext`, and formats it into a locale-aware
prompt-injection block. Default 12-hour age floor keeps the model from
treating today's earlier run as "previous". The block ends with an
explicit comparison instruction in the user's locale; passing `null`
context produces a "first analysis — no delta to surface" instruction
so the model doesn't hallucinate one.
**Wired into `general-status` and `blood-pressure-status` generators.**
Both prompts now accept an optional `previousContextBlock` argument.
When supplied, the model gets the previous narrative + the comparison
instruction inline above the snapshot. No schema change — caches keep
writing to the same audit-log rows the helper reads from. Empty for
first-run users.
**Hero finding card.** Top finding gets a severity-tinted gradient
(green/cyan/orange/red), 2-px coloured left border, larger typography,
and a descriptive eyebrow label ("Top finding" / "Worth attention" /
"Top alert"). Secondary findings keep the previous compact layout so
the visual hierarchy reads at a glance. Eyebrow labels translated
under `insights.heroFinding{Positive,Neutral,Attention,Warning}`.
Tests: 400 → 410 (+10 for the memory helper covering JSON cache,
legacy plaintext fallback, age-floor semantics, locale-aware
formatting).
Follow-up PRs:
- Wire the same memory helper into the remaining five generators
(weight, pulse, BMI, mood, medication-compliance). Pattern is
mechanical — the prompt signatures already accept the new optional
argument.
- Dynamic chart inclusion via an allowlist token map
(`metric:BLOOD_PRESSURE_SYS` → `<HealthChart>` inline). Out of scope
for this PR.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
This commit is contained in:
1 parent
f131d6be2c
commit
751448494b
9 files changed
+527
-91
No files matched your search
@@ -602,6 +602,10 @@
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
"findingsTitle": "Befunde",
|
||||
"heroFindingPositive": "Wichtigster Befund",
|
||||
"heroFindingNeutral": "Wichtigster Befund",
|
||||
"heroFindingAttention": "Beachten",
|
||||
"heroFindingWarning": "Wichtigster Alarm",
|
||||
"correlationsTitle": "Zusammenhänge",
|
||||
"recommendationsTitle": "Empfehlungen",
|
||||
"dataFoundation": "Datengrundlage",
|
||||
|
||||
@@ -602,6 +602,10 @@
|
||||
"refreshAnalysis": "Refresh analysis",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
"findingsTitle": "Findings",
|
||||
"heroFindingPositive": "Top finding",
|
||||
"heroFindingNeutral": "Top finding",
|
||||
"heroFindingAttention": "Worth attention",
|
||||
"heroFindingWarning": "Top alert",
|
||||
"correlationsTitle": "Correlations",
|
||||
"recommendationsTitle": "Recommendations",
|
||||
"dataFoundation": "Data coverage",
|
||||
|
||||
@@ -34,36 +34,34 @@ interface InsightAdvisorCardProps {
|
||||
|
||||
// ─── Classification Colors ───────────────────────────────
|
||||
|
||||
const CLASSIFICATION_STYLES: Record<
|
||||
string,
|
||||
{ badge: string; border: string }
|
||||
> = {
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
const CLASSIFICATION_STYLES: Record<string, { badge: string; border: string }> =
|
||||
{
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assessment Icons ─────────────────────────────────────
|
||||
|
||||
@@ -74,16 +72,91 @@ function AssessmentIcon({
|
||||
}) {
|
||||
switch (assessment) {
|
||||
case "positive":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-dracula-green" />;
|
||||
return <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
|
||||
case "neutral":
|
||||
return <Minus className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
|
||||
case "attention":
|
||||
return <AlertCircle className="h-4 w-4 shrink-0 text-dracula-orange" />;
|
||||
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 shrink-0 text-dracula-red" />;
|
||||
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero Finding ─────────────────────────────────────────
|
||||
|
||||
const HERO_STYLES: Record<
|
||||
InsightFinding["assessment"],
|
||||
{ wrapper: string; icon: string; label: string }
|
||||
> = {
|
||||
positive: {
|
||||
wrapper:
|
||||
"from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green",
|
||||
icon: "text-dracula-green",
|
||||
label: "text-dracula-green",
|
||||
},
|
||||
neutral: {
|
||||
wrapper:
|
||||
"from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan",
|
||||
icon: "text-dracula-cyan",
|
||||
label: "text-dracula-cyan",
|
||||
},
|
||||
attention: {
|
||||
wrapper:
|
||||
"from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange",
|
||||
icon: "text-dracula-orange",
|
||||
label: "text-dracula-orange",
|
||||
},
|
||||
warning: {
|
||||
wrapper:
|
||||
"from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red",
|
||||
icon: "text-dracula-red",
|
||||
label: "text-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
const { t } = useTranslations();
|
||||
const style = HERO_STYLES[finding.assessment];
|
||||
const labelKey =
|
||||
finding.assessment === "positive"
|
||||
? "insights.heroFindingPositive"
|
||||
: finding.assessment === "warning"
|
||||
? "insights.heroFindingWarning"
|
||||
: finding.assessment === "attention"
|
||||
? "insights.heroFindingAttention"
|
||||
: "insights.heroFindingNeutral";
|
||||
return (
|
||||
<div
|
||||
data-slot="insight-hero-finding"
|
||||
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug font-medium">{finding.label}</p>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Confidence Colors ────────────────────────────────────
|
||||
|
||||
const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
@@ -102,9 +175,9 @@ const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider font-medium text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
||||
<span>{label}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +199,8 @@ export function InsightAdvisorCard({
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
const classStyle = insight
|
||||
? CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut
|
||||
? (CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut)
|
||||
: CLASSIFICATION_STYLES.gut;
|
||||
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
@@ -135,8 +208,8 @@ export function InsightAdvisorCard({
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-dracula-purple" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
{t("insights.generating")}
|
||||
</span>
|
||||
</CardContent>
|
||||
@@ -151,26 +224,24 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
@@ -202,9 +273,7 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
@@ -231,13 +300,13 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -245,8 +314,8 @@ export function InsightAdvisorCard({
|
||||
|
||||
{/* Primary Recommendation */}
|
||||
{insight.primaryRecommendation && (
|
||||
<div className="rounded-md border-l-2 border-dracula-purple bg-dracula-purple/5 px-4 py-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-dracula-purple">
|
||||
<div className="border-dracula-purple bg-dracula-purple/5 rounded-md border-l-2 px-4 py-3">
|
||||
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
|
||||
{t("insights.keyTakeaway")}
|
||||
</p>
|
||||
<p className="text-sm">{insight.primaryRecommendation}</p>
|
||||
@@ -254,34 +323,42 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{insight.summary}
|
||||
</p>
|
||||
|
||||
{/* Findings */}
|
||||
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
|
||||
{insight.findings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.findingsTitle")} />
|
||||
<div className="space-y-2">
|
||||
{insight.findings.map((finding, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">{finding.label}</span>
|
||||
{/* v1.4 — pull the first finding into a severity-tinted hero
|
||||
* card. The model is instructed to put the most clinically
|
||||
* relevant finding first; we let the UI honour that by giving
|
||||
* it more weight than the secondary findings.
|
||||
*/}
|
||||
<HeroFinding finding={insight.findings[0]} />
|
||||
{insight.findings.length > 1 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{insight.findings.slice(1).map((finding, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">{finding.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="ml-6 mt-0.5 text-xs text-muted-foreground">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -293,7 +370,7 @@ export function InsightAdvisorCard({
|
||||
{insight.correlations.map((corr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs"
|
||||
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span>{corr.factor}</span>
|
||||
<span className="text-muted-foreground">↔</span>
|
||||
@@ -316,7 +393,7 @@ export function InsightAdvisorCard({
|
||||
<ol className="space-y-1.5 pl-0">
|
||||
{insight.recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span className="shrink-0 font-medium text-muted-foreground">
|
||||
<span className="text-muted-foreground shrink-0 font-medium">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{rec}</span>
|
||||
@@ -332,7 +409,7 @@ export function InsightAdvisorCard({
|
||||
<button
|
||||
onClick={() => setDataQualityOpen((v) => !v)}
|
||||
aria-expanded={dataQualityOpen}
|
||||
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
|
||||
>
|
||||
{dataQualityOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
@@ -355,7 +432,7 @@ export function InsightAdvisorCard({
|
||||
</button>
|
||||
|
||||
{dataQualityOpen && (
|
||||
<div className="mt-2 space-y-2 rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground animate-insight-in">
|
||||
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
|
||||
<p>{insight.dataQuality.coverage}</p>
|
||||
{insight.dataQuality.gaps.length > 0 && (
|
||||
<div>
|
||||
@@ -373,17 +450,15 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
|
||||
{cachedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-1.5 pt-1">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{insight.disclaimer}
|
||||
</p>
|
||||
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
|
||||
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -68,19 +68,24 @@ export function getBloodPressureUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
previousContextBlock?: string,
|
||||
): string {
|
||||
const ctxBlock =
|
||||
previousContextBlock && previousContextBlock.trim().length > 0
|
||||
? `\n\n${previousContextBlock}\n`
|
||||
: "";
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the following blood-pressure data with focus on trends, target attainment and medication effectiveness.
|
||||
Account for measurement timing and density when judging confidence.
|
||||
Use the precomputed correlations and historicalComparison for a sound temporal assessment.
|
||||
Use the precomputed correlations and historicalComparison for a sound temporal assessment.${ctxBlock}
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
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.
|
||||
Nutze die vorberechneten Korrelationen (correlations) und den historischen Vergleich (historicalComparison) für eine fundierte temporale Analyse.${ctxBlock}
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
@@ -46,17 +46,26 @@ export function getGeneralStatusUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
previousContextBlock?: string,
|
||||
): string {
|
||||
// v1.4: when the previous-analysis context block is supplied, the
|
||||
// model is instructed to call out improvements / regressions
|
||||
// explicitly. Block already includes the comparison instruction,
|
||||
// so we just inject it ahead of the snapshot.
|
||||
const ctxBlock =
|
||||
previousContextBlock && previousContextBlock.trim().length > 0
|
||||
? `\n\n${previousContextBlock}\n`
|
||||
: "";
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Produce a comprehensive overall assessment across every available health metric.
|
||||
Focus on the interplay between parameters and identify the single most important call to action.
|
||||
Focus on the interplay between parameters and identify the single most important call to action.${ctxBlock}
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Erstelle eine umfassende Gesamtbewertung aller verfügbaren Gesundheitsdaten.
|
||||
Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere die wichtigste Handlungsempfehlung.
|
||||
Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere die wichtigste Handlungsempfehlung.${ctxBlock}
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
} from "../memory";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockReset();
|
||||
});
|
||||
|
||||
describe("getPreviousInsightContext", () => {
|
||||
it("returns null when no audit row exists", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null);
|
||||
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the audit row's details cannot be parsed", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: new Date(),
|
||||
details: "",
|
||||
} as never);
|
||||
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts text from the JSON-shaped cache payload", async () => {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: sevenDaysAgo,
|
||||
details: JSON.stringify({
|
||||
dateKey: "2026-05-01",
|
||||
text: "Your BP averaged 130/80 — high-normal.",
|
||||
}),
|
||||
} as never);
|
||||
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.ageDays).toBe(7);
|
||||
expect(ctx!.text).toContain("130/80");
|
||||
});
|
||||
|
||||
it("falls back to raw audit details when not JSON", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
details: "Plain text from a legacy cache row.",
|
||||
} as never);
|
||||
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
|
||||
expect(ctx?.text).toBe("Plain text from a legacy cache row.");
|
||||
});
|
||||
|
||||
it("caps the text length so a verbose snapshot cannot bloat the prompt", async () => {
|
||||
const big = "x".repeat(5000);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
details: JSON.stringify({ text: big }),
|
||||
} as never);
|
||||
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
|
||||
expect(ctx!.text.length).toBeLessThanOrEqual(1502); // 1500 + "…"
|
||||
expect(ctx!.text.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by minAgeHours so today's earlier run isn't treated as 'previous'", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null);
|
||||
await getPreviousInsightContext("u-1", "general-status", "en", 24);
|
||||
const call = vi.mocked(prisma.auditLog.findFirst).mock.calls[0]?.[0];
|
||||
expect(call?.where?.action).toBe("insights.general-status.en");
|
||||
// The lt filter exists with a date in the past. It can't be more
|
||||
// than 24h ago because we just constructed it; allow a 30s slack.
|
||||
const lt = (call?.where?.createdAt as { lt: Date }).lt;
|
||||
expect(Date.now() - lt.getTime()).toBeGreaterThan(
|
||||
24 * 60 * 60 * 1000 - 30_000,
|
||||
);
|
||||
expect(Date.now() - lt.getTime()).toBeLessThan(
|
||||
24 * 60 * 60 * 1000 + 30_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPreviousContextForPrompt", () => {
|
||||
it("returns a 'no history' instruction when ctx is null (English)", () => {
|
||||
const out = formatPreviousContextForPrompt(null, "en");
|
||||
expect(out).toContain("PREVIOUS ANALYSIS: none on file");
|
||||
expect(out).toContain("no improvement/regression delta to surface");
|
||||
});
|
||||
|
||||
it("returns a 'no history' instruction when ctx is null (German)", () => {
|
||||
const out = formatPreviousContextForPrompt(null, "de");
|
||||
expect(out).toContain("VORHERIGE ANALYSE: keine vorhanden");
|
||||
});
|
||||
|
||||
it("formats a 7-days-ago context with date + text + comparison instruction (English)", () => {
|
||||
const out = formatPreviousContextForPrompt(
|
||||
{
|
||||
generatedAt: "2026-05-01T08:00:00.000Z",
|
||||
ageDays: 7,
|
||||
text: "Your BP averaged 130/80 last week.",
|
||||
},
|
||||
"en",
|
||||
);
|
||||
expect(out).toContain("PREVIOUS ANALYSIS (7 days ago, 2026-05-01)");
|
||||
expect(out).toContain("Your BP averaged 130/80 last week.");
|
||||
expect(out).toContain('"down 4 mmHg from your last check"');
|
||||
});
|
||||
|
||||
it("formats a same-day context (German)", () => {
|
||||
const out = formatPreviousContextForPrompt(
|
||||
{
|
||||
generatedAt: "2026-05-08T06:00:00.000Z",
|
||||
ageDays: 0,
|
||||
text: "Frühanalyse: 130/80 grenzwertig.",
|
||||
},
|
||||
"de",
|
||||
);
|
||||
expect(out).toContain("VORHERIGE ANALYSE (heute früher, 2026-05-08)");
|
||||
expect(out).toContain("Frühanalyse: 130/80 grenzwertig.");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { getBloodPressureSystemPrompt, getBloodPressureUserPrompt } from "@/lib/ai/prompts/blood-pressure";
|
||||
import {
|
||||
getBloodPressureSystemPrompt,
|
||||
getBloodPressureUserPrompt,
|
||||
} from "@/lib/ai/prompts/blood-pressure";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
} from "@/lib/insights/memory";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
pearsonCorrelation,
|
||||
@@ -453,7 +460,10 @@ export async function generateBloodPressureStatusForUser(
|
||||
latest: dailyMoodSeries.at(-1)?.value ?? null,
|
||||
series: dailyMoodSeries.slice(-10),
|
||||
moodVsSystolicCorrelation: (() => {
|
||||
const moodVsSysPairs = pairDailySeries(dailyMoodSeries, sysSeries);
|
||||
const moodVsSysPairs = pairDailySeries(
|
||||
dailyMoodSeries,
|
||||
sysSeries,
|
||||
);
|
||||
return pearsonCorrelation(moodVsSysPairs);
|
||||
})(),
|
||||
}
|
||||
@@ -462,9 +472,25 @@ export async function generateBloodPressureStatusForUser(
|
||||
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const previousContext = await getPreviousInsightContext(
|
||||
userId,
|
||||
"blood-pressure-status",
|
||||
locale,
|
||||
12,
|
||||
);
|
||||
const previousContextBlock = formatPreviousContextForPrompt(
|
||||
previousContext,
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(snapshotJson, todayKey, locale),
|
||||
userPrompt: getBloodPressureUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt } from "@/lib/ai/prompts/general-status";
|
||||
import {
|
||||
getGeneralStatusSystemPrompt,
|
||||
getGeneralStatusUserPrompt,
|
||||
} from "@/lib/ai/prompts/general-status";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
} from "@/lib/insights/memory";
|
||||
|
||||
const GENERAL_STATUS_POINTS = 30;
|
||||
|
||||
@@ -350,9 +357,28 @@ export async function generateGeneralStatusForUser(
|
||||
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
// v1.4: pull the previous cached general-status into the prompt so
|
||||
// the model can compare to the user's last analysis. Falls back
|
||||
// gracefully when there's no history (first-run users).
|
||||
const previousContext = await getPreviousInsightContext(
|
||||
userId,
|
||||
"general-status",
|
||||
locale,
|
||||
12,
|
||||
);
|
||||
const previousContextBlock = formatPreviousContextForPrompt(
|
||||
previousContext,
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: getGeneralStatusUserPrompt(snapshotJson, todayKey, locale),
|
||||
userPrompt: getGeneralStatusUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Insight memory — what the AI told this user last time.
|
||||
*
|
||||
* Every status generator (general / blood-pressure / weight / pulse /
|
||||
* bmi / medication-compliance / mood) caches its rendered text in an
|
||||
* `audit_logs` row keyed by `action = "insights.{scope}.{locale}"`.
|
||||
* v1.4 turns that history into prompt context: the next generation
|
||||
* sees the previous classification + a short summary, so the model
|
||||
* can call out improvements and regressions instead of restating
|
||||
* status from scratch every day.
|
||||
*
|
||||
* Two consumers share this helper:
|
||||
*
|
||||
* - The status-generator pipeline reads the previous snapshot via
|
||||
* `getPreviousInsightContext()` and injects it into the user prompt.
|
||||
* - The card UI reads the `delta` flag derived in
|
||||
* `compareSnapshots()` and renders ↑/↓/→ glyphs next to findings.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Stable identifiers for the seven insight scopes. The string MUST
|
||||
* match the suffix in the existing audit-log `action` field so we
|
||||
* read the same rows the cache already writes.
|
||||
*/
|
||||
export type InsightScope =
|
||||
| "general-status"
|
||||
| "blood-pressure-status"
|
||||
| "weight-status"
|
||||
| "pulse-status"
|
||||
| "bmi-status"
|
||||
| "medication-compliance-status"
|
||||
| "mood-status";
|
||||
|
||||
export interface PreviousInsightContext {
|
||||
/** ISO timestamp the previous analysis was rendered. */
|
||||
generatedAt: string;
|
||||
/** Days between the previous analysis and now (rounded down). */
|
||||
ageDays: number;
|
||||
/**
|
||||
* Free-text snapshot of the previous analysis — typically the
|
||||
* `summary` field from `InsightResult` or the rendered narrative.
|
||||
* Already in the user's locale.
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the most recent cached insight for `userId` + `scope` + `locale`
|
||||
* that is at least `minAgeHours` old. Returns null when no eligible
|
||||
* snapshot exists (first-run users) or when the cache row's payload
|
||||
* is malformed.
|
||||
*
|
||||
* The default 12-hour floor keeps the model from comparing today's
|
||||
* 09:00 reading to today's 06:00 reading and treating sub-day noise as
|
||||
* a real change. Status generators pass shorter floors when force-
|
||||
* regenerating.
|
||||
*/
|
||||
export async function getPreviousInsightContext(
|
||||
userId: string,
|
||||
scope: InsightScope,
|
||||
locale: "de" | "en",
|
||||
minAgeHours: number = 12,
|
||||
): Promise<PreviousInsightContext | null> {
|
||||
const action = `insights.${scope}.${locale}`;
|
||||
const olderThan = new Date(Date.now() - minAgeHours * 60 * 60 * 1000);
|
||||
|
||||
const row = await prisma.auditLog.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
action,
|
||||
createdAt: { lt: olderThan },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, details: true },
|
||||
});
|
||||
|
||||
if (!row?.details) return null;
|
||||
|
||||
let text: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(row.details) as {
|
||||
text?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
if (typeof parsed.text === "string" && parsed.text.trim().length > 0) {
|
||||
text = parsed.text;
|
||||
} else if (
|
||||
typeof parsed.summary === "string" &&
|
||||
parsed.summary.trim().length > 0
|
||||
) {
|
||||
text = parsed.summary;
|
||||
}
|
||||
} catch {
|
||||
// Older cache rows stored raw text instead of JSON. Surface them
|
||||
// verbatim so the prompt still benefits from the comparison.
|
||||
if (row.details.trim().length > 0) {
|
||||
text = row.details;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const ageMs = Date.now() - row.createdAt.getTime();
|
||||
const ageDays = Math.max(0, Math.floor(ageMs / (24 * 60 * 60 * 1000)));
|
||||
|
||||
return {
|
||||
generatedAt: row.createdAt.toISOString(),
|
||||
ageDays,
|
||||
// Cap text so a verbose previous analysis cannot bloat the prompt.
|
||||
text: text.length > 1500 ? text.slice(0, 1500) + "…" : text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a previous-context block for direct injection into the user
|
||||
* prompt. Output already locale-aware.
|
||||
*
|
||||
* Example (de):
|
||||
* "VORHERIGE ANALYSE (vor 7 Tagen, 2026-05-01):
|
||||
* Dein Blutdruck war im Schnitt 132/85 — grenzwertig. …"
|
||||
*/
|
||||
export function formatPreviousContextForPrompt(
|
||||
ctx: PreviousInsightContext | null,
|
||||
locale: "de" | "en",
|
||||
): string {
|
||||
if (!ctx) {
|
||||
return locale === "en"
|
||||
? "PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface."
|
||||
: "VORHERIGE ANALYSE: keine vorhanden. Behandle dies als erste Analyse in diesem Bereich — kein Verbesserungs-/Verschlechterungs-Delta zu nennen.";
|
||||
}
|
||||
|
||||
const dateLabel = ctx.generatedAt.slice(0, 10);
|
||||
if (locale === "en") {
|
||||
const ageLabel =
|
||||
ctx.ageDays === 0
|
||||
? "earlier today"
|
||||
: ctx.ageDays === 1
|
||||
? "1 day ago"
|
||||
: `${ctx.ageDays} days ago`;
|
||||
return [
|
||||
`PREVIOUS ANALYSIS (${ageLabel}, ${dateLabel}):`,
|
||||
ctx.text,
|
||||
"",
|
||||
'INSTRUCTION: When summarising the current state, explicitly call out what improved, what regressed, and what stayed the same compared to that previous snapshot. Use phrases like "down 4 mmHg from your last check" or "same elevated trend as last week". Do not invent numbers — only compare what\'s in the snapshot below.',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const ageLabel =
|
||||
ctx.ageDays === 0
|
||||
? "heute früher"
|
||||
: ctx.ageDays === 1
|
||||
? "vor 1 Tag"
|
||||
: `vor ${ctx.ageDays} Tagen`;
|
||||
return [
|
||||
`VORHERIGE ANALYSE (${ageLabel}, ${dateLabel}):`,
|
||||
ctx.text,
|
||||
"",
|
||||
'ANWEISUNG: Wenn du den aktuellen Stand zusammenfasst, benenne explizit was sich verbessert hat, was schlechter geworden ist und was gleich geblieben ist gegenüber dieser vorherigen Analyse. Verwende Formulierungen wie "4 mmHg niedriger als beim letzten Check" oder "gleicher erhöhter Trend wie letzte Woche". Erfinde keine Zahlen — vergleiche nur das, was im Snapshot unten steht.',
|
||||
].join("\n");
|
||||
}
|
||||
Reference in new issue
Block a user