mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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