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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent 6fbfe71955
commit fa50bcd1cb
20 files changed
+390 -41

No files matched your search

@@ -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,
@@ -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<string, string> = {
"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,
);
});
});
@@ -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,
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[1],
) => (key === "labs" ? labsModule.on : actual.isModuleEnabled(userId, key)),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
labResult: { findMany: vi.fn() },
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[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);
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[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({
+8 -4
View File
@@ -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<CoachLabsBlock | null> {
if (!(await isModuleEnabled(userId, "labs"))) return null;
const wanted = options.analyte?.trim().toLowerCase() || null;
const floor = reachFloor(reach, now);
+2 -3
View File
@@ -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);
@@ -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,
+1 -1
View File
@@ -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,
+6
View File
@@ -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<CoachToolResult> {
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.
//
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[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);
+33 -1
View File
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[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({
+6
View File
@@ -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;
+5 -1
View File
@@ -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<AggregatedFeatures["labs"] | undefined> {
if (!(await isModuleEnabled(userId, "labs"))) return undefined;
const nowDate = new Date(now);
const cutoff = new Date(nowDate);
cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS);
@@ -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<typeof import("@/lib/modules/gate")>();
return {
...actual,
isModuleEnabled: async (
userId: string,
key: Parameters<typeof actual.isModuleEnabled>[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([
{
+40
View File
@@ -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<string | null> {
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");
}
+3 -24
View File
@@ -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.";
+16 -1
View File
@@ -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([
{
+12
View File
@@ -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()