From 751448494b343a15cb66f02ed8f0de038c40155e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:30:31 +0200 Subject: [PATCH] feat(insights): AI insights remember the previous analysis + hero finding card (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` → `` inline). Out of scope for this PR. Co-authored-by: Marc-André Bombeck --- messages/de.json | 4 + messages/en.json | 4 + .../insights/insight-advisor-card.tsx | 239 ++++++++++++------ src/lib/ai/prompts/blood-pressure.ts | 9 +- src/lib/ai/prompts/general-status.ts | 13 +- src/lib/insights/__tests__/memory.test.ts | 125 +++++++++ src/lib/insights/blood-pressure-status.ts | 32 ++- src/lib/insights/general-status.ts | 30 ++- src/lib/insights/memory.ts | 162 ++++++++++++ 9 files changed, 527 insertions(+), 91 deletions(-) create mode 100644 src/lib/insights/__tests__/memory.test.ts create mode 100644 src/lib/insights/memory.ts diff --git a/messages/de.json b/messages/de.json index 2f6ef4df5..0b60b59cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 1abc97b46..73d1370e8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index 092f8fc03..a5df4a455 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -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 = + { + 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 ; + return ; case "neutral": - return ; + return ; case "attention": - return ; + return ; case "warning": - return ; + return ; } } +// ─── 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 ( +
+
+
+
+ +
+
+

+ {t(labelKey)} +

+

{finding.label}

+ {finding.guideline && ( +

+ {finding.guideline} +

+ )} +
+
+ + {finding.value} + +
+
+ ); +} + // ─── Confidence Colors ──────────────────────────────────── const CONFIDENCE_STYLES: Record = { @@ -102,9 +175,9 @@ const CONFIDENCE_LABEL_KEYS: Record = { function SectionSeparator({ label }: { label: string }) { return ( -
+
{label} -
+
); } @@ -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 ( - - + + {t("insights.generating")} @@ -151,26 +224,24 @@ export function InsightAdvisorCard({
- {icon ?? ( - - )} + {icon ?? } {t("insights.aiAnalysisTitle")}
-

{title}

+

{title}

{error && ( -
+
{error}
)} {!error && (
-

+

{t("insights.noAnalysisYet")}

{onRegenerate && ( @@ -202,9 +273,7 @@ export function InsightAdvisorCard({
- {icon ?? ( - - )} + {icon ?? } {t("insights.aiAnalysisTitle")} @@ -231,13 +300,13 @@ export function InsightAdvisorCard({ )}
-

{title}

+

{title}

{/* Error banner */} {error && ( -
+
{error}
@@ -245,8 +314,8 @@ export function InsightAdvisorCard({ {/* Primary Recommendation */} {insight.primaryRecommendation && ( -
-

+

+

{t("insights.keyTakeaway")}

{insight.primaryRecommendation}

@@ -254,34 +323,42 @@ export function InsightAdvisorCard({ )} {/* Summary */} -

+

{insight.summary}

- {/* Findings */} + {/* Findings — top finding gets a hero treatment, rest are a compact list */} {insight.findings.length > 0 && (
-
- {insight.findings.map((finding, i) => ( -
-
-
- - {finding.label} + {/* 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. + */} + + {insight.findings.length > 1 && ( +
+ {insight.findings.slice(1).map((finding, i) => ( +
+
+
+ + {finding.label} +
+ + {finding.value} +
- - {finding.value} - + {finding.guideline && ( +

+ {finding.guideline} +

+ )}
- {finding.guideline && ( -

- {finding.guideline} -

- )} -
- ))} -
+ ))} +
+ )}
)} @@ -293,7 +370,7 @@ export function InsightAdvisorCard({ {insight.correlations.map((corr, i) => (
{corr.factor} ↔ @@ -316,7 +393,7 @@ export function InsightAdvisorCard({
    {insight.recommendations.map((rec, i) => (
  1. - + {i + 1}. {rec} @@ -332,7 +409,7 @@ export function InsightAdvisorCard({ {dataQualityOpen && ( -
    +

    {insight.dataQuality.coverage}

    {insight.dataQuality.gaps.length > 0 && (
    @@ -373,17 +450,15 @@ export function InsightAdvisorCard({ )} {cachedAt && ( -

    +

    {t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}

    )} {/* Disclaimer */}
    - -

    - {insight.disclaimer} -

    + +

    {insight.disclaimer}

    diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 05198bd41..22316c808 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -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}`; } diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index a267c5487..06d0635dc 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -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}`; } diff --git a/src/lib/insights/__tests__/memory.test.ts b/src/lib/insights/__tests__/memory.test.ts new file mode 100644 index 000000000..1b1317078 --- /dev/null +++ b/src/lib/insights/__tests__/memory.test.ts @@ -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."); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 74fd7e434..2fd7a7023 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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, }); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 765b3458c..0ea21c1c8 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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, }); diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts new file mode 100644 index 000000000..b9277dd69 --- /dev/null +++ b/src/lib/insights/memory.ts @@ -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 { + 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"); +}