mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.29.x (S7) — the shared fenced turn pipeline (design §3, adversarial tests
|
||||
* 12, 14, 16, 17, 18, 19). Pins: per-document egress consent fan-out (ALL clear
|
||||
* or refuse, zero egress + no user turn persisted on partial consent), the SINGLE
|
||||
* consent-checked provider (no cascade), numeric grounding over the LIVE
|
||||
* 12, 14, 16, 17, 18, 19). Pins: the capability re-check at the wire (the
|
||||
* document pick answers `documentAi`, each extra capability the entry point
|
||||
* names is asked for the same provider; any refusal means zero egress and no
|
||||
* user turn persisted), the SINGLE checked provider (no cascade), numeric grounding over the LIVE
|
||||
* attachment UNION, and the owner-scoped context loader (a corrupted/foreign join
|
||||
* row yields null → the turn refuses, never foreign text).
|
||||
*/
|
||||
@@ -21,8 +22,8 @@ vi.mock("@/lib/documents/content-index", () => ({
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
assertAiEgress: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-16"),
|
||||
@@ -49,7 +50,8 @@ import {
|
||||
} from "../fenced-chat";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
import { appendMessage } from "@/lib/ai/coach/persistence";
|
||||
|
||||
@@ -90,12 +92,13 @@ const baseArgs = {
|
||||
contractLocale: "en" as const,
|
||||
locale: "en" as const,
|
||||
signal: new AbortController().signal,
|
||||
alsoRequires: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
||||
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
|
||||
vi.mocked(appendMessage).mockImplementation(
|
||||
async (p) => ({ id: p.role === "assistant" ? "a" : "u", ...p }) as never,
|
||||
);
|
||||
@@ -106,36 +109,67 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("streamFencedReply — consent fan-out", () => {
|
||||
it("checks egress consent ONCE PER attached document", async () => {
|
||||
await streamFencedReply({
|
||||
...baseArgs,
|
||||
docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")],
|
||||
});
|
||||
expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3);
|
||||
expect(assertDocumentEgressConsent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
providerType: "anthropic",
|
||||
surface: "insights",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => {
|
||||
vi.mocked(assertDocumentEgressConsent)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("ConsentRequiredError"));
|
||||
describe("streamFencedReply — the capability at the wire", () => {
|
||||
it("throws the document pick's refusal with ZERO egress and no user turn persisted", async () => {
|
||||
const refusal = new AiUnavailableError("documentAi", "consent_required");
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: refusal,
|
||||
} as never);
|
||||
await expect(
|
||||
streamFencedReply({ ...baseArgs, docs: [doc("a", "x"), doc("b", "y")] }),
|
||||
).rejects.toThrow();
|
||||
).rejects.toBe(refusal);
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks every extra capability about the picked provider", async () => {
|
||||
await drain(
|
||||
await streamFencedReply({
|
||||
...baseArgs,
|
||||
alsoRequires: ["coach"],
|
||||
docs: [doc("a", "x"), doc("b", "y")],
|
||||
}),
|
||||
);
|
||||
expect(assertAiEgress).toHaveBeenCalledTimes(1);
|
||||
expect(assertAiEgress).toHaveBeenCalledWith("coach", "user-1", [
|
||||
"anthropic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses with ZERO egress when an extra capability closed", async () => {
|
||||
vi.mocked(assertAiEgress).mockRejectedValueOnce(
|
||||
new AiUnavailableError("coach", "operator_disabled"),
|
||||
);
|
||||
await expect(
|
||||
streamFencedReply({
|
||||
...baseArgs,
|
||||
alsoRequires: ["coach"],
|
||||
docs: [doc("a", "x")],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AiUnavailableError);
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a missing provider an SSE frame, not a refusal", async () => {
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
const events = await drain(
|
||||
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ code: "documents.chat.provider.none" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamFencedReply — single provider, no cascade", () => {
|
||||
it("passes exactly the ONE consent-checked provider entry to the runner", async () => {
|
||||
it("passes exactly the ONE checked provider entry to the runner", async () => {
|
||||
await drain(
|
||||
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
}));
|
||||
|
||||
import { indexDocumentContent } from "../index-document";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import {
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
@@ -259,6 +260,26 @@ describe("indexDocumentContent — local fallback path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexDocumentContent — the documentAi capability", () => {
|
||||
it("indexes through the provider-free text layer when the pick was withheld", async () => {
|
||||
// The operator turned document reading off after the upload enqueued this
|
||||
// job: the pick comes back empty with the refusal beside it, no model is
|
||||
// called, and the text layer still makes the document searchable.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
} as never);
|
||||
|
||||
const outcome = await indexDocumentContent("user-1", "doc-1");
|
||||
|
||||
expect(transcribeDocument).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(localExtractText).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ indexed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexDocumentContent — fail-closed", () => {
|
||||
it("returns not-found for an unknown / non-owned document", async () => {
|
||||
vi.mocked(loadOwnedDocument).mockResolvedValue(null as never);
|
||||
|
||||
@@ -12,13 +12,27 @@ 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";
|
||||
@@ -103,7 +117,11 @@ describe("documentEgressClass", () => {
|
||||
});
|
||||
|
||||
describe("resolveDocumentVisionProvider", () => {
|
||||
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
|
||||
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({
|
||||
@@ -124,3 +142,152 @@ describe("resolveDocumentVisionProvider", () => {
|
||||
).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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user