mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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.
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
/**
|
|
* Document-class provider order (governance fix, oauth-investigation
|
|
* SYNTHESIS §1). Pins: for a DOCUMENT read the chain is reprioritised
|
|
* local-first with codex (ChatGPT-subscription OAuth) LAST, and the egress
|
|
* class the vault notice reads is vendor-blind local/external. Coach / insights
|
|
* do not use these helpers, so their app-wide order is untouched.
|
|
*/
|
|
|
|
vi.mock("@/lib/labs/ocr-capability", () => ({
|
|
resolveVisionProvider: vi.fn(),
|
|
resolveTextProvider: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
|
aiEgressRefusal: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
|
getAiCapability: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
documentEgressClass,
|
|
reorderChainForDocumentClass,
|
|
requireDocumentTextProvider,
|
|
requireDocumentVisionProvider,
|
|
resolveDocumentAiCapability,
|
|
resolveDocumentTextProvider,
|
|
resolveDocumentVisionProvider,
|
|
} from "../provider-order";
|
|
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
|
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
|
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
|
import {
|
|
resolveTextProvider,
|
|
resolveVisionProvider,
|
|
type VisionProviderPick,
|
|
} from "@/lib/labs/ocr-capability";
|
|
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
|
|
|
function entry(providerType: string): ProviderChainResolved {
|
|
return {
|
|
providerType: providerType as ProviderChainResolved["providerType"],
|
|
instance: {
|
|
type: providerType,
|
|
generateCompletion: vi.fn(),
|
|
} as unknown as ProviderChainResolved["instance"],
|
|
};
|
|
}
|
|
|
|
const order = (chain: ProviderChainResolved[]) =>
|
|
reorderChainForDocumentClass(chain).map((e) => e.providerType);
|
|
|
|
describe("reorderChainForDocumentClass", () => {
|
|
it("demotes codex last and lifts local first for the default chain", () => {
|
|
// The persisted default chain the audit flagged: codex at priority 1.
|
|
const chain = [
|
|
entry("codex"),
|
|
entry("openai"),
|
|
entry("anthropic"),
|
|
entry("local"),
|
|
entry("admin-openai"),
|
|
];
|
|
expect(order(chain)).toEqual([
|
|
"local",
|
|
"openai",
|
|
"anthropic",
|
|
"admin-openai",
|
|
"codex",
|
|
]);
|
|
});
|
|
|
|
it("keeps codex behind local even when codex is the only cheap option", () => {
|
|
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
|
|
});
|
|
|
|
it("demotes the shared central codex (admin-codex) to the subscription tier (rank 3)", () => {
|
|
// Same train-by-default subscription tier as `codex` — behind local, BYOK,
|
|
// and the operator's no-train admin key.
|
|
expect(
|
|
order([
|
|
entry("admin-codex"),
|
|
entry("local"),
|
|
entry("openai"),
|
|
entry("admin-openai"),
|
|
]),
|
|
).toEqual(["local", "openai", "admin-openai", "admin-codex"]);
|
|
});
|
|
|
|
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
|
|
expect(order([entry("anthropic"), entry("openai")])).toEqual([
|
|
"anthropic",
|
|
"openai",
|
|
]);
|
|
expect(order([entry("openai"), entry("anthropic")])).toEqual([
|
|
"openai",
|
|
"anthropic",
|
|
]);
|
|
});
|
|
|
|
it("does not mutate the input array", () => {
|
|
const chain = [entry("codex"), entry("local")];
|
|
reorderChainForDocumentClass(chain);
|
|
expect(chain.map((e) => e.providerType)).toEqual(["codex", "local"]);
|
|
});
|
|
});
|
|
|
|
describe("documentEgressClass", () => {
|
|
it("classifies local as on-machine and everything else as external", () => {
|
|
expect(documentEgressClass("local")).toBe("local");
|
|
expect(documentEgressClass("codex")).toBe("external");
|
|
expect(documentEgressClass("openai")).toBe("external");
|
|
expect(documentEgressClass("anthropic")).toBe("external");
|
|
expect(documentEgressClass("admin-openai")).toBe("external");
|
|
expect(documentEgressClass("admin-codex")).toBe("external");
|
|
});
|
|
});
|
|
|
|
describe("resolveDocumentVisionProvider", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(resolveVisionProvider).mockReset();
|
|
vi.mocked(aiEgressRefusal).mockReset();
|
|
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
|
|
});
|
|
|
|
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
|
chain: [],
|
|
localOcrEnabled: false,
|
|
pick: null,
|
|
} as VisionProviderPick);
|
|
|
|
await resolveDocumentVisionProvider("u1");
|
|
|
|
const call = vi.mocked(resolveVisionProvider).mock.calls[0]!;
|
|
expect(call[0]).toBe("u1");
|
|
const reorder = call[1]!.reorder!;
|
|
// The reorder the document resolver hands down puts a codex-first chain
|
|
// local-first — proving documents never default to the subscription path.
|
|
expect(
|
|
reorder([entry("codex"), entry("local")]).map((e) => e.providerType),
|
|
).toEqual(["local", "codex"]);
|
|
});
|
|
});
|
|
|
|
function visionPick(providerType: string): VisionProviderPick {
|
|
return {
|
|
chain: [entry(providerType)],
|
|
localOcrEnabled: false,
|
|
pick: {
|
|
entry: entry(providerType),
|
|
providerType: providerType as never,
|
|
pdfSupported: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("the document pick re-checks documentAi at the wire", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(resolveVisionProvider).mockReset();
|
|
vi.mocked(resolveTextProvider).mockReset();
|
|
vi.mocked(aiEgressRefusal).mockReset();
|
|
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
|
|
});
|
|
|
|
it("asks about the picked provider, under documentAi", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
|
|
const result = await resolveDocumentVisionProvider("u1");
|
|
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [
|
|
"openai",
|
|
]);
|
|
expect(result.pick?.providerType).toBe("openai");
|
|
expect(result.withheld).toBeNull();
|
|
});
|
|
|
|
it("withholds the pick when the capability closed after the route answered", async () => {
|
|
// A job enqueued before the operator turned the switch off: the pick is
|
|
// resolved, and the wire says no. The caller sees no provider to call.
|
|
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
|
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
|
const result = await resolveDocumentVisionProvider("u1");
|
|
expect(result.pick).toBeNull();
|
|
expect(result.withheld).toBe(refusal);
|
|
});
|
|
|
|
it("withholds a text-mode pick the same way", async () => {
|
|
const refusal = new AiUnavailableError("documentAi", "consent_required");
|
|
vi.mocked(resolveTextProvider).mockResolvedValue({
|
|
chain: [entry("codex")],
|
|
pick: { entry: entry("codex"), providerType: "codex" },
|
|
});
|
|
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
|
const result = await resolveDocumentTextProvider("u1");
|
|
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["codex"]);
|
|
expect(result.pick).toBeNull();
|
|
expect(result.withheld).toBe(refusal);
|
|
});
|
|
|
|
it("does not ask when nothing was picked", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
|
chain: [],
|
|
localOcrEnabled: false,
|
|
pick: null,
|
|
});
|
|
const result = await resolveDocumentVisionProvider("u1");
|
|
expect(aiEgressRefusal).not.toHaveBeenCalled();
|
|
expect(result.withheld).toBeNull();
|
|
});
|
|
|
|
it("the route form throws the refusal", async () => {
|
|
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
|
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
|
await expect(requireDocumentVisionProvider("u1")).rejects.toBe(refusal);
|
|
});
|
|
|
|
it("the route form refuses a missing provider with the vault's own code", async () => {
|
|
vi.mocked(resolveTextProvider).mockResolvedValue({ chain: [], pick: null });
|
|
const error = await requireDocumentTextProvider("u1").catch((e) => e);
|
|
expect(error).toBeInstanceOf(AiUnavailableError);
|
|
expect(error.status).toBe(422);
|
|
expect(error.meta).toEqual({
|
|
errorCode: "documents.inbound.providerUnsupported",
|
|
capability: "documentAi",
|
|
reason: "no_provider",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDocumentAiCapability", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(resolveVisionProvider).mockReset();
|
|
vi.mocked(aiEgressRefusal).mockReset();
|
|
vi.mocked(getAiCapability).mockReset();
|
|
});
|
|
|
|
it("reports the capability beside the provider answer", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
|
vi.mocked(getAiCapability).mockResolvedValue({
|
|
available: true,
|
|
reason: null,
|
|
onDeviceAllowed: true,
|
|
});
|
|
const dto = await resolveDocumentAiCapability("u1");
|
|
expect(dto).toMatchObject({
|
|
available: true,
|
|
mode: "vision",
|
|
egress: "local",
|
|
ai: { available: true, reason: null },
|
|
});
|
|
// A probe never runs the wire re-check: it sends nothing.
|
|
expect(aiEgressRefusal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is unavailable when the operator turned document reading off", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
|
vi.mocked(getAiCapability).mockResolvedValue({
|
|
available: false,
|
|
reason: "operator_disabled",
|
|
onDeviceAllowed: false,
|
|
});
|
|
const dto = await resolveDocumentAiCapability("u1");
|
|
expect(dto).toEqual({
|
|
available: false,
|
|
mode: null,
|
|
reason: null,
|
|
pdfSupported: false,
|
|
egress: null,
|
|
ai: {
|
|
available: false,
|
|
reason: "operator_disabled",
|
|
onDeviceAllowed: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("stays available when only a consent receipt is missing, so the read can ask for it", async () => {
|
|
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
|
|
vi.mocked(getAiCapability).mockResolvedValue({
|
|
available: false,
|
|
reason: "consent_required",
|
|
onDeviceAllowed: true,
|
|
});
|
|
const dto = await resolveDocumentAiCapability("u1");
|
|
expect(dto).toMatchObject({
|
|
available: true,
|
|
mode: "vision",
|
|
egress: "external",
|
|
ai: { reason: "consent_required" },
|
|
});
|
|
});
|
|
});
|