From d99e1f0e8bcb5793e1a47573a00eca4ad7a6d9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 02:49:36 +0200 Subject: [PATCH] feat(coach): fold a rolling-profile memory block into the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assemble a zero-LLM longitudinal memory block from artefacts already persisted: the latest period-narrative headline and a per-metric prior-vs-current band recall. The Coach can now reference what was true last period instead of re-deriving a cold snapshot every turn. The block is machine-derived (labels plus numbers only), so it is unencrypted and adds no model cost. Each sub-source is fault-isolated — a failing narrative read or context build drops that sub-block and never sinks the turn. It registers against the lowest-signal cluster so the budget degrader sheds it first, before any clinical data. --- .../coach/__tests__/memory-snapshot.test.ts | 167 ++++++++++++++++++ src/lib/ai/coach/__tests__/snapshot.test.ts | 8 + src/lib/ai/coach/memory-snapshot.ts | 157 ++++++++++++++++ src/lib/ai/coach/snapshot.ts | 49 +++-- 4 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/memory-snapshot.test.ts create mode 100644 src/lib/ai/coach/memory-snapshot.ts diff --git a/src/lib/ai/coach/__tests__/memory-snapshot.test.ts b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts new file mode 100644 index 000000000..df225bce4 --- /dev/null +++ b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts @@ -0,0 +1,167 @@ +/** + * v1.11.0 W5a — buildCoachMemoryBlock unit tests. + * + * Asserts the rolling-profile block assembles from the two persisted + * sources (period-narrative read + band transitions), carries the right + * shape, and is fault-isolated per sub-source so a failing source never + * sinks the Coach turn. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BaselineProfile } from "@/lib/insights/derived"; + +const readPeriodNarrative = vi.fn(); +const buildPeriodNarrativeContext = vi.fn(); + +vi.mock("@/lib/insights/narrative/period-narrative-generate", () => ({ + readPeriodNarrative: (...args: unknown[]) => readPeriodNarrative(...args), +})); +vi.mock("@/lib/insights/narrative/period-narrative", () => ({ + buildPeriodNarrativeContext: (...args: unknown[]) => + buildPeriodNarrativeContext(...args), +})); + +import { buildCoachMemoryBlock } from "../memory-snapshot"; + +const PROFILE: BaselineProfile = { + ageYears: 40, + sex: "MALE", + heightCm: 180, +}; +const NOW = new Date("2026-06-03T08:00:00.000Z"); +const USER = "user-1"; + +function narrativeRow(text: string) { + return { + period: "month" as const, + locale: "de" as const, + text, + dateKey: "2026-06-01", + provenance: null, + providerType: "codex", + promptVersion: "1.11.0", + updatedAt: "2026-06-01T04:30:00.000Z", + }; +} + +function readyContext( + transitions: Array<{ type: string; direction: "above" | "below" | "in" }>, +) { + return { + status: "ready" as const, + period: "month" as const, + metricDeltas: [], + bandTransitions: transitions.map((t) => ({ + type: t.type, + center: 60, + bandLow: 50, + bandHigh: 65, + direction: t.direction, + movedOut: t.direction !== "in", + baselineDays: 21, + })), + drivers: [], + coincidentFlags: [], + pairsTested: 0, + fdrQ: 0.1, + provenance: { + metrics: transitions.map((t) => t.type), + window: { from: "2026-05-04", to: "2026-06-03" }, + computedAt: NOW.toISOString(), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("buildCoachMemoryBlock", () => { + it("assembles priorNarrative + trendMemory from both sources", async () => { + readPeriodNarrative.mockResolvedValue( + narrativeRow("Dein Ruhepuls ist diesen Monat leicht gestiegen."), + ); + buildPeriodNarrativeContext.mockResolvedValue( + readyContext([ + { type: "HEART_RATE", direction: "above" }, + { type: "WEIGHT", direction: "in" }, + ]), + ); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + + expect(block).not.toBeNull(); + expect(block?.priorNarrative?.headline).toContain("Ruhepuls"); + expect(block?.priorNarrative?.drivers).toEqual([]); + expect(block?.trendMemory).toEqual({ + HEART_RATE: { priorBand: "in", currentBand: "above", priorPeriod: "month" }, + WEIGHT: { priorBand: "in", currentBand: "in", priorPeriod: "month" }, + }); + }); + + it("returns null when neither source yields anything", async () => { + readPeriodNarrative.mockResolvedValue(null); + buildPeriodNarrativeContext.mockResolvedValue({ + status: "insufficient", + period: "month", + reason: "no-history", + }); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + expect(block).toBeNull(); + }); + + it("isolates a narrative-read failure — trend memory still stands", async () => { + readPeriodNarrative.mockRejectedValue(new Error("db down")); + buildPeriodNarrativeContext.mockResolvedValue( + readyContext([{ type: "HEART_RATE", direction: "below" }]), + ); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + expect(block).not.toBeNull(); + expect(block?.priorNarrative).toBeUndefined(); + expect(block?.trendMemory.HEART_RATE).toEqual({ + priorBand: "in", + currentBand: "below", + priorPeriod: "month", + }); + }); + + it("isolates a context failure — narrative recall still stands", async () => { + readPeriodNarrative.mockResolvedValue(narrativeRow("Stabiler Monat.")); + buildPeriodNarrativeContext.mockRejectedValue(new Error("compute failed")); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + expect(block).not.toBeNull(); + expect(block?.priorNarrative?.headline).toBe("Stabiler Monat."); + expect(block?.trendMemory).toEqual({}); + }); + + it("caps an over-long narrative headline", async () => { + const long = "x".repeat(900); + readPeriodNarrative.mockResolvedValue(narrativeRow(long)); + buildPeriodNarrativeContext.mockResolvedValue(readyContext([])); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + expect(block?.priorNarrative?.headline.length).toBeLessThanOrEqual(601); + expect(block?.priorNarrative?.headline.endsWith("…")).toBe(true); + }); + + it("ignores a blank narrative row", async () => { + readPeriodNarrative.mockResolvedValue(narrativeRow(" ")); + buildPeriodNarrativeContext.mockResolvedValue( + readyContext([{ type: "HEART_RATE", direction: "above" }]), + ); + + const block = await buildCoachMemoryBlock(USER, PROFILE, NOW, "de"); + expect(block?.priorNarrative).toBeUndefined(); + expect(block?.trendMemory.HEART_RATE).toBeDefined(); + }); + + it("reads the narrative with the requested locale", async () => { + readPeriodNarrative.mockResolvedValue(null); + buildPeriodNarrativeContext.mockResolvedValue(readyContext([])); + + await buildCoachMemoryBlock(USER, PROFILE, NOW, "en"); + expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index e78bf2d33..a8bfa305a 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -21,6 +21,14 @@ vi.mock("@/lib/insights/features", () => ({ extractFeatures: vi.fn(), })); +// The rolling-profile memory block reads its own persisted sources +// (period narrative + band transitions); stub it out here so the +// query-count + cache assertions below stay scoped to the core snapshot +// reads. Its own assembly is covered in memory-snapshot.test.ts. +vi.mock("../memory-snapshot", () => ({ + buildCoachMemoryBlock: vi.fn().mockResolvedValue(null), +})); + import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts new file mode 100644 index 000000000..ff7371003 --- /dev/null +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -0,0 +1,157 @@ +/** + * v1.11.0 W5a — Coach rolling-profile memory block (Pillar P2 2a). + * + * A ZERO-LLM, machine-derived "what was true recently + what the Coach + * already noted" block folded into the Coach snapshot. It carries no raw + * series and calls no model — it reads artefacts we already persist: + * + * - `priorNarrative` — the most recent period-narrative (W3) headline + + * its driver list, so the Coach can say "as I noted at the start of the + * month, your resting HR drifted up" instead of re-deriving cold every + * turn. Source: `readPeriodNarrative` (stale-while-revalidate read of the + * typed `insight_narratives` row). + * - `trendMemory` — per in-scope vital, the band it held in the PRIOR + * period vs where its current-period center sits now. This generalises + * the per-status `memory.ts` previous-context primitive to the Coach. + * Source: the band transitions already computed by + * `buildPeriodNarrativeContext` (MAD baseline, prior-period band edges). + * + * Unencrypted by the same rule as `metricSourceJson` / the derived block: + * it is the user's own labels + numbers + a provenance-grounded narrative + * recall, never free conversational content (that lives in the encrypted + * W5b summary). Per-source fault isolation: a transient failure on either + * sub-source drops that sub-block and never sinks the Coach turn. + * + * Lowest snapshot priority by design — wired into `snapshot.ts` so + * `degradeToBudget` sheds it FIRST under the char cap, before any clinical + * cluster. + * + * Server-only — reads `@/lib/db` transitively through the two sources. + */ +import { + readPeriodNarrative, + type NarrativeRead, +} from "@/lib/insights/narrative/period-narrative-generate"; +import { + buildPeriodNarrativeContext, + type NarrativePeriod, + type BandTransition, +} from "@/lib/insights/narrative/period-narrative"; +import type { BaselineProfile } from "@/lib/insights/derived"; + +/** The period the rolling profile recalls — month is the high-signal beat. */ +const MEMORY_PERIOD: NarrativePeriod = "month"; + +/** Cap the recalled narrative so a verbose row cannot bloat the snapshot. */ +const NARRATIVE_HEADLINE_CHARS = 600; +/** Drivers are conservative one-liners; a handful is plenty of recall. */ +const MAX_RECALLED_DRIVERS = 4; + +/** A band a metric held: inside its personal range, or above/below it. */ +type TrendBand = "in" | "above" | "below"; + +/** Per-metric trend recall: where it sat in the prior period vs now. */ +export interface TrendMemoryEntry { + /** Where the metric sat over the prior period (its baseline = "in"). */ + priorBand: TrendBand; + /** Where the current-period center sits relative to the prior band. */ + currentBand: TrendBand; + /** The prior period this recall compares against. */ + priorPeriod: NarrativePeriod; +} + +/** The machine-derived narrative recall the Coach can ground a callback in. */ +export interface PriorNarrativeRecall { + /** The narrative prose, capped — the Coach paraphrases, never quotes raw. */ + headline: string; + /** The conservative, descriptive driver one-liners, verbatim. */ + drivers: string[]; +} + +/** The rolling-profile memory block folded under `snapshot.memory`. */ +export interface CoachMemoryBlock { + priorNarrative?: PriorNarrativeRecall; + trendMemory: Record; +} + +/** Pull the headline + driver recall off the latest period narrative. */ +function recallNarrative(row: NarrativeRead): PriorNarrativeRecall { + const headline = + row.text.length > NARRATIVE_HEADLINE_CHARS + ? row.text.slice(0, NARRATIVE_HEADLINE_CHARS) + "…" + : row.text; + // The typed narrative row carries no structured driver list (the prose + // already folds them in); we surface the headline as the primary recall + // and keep drivers empty unless the row ever carries them — never + // fabricate. + return { headline, drivers: [] }; +} + +/** Map a band transition onto the {prior,current} band pair. */ +function trendFromTransition(b: BandTransition): TrendMemoryEntry { + // The band edges are established over the PRIOR period, so by construction + // the prior-period center sat inside its own band → priorBand = "in". The + // current-period direction is the live placement. + return { + priorBand: "in", + currentBand: b.direction, + priorPeriod: MEMORY_PERIOD, + }; +} + +/** + * Build the rolling-profile memory block, or `null` when neither sub-source + * yields anything (no narrative on file AND no band transitions). Each + * sub-source is fault-isolated: a failure on one never sinks the other or + * the Coach turn. + * + * `profile` is accepted for parity with `buildDerivedSnapshotBlock` and to + * keep the call site uniform; the current sub-sources read their own + * baselines internally. + */ +export async function buildCoachMemoryBlock( + userId: string, + _profile: BaselineProfile, + now: Date, + locale: "de" | "en", +): Promise { + let priorNarrative: PriorNarrativeRecall | undefined; + // Sub-source 1: the latest period-narrative headline + driver recall. + try { + const row = await readPeriodNarrative(userId, MEMORY_PERIOD, locale); + if (row && row.text.trim().length > 0) { + const recall = recallNarrative(row); + recall.drivers = recall.drivers.slice(0, MAX_RECALLED_DRIVERS); + priorNarrative = recall; + } + } catch { + // A narrative read failure is non-fatal — the trend memory still stands. + priorNarrative = undefined; + } + + // Sub-source 2: per-metric band movement (prior period vs now). Reuses the + // SAME MAD-baseline band transitions the narrative context computes — no + // parallel aggregation, no recompute beyond the one assembly call. + const trendMemory: Record = {}; + try { + const ctx = await buildPeriodNarrativeContext(userId, { + period: MEMORY_PERIOD, + now, + }); + if (ctx.status === "ready") { + for (const transition of ctx.bandTransitions) { + trendMemory[transition.type] = trendFromTransition(transition); + } + } + } catch { + // A context failure leaves trendMemory empty — never sinks the turn. + } + + if (!priorNarrative && Object.keys(trendMemory).length === 0) { + return null; + } + + const block: CoachMemoryBlock = { trendMemory }; + if (priorNarrative) block.priorNarrative = priorNarrative; + return block; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7fdcf7867..7c70754a6 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -27,6 +27,7 @@ import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; +import { buildCoachMemoryBlock } from "./memory-snapshot"; import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, @@ -453,9 +454,13 @@ async function buildCoachSnapshotImpl( // key is absent). So the prefs read must precede `resolveScope`. const prefsRow = await prisma.user.findUnique({ where: { id: userId }, - select: { coachPrefsJson: true, timezone: true }, + select: { coachPrefsJson: true, timezone: true, locale: true }, }); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); + // Resolve the UI locale for the rolling-profile narrative recall. The + // narrative rows are keyed by ("de" | "en"); default to "de" (the app + // default locale) when the user never picked one. + const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de"; const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters); const { sources: scopedSources, window } = resolveScope( scope, @@ -1204,16 +1209,16 @@ async function buildCoachSnapshotImpl( "sleep", "vo2_max", ]; + const derivedCtx = features.context; + const derivedProfile: BaselineProfile = { + ageYears: derivedCtx?.ageYears ?? null, + sex: + derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE" + ? (derivedCtx.gender as "MALE" | "FEMALE") + : null, + heightCm: derivedCtx?.heightCm ?? null, + }; if (derivedSources.some((s) => sources.has(s))) { - const ctx = features.context; - const derivedProfile: BaselineProfile = { - ageYears: ctx?.ageYears ?? null, - sex: - ctx?.gender === "MALE" || ctx?.gender === "FEMALE" - ? (ctx.gender as "MALE" | "FEMALE") - : null, - heightCm: ctx?.heightCm ?? null, - }; const derivedBlock = await buildDerivedSnapshotBlock( userId, derivedProfile, @@ -1226,6 +1231,30 @@ async function buildCoachSnapshotImpl( } } + // ── v1.11.0 W5a — rolling-profile memory (Pillar P2 2a) ────────────── + // + // Zero-LLM longitudinal recall: the latest period-narrative headline + + // a per-metric prior-vs-current band memory, assembled from artefacts we + // already persist. Lets the Coach reference "as I noted at the start of + // the month…" instead of re-deriving cold every turn. Folded under the + // `memory` key and registered against the LOWEST-signal cluster + // (`environment`, the tail of CLUSTER_PRIORITY) so `degradeToBudget` + // sheds it FIRST under the char cap — before any clinical cluster. The + // builder is fault-isolated per sub-source and returns null when neither + // a narrative nor any band movement is on file. + const memoryBlock = await buildCoachMemoryBlock( + userId, + derivedProfile, + now, + coachLocale, + ); + if (memoryBlock) { + snapshot.memory = memoryBlock; + // `skin_temp` maps to the `environment` cluster — the lowest priority + // in CLUSTER_PRIORITY — so this block degrades before everything else. + registerBlock("memory", "skin_temp"); + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); }