mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user