From fa50bcd1cb0bfa4c6dc84a0a1d5ee4f7457d2396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:45:39 +0100 Subject: [PATCH] fix(ai): keep lab values out of every model prompt while Labs is off The Coach snapshot attached its labs block regardless of the module, a decision from before Labs could be switched off, while the snapshot's own prelude says a disabled domain never reaches the model. With Labs off, the Coach prompt, its get_labs tool, the daily briefing's flagged-marker block, the biomarker status note, the reaction line to a new panel and the MCP lab_trend_brief prompt all still read lab values for a model. Each of those reads now asks the labs module first and reads nothing when it is off: the Coach tool answers module_disabled, the briefing omits the block, the status note neither writes nor serves text, the reaction line gets no value, and the MCP prompt refuses like get_labs and the doctor-visit prompt already did. The data routes stay as they are. A structural guard holds every lab-table reader under the model-input paths to the gate or to a listed reason its rows never reach a model. --- .../baselines/source-file-lines.json | 2 +- .../labs-module-ai-gate-guard.test.ts | 112 ++++++++++++++++++ .../coach-history-reach-guard.test.ts | 1 + .../__tests__/data-field-injection.test.ts | 13 ++ .../ai/coach/__tests__/labs-snapshot.test.ts | 30 ++++- .../ai/coach/__tests__/tools-executor.test.ts | 30 ++++- src/lib/ai/coach/labs-snapshot.ts | 12 +- src/lib/ai/coach/snapshot.ts | 5 +- .../__snapshots__/coach-turn-tool-defs.snap | 2 +- src/lib/ai/coach/tools/definitions.ts | 2 +- src/lib/ai/coach/tools/run/get-labs.ts | 6 + .../__tests__/biomarker-status.test.ts | 34 +++++- src/lib/insights/__tests__/features.test.ts | 34 +++++- src/lib/insights/biomarker-status.ts | 6 + src/lib/insights/feature-blocks.ts | 6 +- .../reaction-line-degradation.test.ts | 40 ++++++- src/lib/jobs/reaction-line-labs.ts | 40 +++++++ src/lib/jobs/reaction-line.ts | 27 +---- src/lib/mcp/__tests__/prompts.test.ts | 17 ++- src/lib/mcp/prompts.ts | 12 ++ 20 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/labs-module-ai-gate-guard.test.ts create mode 100644 src/lib/jobs/reaction-line-labs.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index d57271904..ad473f07b 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -68,7 +68,7 @@ "lib/jobs/insight-pregenerate.ts": 1189, "lib/jobs/medication-intake-import.ts": 819, "lib/jobs/offhost-backup.ts": 1147, - "lib/jobs/reaction-line.ts": 824, + "lib/jobs/reaction-line.ts": 803, "lib/jobs/reminder/medication-reminder-check.ts": 816, "lib/managed-profiles/handover.ts": 986, "lib/mcp/record-search.ts": 1467, diff --git a/src/__tests__/labs-module-ai-gate-guard.test.ts b/src/__tests__/labs-module-ai-gate-guard.test.ts new file mode 100644 index 000000000..61a762c82 --- /dev/null +++ b/src/__tests__/labs-module-ai-gate-guard.test.ts @@ -0,0 +1,112 @@ +/** + * Structural guard: no lab value reaches a model while the labs module is off. + * + * ## The failure this freezes + * + * The Coach snapshot carried a labs block "intentionally not module-gated", a + * decision from before Labs could be switched off. Once it could, the Coach + * prompt, its `get_labs` tool, the daily briefing, the biomarker status note, + * the reaction line to a new panel and the MCP lab-trend prompt all kept + * reading lab values for the model after the owner had turned the module + * off, while the snapshot's own prelude promised that "the model never sees + * a disabled domain". Each read was a separate module with its own + * `prisma.labResult` query, and nothing tied them to the switch. + * + * ## What it proves and what it does not + * + * Every module under the paths that build model input (`src/lib/ai`, + * `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) that reads the lab + * table either asks the labs module itself (`isModuleEnabled(…, "labs")`, + * the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn` wrapper) or + * is listed below with the reason its read cannot reach a prompt. A new + * reader fails here until it does one or the other. The data routes are not + * in scope: a module decides what a model is given, not what the owner can + * read (`CLAUDE.md`, "An AI capability gates model work"). + * + * It is grep-shaped: it sees a read, not where the rows go, and a reader in + * another directory is outside it. The behaviour is pinned per path by the + * module-off cases in the suites of the readers themselves. + */ +import { describe, expect, it } from "vitest"; + +import { readSourceFolder, stripComments } from "./helpers/source-files"; + +const ROOTS = ["src/lib/ai", "src/lib/insights", "src/lib/jobs", "src/lib/mcp"]; + +/** A read of the lab table: the Prisma delegate or raw SQL naming it. */ +const LAB_READ = + /\.labResult\s*\.\s*(?:findMany|findFirst|findUnique|count|aggregate|groupBy)\s*\(|\blab_results\b/; + +/** What counts as asking the labs module. */ +const LABS_GATE = + /isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(/; + +/** Readers whose rows never reach a model, each with the reason. */ +const NOT_MODEL_INPUT: Record = { + "src/lib/jobs/lab-biomarker-backfill.ts": + "links stored rows to their biomarker; no model is involved", + "src/lib/jobs/soft-delete-retention.ts": + "purges rows past the trash window; no model is involved", + "src/lib/insights/correlation-channel-series.ts": + "`fetchLabDraws`: both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface", + "src/lib/mcp/rich-reads.ts": + "`getLabHistory`: reached only through the MCP get_labs tool, which runs behind whenModuleOn", + "src/lib/ai/coach/tools/availability.ts": + "counts lab rows for the Coach's empty-read answer; the only lab subject comes from get_labs, which answers module_disabled first", +}; + +function labReaders(): Array<{ path: string; gated: boolean }> { + const out: Array<{ path: string; gated: boolean }> = []; + for (const root of ROOTS) { + for (const [rel, source] of readSourceFolder(root, { + floor: 20, + include: (r) => + !r.split("/").includes("__tests__") && + !/\.(?:test|spec)\.tsx?$/.test(r), + })) { + const code = stripComments(source); + if (!LAB_READ.test(code)) continue; + out.push({ path: `${root}/${rel}`, gated: LABS_GATE.test(code) }); + } + } + return out.sort((a, b) => a.path.localeCompare(b.path)); +} + +describe("labs reach a model only with the labs module on", () => { + const readers = labReaders(); + + it("finds the readers it exists to check", () => { + // An empty match set would pass every assertion below vacuously. + expect(readers.length).toBeGreaterThanOrEqual(8); + expect(readers.filter((r) => r.gated).length).toBeGreaterThanOrEqual(6); + }); + + it("every reader asks the labs module or is listed as not model input", () => { + const open = readers + .filter((r) => !r.gated && !(r.path in NOT_MODEL_INPUT)) + .map((r) => r.path); + expect(open).toEqual([]); + }); + + it("lists no reader that has since gained the gate or gone", () => { + const paths = new Map(readers.map((r) => [r.path, r.gated])); + const stale = Object.keys(NOT_MODEL_INPUT).filter( + (path) => paths.get(path) !== false, + ); + expect(stale).toEqual([]); + }); + + it("the matchers catch what they exist to catch", () => { + expect(LAB_READ.test("await prisma.labResult.findMany({")).toBe(true); + expect(LAB_READ.test("tx.labResult\n .count({")).toBe(true); + expect(LAB_READ.test("SELECT 1 FROM lab_results")).toBe(true); + expect(LAB_READ.test("prisma.labResultDocumentLink.findMany(")).toBe(false); + expect(LABS_GATE.test('await isModuleEnabled(userId, "labs")')).toBe(true); + expect(LABS_GATE.test('await isModuleEnabled(ctx.userId, "labs")')).toBe( + true, + ); + expect(LABS_GATE.test('isModuleEnabled(userId, "medications")')).toBe( + false, + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index 77bb7d4fc..d61b7a3fd 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -183,6 +183,7 @@ vi.mock("@/lib/tz/resolver", () => ({ })); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: async () => ({}), + isModuleEnabled: async () => true, })); vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults: async () => null, diff --git a/src/lib/ai/coach/__tests__/data-field-injection.test.ts b/src/lib/ai/coach/__tests__/data-field-injection.test.ts index 2c71111c9..077547ce5 100644 --- a/src/lib/ai/coach/__tests__/data-field-injection.test.ts +++ b/src/lib/ai/coach/__tests__/data-field-injection.test.ts @@ -20,6 +20,19 @@ import { resolve } from "node:path"; import { describe, expect, it, vi, beforeEach } from "vitest"; +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { labResult: { findMany: vi.fn() }, diff --git a/src/lib/ai/coach/__tests__/labs-snapshot.test.ts b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts index aa1063b64..566137641 100644 --- a/src/lib/ai/coach/__tests__/labs-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts @@ -1,4 +1,17 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, onTestFinished } from "vitest"; + +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); vi.mock("@/lib/db", () => ({ prisma: { @@ -45,6 +58,21 @@ beforeEach(() => { }); describe("buildLabsSnapshotBlock", () => { + it("reads no lab row and answers null with the labs module off", async () => { + labsModule.on = false; + onTestFinished(() => { + labsModule.on = true; + }); + prismaMock.labResult.findMany.mockResolvedValue([numericRow()]); + expect(await buildLabsSnapshotBlock("user_1", NOW)).toBeNull(); + expect( + await buildLabsSnapshotBlock("user_1", NOW, undefined, { + analyte: "ldl", + }), + ).toBeNull(); + expect(prismaMock.labResult.findMany).not.toHaveBeenCalled(); + }); + it("returns null when the account has no readings", async () => { prismaMock.labResult.findMany.mockResolvedValue([]); const block = await buildLabsSnapshotBlock("user_1", NOW); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index e359bea4f..b2cdaf955 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -5,7 +5,20 @@ * audit asserts the model can always tell "no data" from "data". */ import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, onTestFinished } from "vitest"; + +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -267,6 +280,21 @@ describe("executeCoachTool", () => { // can cite the reading, not just name the biomarker. The snapshot labs block // already carries { analyte, value, unit, rangeStatus, … }; the tool passes // it through verbatim. This guards that the value + unit survive the tool. + it("get_labs answers module_disabled with the labs module off", async () => { + labsModule.on = false; + onTestFinished(() => { + labsModule.on = true; + }); + buildCoachSnapshot.mockClear(); + const result = await executeCoachTool({ + userId: "u1", + name: "get_labs", + rawArguments: "{}", + }); + expect(result).toMatchObject({ present: false, reason: "module_disabled" }); + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + }); + it("get_labs carries each reading's value and unit (citation guarantee)", async () => { buildCoachSnapshot.mockResolvedValue( snapshot({ diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index bb030f516..69f260f7b 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -46,10 +46,12 @@ * `labs_read` tool result, so sanitisation lives here at the shared source. * * `userId` is narrowed from the authenticated session by the caller and feeds - * the Prisma `where` field-by-field; it is never an input. Unlike the illness - * block, Labs is intentionally NOT module-gated (mirrors `/api/labs` — the data - * is always owner-scoped and safe to read), so the block is `null` only when the - * account has no recent readings. + * the Prisma `where` field-by-field; it is never an input. Like the illness + * block it is module-gated: with the `labs` module off the block is `null` + * and no lab row is read, so neither the Coach prompt nor its `get_labs` tool + * sees a value. It used to ride regardless, a decision from before Labs could + * be switched off; the data routes (`/api/labs`) stay ungated, because a + * module decides what a model is given, not what the owner can read. */ import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { @@ -61,6 +63,7 @@ import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range"; import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; /** Cap on distinct biomarkers in the block (newest reading first). */ const MAX_BIOMARKERS = 24; @@ -235,6 +238,7 @@ export async function buildLabsSnapshotBlock( analyte?: string; } = {}, ): Promise { + if (!(await isModuleEnabled(userId, "labs"))) return null; const wanted = options.analyte?.trim().toLowerCase() || null; const floor = reachFloor(reach, now); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index b572218a7..8b4eae6ee 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -666,9 +666,8 @@ async function buildCoachSnapshotImpl( // v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a // scope-gated metric: attached without a `registerBlock` so the budget - // degrader never sheds it, and Labs is intentionally not module-gated (the - // helper reads owner-scoped rows directly, mirroring `/api/labs`). The block - // carries the most-recent resolved reading per biomarker (last 12 months, + // degrader never sheds it. The helper is module-gated internally: with the + // labs module off it reads nothing and answers null. The block carries the most-recent resolved reading per biomarker (last 12 months, // capped) so the Coach can answer "what was my LDL" without re-deriving. const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach); diff --git a/src/lib/ai/coach/tools/__tests__/__snapshots__/coach-turn-tool-defs.snap b/src/lib/ai/coach/tools/__tests__/__snapshots__/coach-turn-tool-defs.snap index 816b4e8d7..5e6fcd36d 100644 --- a/src/lib/ai/coach/tools/__tests__/__snapshots__/coach-turn-tool-defs.snap +++ b/src/lib/ai/coach/tools/__tests__/__snapshots__/coach-turn-tool-defs.snap @@ -137,7 +137,7 @@ }, { "name": "get_labs", - "description": "Fetch the user's lab results over the whole record (or the user's lookback limit, when there is one): without arguments the latest reading per biomarker. Pass an analyte for how it changed over time: each matching marker then carries a history, the newest earlier values in full and older ones as one mean / min / max / count per year. Returns { present: false } with a reason — no_data means no labs are on file, outside_reach means they are older than the user's lookback limit.", + "description": "Fetch the user's lab results over the whole record (or the user's lookback limit, when there is one): without arguments the latest reading per biomarker. Pass an analyte for how it changed over time: each matching marker then carries a history, the newest earlier values in full and older ones as one mean / min / max / count per year. Returns { present: false } with a reason — no_data means no labs are on file, outside_reach means they are older than the user's lookback limit, module_disabled means the user switched the labs module off.", "parameters": { "type": "object", "additionalProperties": false, diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 41b181ac4..dcc590b20 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -321,7 +321,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ { name: "get_labs", description: - "Fetch the user's lab results over the whole record (or the user's lookback limit, when there is one): without arguments the latest reading per biomarker. Pass an analyte for how it changed over time: each matching marker then carries a history, the newest earlier values in full and older ones as one mean / min / max / count per year. Returns { present: false } with a reason — no_data means no labs are on file, outside_reach means they are older than the user's lookback limit.", + "Fetch the user's lab results over the whole record (or the user's lookback limit, when there is one): without arguments the latest reading per biomarker. Pass an analyte for how it changed over time: each matching marker then carries a history, the newest earlier values in full and older ones as one mean / min / max / count per year. Returns { present: false } with a reason — no_data means no labs are on file, outside_reach means they are older than the user's lookback limit, module_disabled means the user switched the labs module off.", parameters: { type: "object", additionalProperties: false, diff --git a/src/lib/ai/coach/tools/run/get-labs.ts b/src/lib/ai/coach/tools/run/get-labs.ts index 14d39e0b6..bd837166e 100644 --- a/src/lib/ai/coach/tools/run/get-labs.ts +++ b/src/lib/ai/coach/tools/run/get-labs.ts @@ -2,6 +2,7 @@ import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types"; import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { getLabsArgsSchema } from "../definitions"; import { subjectForTool } from "../availability"; import { badArgs, emptyRead, pickSection, scopeFor } from "./shared"; @@ -16,6 +17,11 @@ export async function getLabs( ): Promise { const parsed = getLabsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_labs", parsed.error); + // The labs module off: said as such, not probed for rows (the block below + // would be empty and the availability probe would count lab rows). + if (!(await isModuleEnabled(userId, "labs"))) { + return { present: false, reason: "module_disabled" }; + } // Labs ride the snapshot regardless of `sources` (attached unconditionally), // so a minimal scope still surfaces them. // diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts index 7c6bed73d..e264cc8e9 100644 --- a/src/lib/insights/__tests__/biomarker-status.test.ts +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -1,4 +1,17 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, onTestFinished } from "vitest"; + +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); vi.mock("@/lib/db", () => ({ prisma: { @@ -108,6 +121,25 @@ beforeEach(() => { vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); }); +describe("generateBiomarkerStatus — labs module", () => { + it("writes and serves nothing about a marker with the labs module off", async () => { + labsModule.on = false; + onTestFinished(() => { + labsModule.on = true; + }); + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + force: true, + }); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }); +}); + describe("generateBiomarkerStatus — empty-data guard", () => { it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 98dc39deb..0a1b36456 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -1,4 +1,17 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, onTestFinished } from "vitest"; + +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); vi.mock("@/lib/db", () => ({ prisma: { @@ -410,6 +423,25 @@ describe("extractFeatures — weight target direction (#1006)", () => { }); describe("extractFeatures — v1.22 labs briefing block", () => { + it("leaves the labs out of the briefing with the labs module off", async () => { + labsModule.on = false; + onTestFinished(() => { + labsModule.on = true; + }); + prismaMock.labResult.findMany.mockResolvedValue([ + labRow({ + analyte: "LDL", + value: 200, + referenceLow: 0, + referenceHigh: 130, + daysAgo: 5, + }), + ]); + const f = await extractFeatures("user-1", false); + expect(f.labs).toBeUndefined(); + expect(prismaMock.labResult.findMany).not.toHaveBeenCalled(); + }); + it("surfaces an abnormal (out-of-range) biomarker", async () => { prismaMock.labResult.findMany.mockResolvedValue([ labRow({ diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 5a178c3e1..75c4c7f8b 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -21,6 +21,7 @@ * `insufficient` marker WITHOUT touching the provider chain. */ import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { @@ -130,6 +131,11 @@ export async function generateBiomarkerStatus(args: { // Unknown / cross-user marker — degrade gracefully rather than throwing. return insufficient(); } + // With the labs module off no model writes about a marker, and text it + // wrote while the module was on is not served either. + if (!(await isModuleEnabled(args.userId, "labs"))) { + return { hasProvider: true, text: null, cached: false, updatedAt: null }; + } const prepared = await prepareBiomarkerStatus(marker, args); return (await runPreparedStatusCard(prepared)) as BiomarkerStatusResult; diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 63229ce86..de67e5d73 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -20,6 +20,7 @@ import { import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { resolveLabFields } from "@/lib/labs/serialise"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, @@ -346,12 +347,15 @@ function sanitizeLabel(text: string, max = 80): string { * Most-recent reading per biomarker over the lookback window; hidden markers * excluded; qualitative rows neutral. Only abnormal (below/above) OR trending * markers surface, bounded. Returns `undefined` when nothing is flagged so the - * block is omitted rather than emitting an empty shape. + * block is omitted rather than emitting an empty shape, and when the `labs` + * module is off, before any row is read: the briefing prompt never carries a + * switched-off domain. */ export async function readLabsBriefingBlock( userId: string, now: number, ): Promise { + if (!(await isModuleEnabled(userId, "labs"))) return undefined; const nowDate = new Date(now); const cutoff = new Date(nowDate); cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index f9ba8ad97..544d7424b 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -14,7 +14,20 @@ * still surface the chip. The last assertion of each case is the one that * matters: `justIn` survives. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, onTestFinished } from "vitest"; + +// The labs module is on unless a test switches it off. +const { labsModule } = vi.hoisted(() => ({ labsModule: { on: true } })); +vi.mock("@/lib/modules/gate", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + isModuleEnabled: async ( + userId: string, + key: Parameters[1], + ) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)), + }; +}); import { getServerTranslator } from "@/lib/i18n/server-translator"; import { buildDailyDigest, type DailyDigestInput } from "@/lib/daily/digest"; @@ -336,6 +349,31 @@ describe("reaction line — degradation", () => { expectSurfaceStillWorks(); }); + it("gives the model no lab value once the labs module is off", async () => { + labsModule.on = false; + onTestFinished(() => { + labsModule.on = true; + }); + labResultFindMany.mockResolvedValue([ + { analyte: "LDL", value: 190, valueText: null, unit: "mg/dL" }, + ]); + const generateCompletion = vi.fn().mockResolvedValue({ + content: "A new lab panel arrived.", + tokensUsed: 300, + cachedInputTokens: 0, + }); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: { generateCompletion } }, + ]); + + await runReactionLine({ ...JOB, kind: "labs_panel" }); + + expect(labResultFindMany).not.toHaveBeenCalled(); + for (const [request] of generateCompletion.mock.calls) { + expect(JSON.stringify(request)).not.toContain("190"); + } + }); + it("fences free-text lab fields as data, never prompt instructions", async () => { labResultFindMany.mockResolvedValue([ { diff --git a/src/lib/jobs/reaction-line-labs.ts b/src/lib/jobs/reaction-line-labs.ts new file mode 100644 index 000000000..8d71e8a37 --- /dev/null +++ b/src/lib/jobs/reaction-line-labs.ts @@ -0,0 +1,40 @@ +/** + * The new lab panel a reaction line is written about, as evidence lines for + * the model (`loadArrivalEvidence` in `./reaction-line`). + * + * Module-gated: an arrival is only emitted with Labs on, but its job can run + * after the owner switched the module off, and then the model gets no value. + */ +import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +/** Up to eight results of the panel, fenced; null when there is nothing to say. */ +export async function labsPanelEvidence( + userId: string, + arrival: { occurredAt: Date; refId: string | null }, +): Promise { + if (!(await isModuleEnabled(userId, "labs"))) return null; + const labs = await prisma.labResult.findMany({ + where: { + userId, + takenAt: arrival.occurredAt, + deletedAt: null, + ...(arrival.refId ? { panel: arrival.refId } : {}), + }, + select: { analyte: true, value: true, valueText: true, unit: true }, + orderBy: { analyte: "asc" }, + take: 8, + }); + if (labs.length === 0) return null; + return labs + .map((lab) => { + const value = + lab.value === null + ? fenceUserText(lab.valueText ?? "not reported") + : String(lab.value); + const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : ""; + return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`; + }) + .join("\n"); +} diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 4ec108073..b8cc5e7db 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -57,7 +57,7 @@ import { } from "@/lib/ai/coach/coach-prose-grounding"; import { extractAssessmentSummary } from "@/lib/insights/status-shared"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { labsPanelEvidence } from "@/lib/jobs/reaction-line-labs"; import { SESSION_USER_SELECT } from "@/lib/auth/session-user"; import { singleUserTurn, type CompletionResult } from "@/lib/ai/types"; import type { Locale } from "@/lib/i18n/config"; @@ -267,29 +267,8 @@ export async function loadArrivalEvidence( } if (job.kind === "labs_panel") { - const labs = await prisma.labResult.findMany({ - where: { - userId: job.userId, - takenAt: row.occurredAt, - deletedAt: null, - ...(row.refId ? { panel: row.refId } : {}), - }, - select: { analyte: true, value: true, valueText: true, unit: true }, - orderBy: { analyte: "asc" }, - take: 8, - }); - if (labs.length > 0) { - return labs - .map((lab) => { - const value = - lab.value === null - ? fenceUserText(lab.valueText ?? "not reported") - : String(lab.value); - const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : ""; - return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`; - }) - .join("\n"); - } + const lines = await labsPanelEvidence(job.userId, row); + if (lines) return lines; } return "- The arrival marker was written, but its exact value is unavailable."; diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts index 79bfdc9be..d321aa8e9 100644 --- a/src/lib/mcp/__tests__/prompts.test.ts +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, onTestFinished } from "vitest"; vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), @@ -457,6 +457,21 @@ describe("sleep_review", () => { }); describe("lab_trend_brief", () => { + it("refuses with the labs module off and reads no lab row", async () => { + const { isModuleEnabled } = await import("@/lib/modules/gate"); + vi.mocked(isModuleEnabled).mockImplementation( + async (_userId, key) => key !== "labs", + ); + onTestFinished(() => { + vi.mocked(isModuleEnabled).mockImplementation(async () => true); + }); + vi.mocked(prisma.labResult.findMany).mockClear(); + await expect(prompt("lab_trend_brief").run(CTX, {})).rejects.toThrow( + /"labs" module is turned off/, + ); + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + }); + it("builds a per-analyte trajectory with stored ranges + status, latest first", async () => { vi.mocked(prisma.labResult.findMany).mockResolvedValue([ { diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 2d41ae753..9507775c2 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -567,6 +567,18 @@ export const MCP_PROMPTS: McpPromptDefinition[] = [ "Assemble a grounded per-analyte lab trajectory — latest reading vs prior readings, against the stored reference range, for one named analyte or across the panel. Reads the user's own lab store directly (the latest-only labs tool can't show a trend). Reference ranges are the lab's own stored bounds, never recomputed. Facts only: no diagnosis, no verdict.", argsShape: { analyte: z.string().min(1).max(80).optional() }, async run(ctx, args) { + // The same door `get_labs` has: refuse with the module off rather than + // hand the assistant an empty panel to narrate as "no labs on file". + if (!(await isModuleEnabled(ctx.userId, "labs"))) { + annotate({ + action: { name: "mcp.prompt.invoked" }, + meta: { prompt: "lab_trend_brief", refused: "module_disabled" }, + }); + throw new Error( + 'The "labs" module is turned off for this account, so the lab trend ' + + "brief is not available.", + ); + } const analyte = typeof args.analyte === "string" && args.analyte.trim() !== "" ? args.analyte.trim()