mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -64,7 +64,7 @@ const RESOLUTION_HELPERS = [
|
||||
"resolveProviderForTest",
|
||||
] as const;
|
||||
|
||||
/** The two sanctioned consent APIs. Either satisfies the guard. */
|
||||
/** The sanctioned consent APIs in the consent guard. Any satisfies the guard. */
|
||||
const CONSENT_HELPERS = [
|
||||
// Chain-shaped: throw-form and predicate-form.
|
||||
"assertConsentForChain",
|
||||
@@ -108,9 +108,23 @@ function resolvesAProvider(source: string): boolean {
|
||||
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability re-check at the wire. It answers the consent question for the
|
||||
* providers a caller is about to send to, from the receipt kinds the capability
|
||||
* table declares, so it is a consent helper in its own right.
|
||||
*/
|
||||
const CAPABILITY_EGRESS_HELPERS = [
|
||||
"assertAiEgress",
|
||||
"aiEgressRefusal",
|
||||
] as const;
|
||||
|
||||
function importsAConsentHelper(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
|
||||
return CONSENT_HELPERS.some((h) => imported.includes(h));
|
||||
const egress = importedNamesFrom(source, "@/lib/ai/capabilities/egress");
|
||||
return (
|
||||
CONSENT_HELPERS.some((h) => imported.includes(h)) ||
|
||||
CAPABILITY_EGRESS_HELPERS.some((h) => egress.includes(h))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,8 +139,6 @@ function importsAConsentHelper(source: string): boolean {
|
||||
*/
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
// --- Capability / config probes: resolve a chain, never call it. ---
|
||||
"lib/labs/ocr-capability.ts":
|
||||
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
|
||||
"app/api/insights/provider-chain/route.ts":
|
||||
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A `@/lib/documents/provider-order` mock for the vault route suites.
|
||||
*
|
||||
* The suites steer the document pick through `resolveDocumentVisionProvider` /
|
||||
* `resolveDocumentTextProvider` (a `{ chain, pick }` per case). The routes call
|
||||
* the `require*` forms, which throw instead of returning an empty pick; these
|
||||
* are built on the two mocked resolvers so a case that returns no pick sees
|
||||
* the route answer the vault's own `documents.inbound.providerUnsupported`
|
||||
* 422, and a case that returns a `withheld` refusal sees it thrown.
|
||||
*
|
||||
* Use inside a factory, where only a dynamic import is allowed:
|
||||
*
|
||||
* vi.mock("@/lib/documents/provider-order", async () =>
|
||||
* (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
* );
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
interface MockPick {
|
||||
pick: unknown;
|
||||
withheld?: AiUnavailableError | null;
|
||||
}
|
||||
|
||||
async function required(resolved: Promise<MockPick> | MockPick) {
|
||||
const result = await resolved;
|
||||
if (result?.withheld) throw result.withheld;
|
||||
if (!result?.pick) {
|
||||
throw new AiUnavailableError("documentAi", "no_provider", null, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
return result.pick;
|
||||
}
|
||||
|
||||
export function providerOrderMock() {
|
||||
const resolveDocumentVisionProvider = vi.fn();
|
||||
const resolveDocumentTextProvider = vi.fn();
|
||||
return {
|
||||
resolveDocumentVisionProvider,
|
||||
resolveDocumentTextProvider,
|
||||
requireDocumentVisionProvider: vi.fn((userId: string) =>
|
||||
required(resolveDocumentVisionProvider(userId)),
|
||||
),
|
||||
requireDocumentTextProvider: vi.fn((userId: string) =>
|
||||
required(resolveDocumentTextProvider(userId)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The capability gate, open. Pair with `vi.mock("@/lib/ai/capabilities/gate", …)`. */
|
||||
export function openCapabilityGateMock() {
|
||||
return {
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
getAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user