mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
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.");
|
|
});
|
|
});
|