feat(coach): fold a rolling-profile memory block into the snapshot

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 03:15:09 +02:00
1 parent e1cdbf3e06
commit d99e1f0e8b
4 files changed
+371 -10

No files matched your search

@@ -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");
});
});
@@ -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";
+157
View File
@@ -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<string, TrendMemoryEntry>;
}
/** 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<CoachMemoryBlock | null> {
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<string, TrendMemoryEntry> = {};
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;
}
+39 -10
View File
@@ -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");
}