mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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.
This commit is contained in:
1 parent
a19900c854
commit
bd8ea9d86b
39 files changed
+1420
-376
No files matched your search
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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" });
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,19 @@
|
||||
* one place) and pair each surviving entry with the model that drives its
|
||||
* vision allowlist.
|
||||
*/
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
AiUnavailableError,
|
||||
type NoProviderRefusal,
|
||||
} from "@/lib/ai/capabilities/refusal";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { resolveCodexVisionSlug } from "@/lib/ai/codex-client";
|
||||
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
supportsPdfForProvider,
|
||||
supportsVisionForConfig,
|
||||
@@ -167,20 +175,91 @@ export async function resolveTextProvider(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a lab scan refuses with no provider that can serve it. The code predates
|
||||
* the capability envelope and a client already branches on it.
|
||||
*/
|
||||
export const LABS_OCR_NO_PROVIDER: NoProviderRefusal = {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
status: 422,
|
||||
};
|
||||
|
||||
/**
|
||||
* The labs scan's wire. Resolve the provider for one scan mode and ask the
|
||||
* `labsOcr` capability again about exactly that provider, immediately before
|
||||
* the report is sent: the operator's switch, the labs module, the sharing
|
||||
* grant, and a document-class consent receipt for any pick that leaves the
|
||||
* machine (a lab report is a document). Throws the refusal for `apiHandler`
|
||||
* to render; a missing provider keeps its `labs.ocr.providerUnsupported` code.
|
||||
*
|
||||
* Vision mode picks the first vision-capable entry in chain order; text mode
|
||||
* (the person's in-browser OCR) takes the chain head, which is why the pick,
|
||||
* not the published capability, answers the consent question.
|
||||
*/
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "vision",
|
||||
): Promise<NonNullable<VisionProviderPick["pick"]>>;
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "text",
|
||||
): Promise<{ entry: ProviderChainResolved; providerType: string }>;
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "vision" | "text",
|
||||
) {
|
||||
const { chain, pick } =
|
||||
mode === "vision"
|
||||
? await resolveVisionProvider(userId)
|
||||
: await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: {
|
||||
chainLength: chain.length,
|
||||
...(mode === "text" ? { mode } : {}),
|
||||
},
|
||||
});
|
||||
throw new AiUnavailableError(
|
||||
"labsOcr",
|
||||
"no_provider",
|
||||
null,
|
||||
LABS_OCR_NO_PROVIDER,
|
||||
);
|
||||
}
|
||||
const refusal = await aiEgressRefusal("labsOcr", userId, [pick.providerType]);
|
||||
if (refusal) throw refusal;
|
||||
return pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheap capability DTO for the probe endpoint: whether scanning is
|
||||
* available + why not + whether PDFs are accepted. Built from the same
|
||||
* resolution the extract route uses, so the UI never shows an entry the
|
||||
* extract route would 422.
|
||||
*
|
||||
* `ai` is the `labsOcr` capability for the request's record. A scan the
|
||||
* operator, the labs module or the sharing grant close is not offered
|
||||
* (`available: false`, no mode). A missing consent receipt keeps the scan
|
||||
* offered: the scan is where the person is asked for the receipt.
|
||||
*/
|
||||
export async function resolveOcrCapability(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<OcrCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
|
||||
userId,
|
||||
options,
|
||||
);
|
||||
const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([
|
||||
resolveVisionProvider(userId),
|
||||
getAiCapability("labsOcr"),
|
||||
]);
|
||||
|
||||
if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) {
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer the native vision path whenever it is available — it is more
|
||||
// accurate and the client does not download the OCR WASM. PDFs are readable
|
||||
@@ -195,6 +274,7 @@ export async function resolveOcrCapability(
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +286,7 @@ export async function resolveOcrCapability(
|
||||
mode: null,
|
||||
reason: "no-provider",
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +300,7 @@ export async function resolveOcrCapability(
|
||||
mode: "text",
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,5 +311,6 @@ export async function resolveOcrCapability(
|
||||
mode: null,
|
||||
reason: "enable-local-ocr",
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user