mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user