Files
HealthLog/src/lib/labs/__tests__/ocr-capability.test.ts
T
Marc-André Bombeck bd8ea9d86b feat(ai): gate document, lab and medication extraction on their capabilities
Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
2026-09-24 08:44:57 +02:00

164 lines
5.5 KiB
TypeScript

/**
* The labs scan's wire and its probe.
*
* `requireLabsOcrProvider` is where a lab report meets a provider: it picks
* the provider for the scan mode and asks the `labsOcr` capability again about
* exactly that provider. `resolveOcrCapability` is the probe the Labs page
* reads to decide whether to offer a scan at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/codex-client", () => ({
resolveCodexVisionSlug: vi.fn(() => "gpt-5"),
}));
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/documents/rasterize-pdf", () => ({
RASTERIZATION_AVAILABLE: true,
}));
import { prisma } from "@/lib/db";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
requireLabsOcrProvider,
resolveOcrCapability,
} from "../ocr-capability";
function entry(providerType: string) {
return { providerType, instance: { type: providerType } } as never;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiModel: "claude-sonnet-4-5",
labsLocalOcrEnabled: true,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiModel: null,
} as never);
vi.mocked(resolveProvider).mockResolvedValue({ type: "none" } as never);
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("requireLabsOcrProvider", () => {
it("re-checks labsOcr for the vision pick", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
const pick = await requireLabsOcrProvider("u1", "vision");
expect(pick.providerType).toBe("anthropic");
expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", [
"anthropic",
]);
});
it("re-checks labsOcr for the text-mode chain head, not the first vision entry", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([
entry("codex"),
entry("local"),
]);
const pick = await requireLabsOcrProvider("u1", "text");
expect(pick.providerType).toBe("codex");
expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", ["codex"]);
});
it("throws the wire's refusal and hands back no provider", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
const refusal = new AiUnavailableError("labsOcr", "operator_disabled");
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
await expect(requireLabsOcrProvider("u1", "vision")).rejects.toBe(refusal);
});
it("keeps the scan's own code when nothing can read the report", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([]);
const error = await requireLabsOcrProvider("u1", "vision").catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.status).toBe(422);
expect(error.meta).toEqual({
errorCode: "labs.ocr.providerUnsupported",
capability: "labsOcr",
reason: "no_provider",
});
expect(aiEgressRefusal).not.toHaveBeenCalled();
});
});
describe("resolveOcrCapability", () => {
it("offers the scan and reports the capability beside it", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
const dto = await resolveOcrCapability("u1");
expect(dto).toMatchObject({
available: true,
mode: "vision",
ai: { available: true },
});
expect(getAiCapability).toHaveBeenCalledWith("labsOcr");
});
it("offers nothing when the operator turned document reading off", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
await expect(resolveOcrCapability("u1")).resolves.toEqual({
available: false,
mode: null,
reason: null,
pdfSupported: false,
ai: {
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
},
});
});
it("offers nothing with the labs module off", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "module_disabled",
onDeviceAllowed: false,
});
const dto = await resolveOcrCapability("u1");
expect(dto.available).toBe(false);
expect(dto.mode).toBeNull();
});
it("keeps the scan offered when only a consent receipt is missing", async () => {
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: true,
});
const dto = await resolveOcrCapability("u1");
expect(dto).toMatchObject({ available: true, mode: "vision" });
});
});