Files
HealthLog/src/lib/documents/index-document.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

392 lines
15 KiB
TypeScript

/**
* Content-index ONE stored document — the shared, AI-first decision tree behind
* both the per-document auto-index-on-upload job and the corpus backfill.
*
* Provider resolution follows the DOCUMENT order (local-first, codex last —
* `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain. That
* pick re-checks the `documentAi` capability for the provider it picked: with
* the operator's switch off, the module off, or (for a pick that leaves the
* machine) no extraction consent receipt, there is no pick and the tree goes
* straight to the provider-free text-layer path below, so search keeps working
* with AI off. The external egress is further governed by the per-user
* `documentsAutoAiRead` opt-in:
*
* 1. LOCAL PROVIDER (a self-hosted vision model) — never egresses, so it runs
* whenever it is the document-order pick, toggle-independent.
* 2. EXTERNAL PROVIDER (codex / BYOK openai|anthropic / admin key) — reads a
* document off the machine, so the auto-index job only uses it when the
* operator opted into `documentsAutoAiRead`. OFF → the job is strictly
* local (never egresses on upload, even if an unrelated consent receipt
* exists); ON → the document-order external pick reads the original (rich,
* handles scanned PDFs + images). Budget-reserved + reconciled exactly like
* the interactive index route; owner-scoped throughout.
* 3. LOCAL TEXT-LAYER (fallback). When no provider is usable — none
* configured, the toggle is OFF for an external pick, the provider can't
* read this file (e.g. a text-layer PDF on a non-Anthropic account), the
* daily budget is reached, or the provider call fails — fall back to
* server-side text-layer extraction (`pdf-parse`, no egress, milliseconds).
* So content search works at a baseline even with no AI, and every
* text-layer PDF is searchable regardless of provider.
*
* Both paths write the SAME blind, encrypted index via `upsertContentIndex`
* (AES-256-GCM text + opaque HMAC token tags) — nothing readable at rest. The
* function NEVER throws for an expected outcome; it returns a tagged result the
* caller logs, so a bad document can never abort an upload or a batch.
*/
import { Buffer } from "node:buffer";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
type BudgetCostOwner,
} from "@/lib/ai/coach/budget";
import {
loadOwnedDocument,
prepareVisionInput,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
upsertContentIndex,
type ContentIndexSource,
} from "@/lib/documents/content-index";
import { transcribeDocument } from "@/lib/documents/describe";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import { localExtractText } from "@/lib/documents/local-extract";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { decryptDocumentContent } from "@/lib/documents/store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
/**
* Why one document's index attempt ended without an index. Refs #776 — the
* provider path's specific failures are terminal reasons now: when the local
* fallback can only say "no text", the recorded reason is the provider path's
* more actionable one (a raster failure, a provider error, an empty
* transcription, or a PDF the picked provider cannot take), so the detail
* view can say WHY instead of a bare not-indexed.
*/
export type IndexFailureReason =
| "not-found"
| "local-empty"
| "local-unsupported"
| "decrypt-error"
| "raster-failed"
| "provider-error"
| "pdf-needs-anthropic"
| "empty-transcription";
/**
* Outcome of one document's index attempt. A provider that is not usable (none
* configured, no consent, budget exhausted, cannot read the file, or errored)
* is never itself a terminal outcome — the tree always falls through to the
* free local path — but its failure NOTE survives that fall-through (see
* `IndexFailureReason`).
*/
export type IndexOutcome =
| { indexed: true; source: ContentIndexSource; tokenCount: number }
| { indexed: false; reason: IndexFailureReason };
/**
* Refs #776 — record what became of this attempt on the document row, so a
* missing index is explainable. `lastIndexAttemptAt` always advances;
* `lastIndexOutcome` carries the failure reason, or null on success (the
* `DocumentContentIndex` row itself is the success signal). Called from
* `indexLoadedDocument` (the choke point the auto job and the corpus backfill
* share) and from the manual index route. Owner-scoped; never throws — a
* diagnostic write must not fail the index work it describes.
*/
export async function recordIndexAttempt(
userId: string,
documentId: string,
outcome: IndexOutcome,
): Promise<void> {
// No row to explain — the document is gone or not the caller's.
if (!outcome.indexed && outcome.reason === "not-found") return;
try {
await prisma.inboundDocument.updateMany({
where: { id: documentId, userId, deletedAt: null },
data: {
lastIndexAttemptAt: new Date(),
lastIndexOutcome: outcome.indexed ? null : outcome.reason,
},
});
} catch (err) {
annotate({
action: { name: "documents.contentIndex.recordFailed" },
meta: { documentId, reason: err instanceof Error ? err.name : "unknown" },
});
}
}
/**
* A resolved, consent-checked provider context. Resolve ONCE per user and reuse
* across a batch so the corpus backfill does not re-read the provider chain per
* document. `usable` is true only when a vision pick exists AND consent passed.
*/
export interface ResolvedIndexProvider {
chain: ProviderChainResolved[];
pick: Awaited<ReturnType<typeof resolveDocumentVisionProvider>>["pick"];
consentOk: boolean;
dailyCap: number;
/**
* v1.38.19 — whose budget `dailyCap` rations. The reservation books
* `operator_tokens` only for an operator-funded pick, so the cap and the
* counter it is compared against are resolved from one place.
*/
costOwner: BudgetCostOwner;
}
/**
* Resolve the DOCUMENT-order vision provider and decide egress eligibility once.
* A local pick is always eligible (it never leaves the machine). An external
* pick is eligible ONLY when the operator opted into `documentsAutoAiRead` — so
* the auto-index job never silently egresses a freshly uploaded document unless
* that toggle is ON. A missing pick or an ineligible external pick both yield an
* unusable context, and the caller falls straight to the local text-layer path.
*/
export async function resolveIndexProvider(
userId: string,
): Promise<ResolvedIndexProvider> {
// A pick the `documentAi` capability refused comes back null, which lands on
// the local text-layer path exactly like having no provider at all.
const { chain, pick, withheld } = await resolveDocumentVisionProvider(userId);
if (withheld) {
annotate({
action: { name: "documents.contentIndex.providerWithheld" },
meta: { reason: withheld.reason },
});
}
let consentOk = false;
if (pick) {
if (!isExternalDocumentEgress(pick.providerType)) {
// A local (self-hosted) vision pick never egresses — always eligible,
// toggle-independent.
consentOk = true;
} else {
// An external pick reads the document off the machine. The auto-index job
// only egresses on upload when the operator opted in; the toggle IS the
// standing consent (the document consent gate short-circuits on it). OFF
// → local-only, even if an unrelated receipt exists.
consentOk = await documentAutoReadEnabled(userId);
}
}
const dailyCap =
pick && consentOk
? resolveDailyCap([{ providerType: pick.entry.providerType }])
: 0;
// v1.38.19 — the cap and its cost owner are resolved from the SAME
// pick, so the reservation books the counter the cap is enforced against.
const costOwner: BudgetCostOwner =
pick && consentOk
? resolveCostOwner([{ providerType: pick.entry.providerType }])
: "operator";
return { chain, pick, consentOk, dailyCap, costOwner };
}
/**
* The provider path's failure note when it falls through to local without a
* terminal outcome. Refs #776 — the note survives the fall-through: when the
* local path can only report "no text", the note is the recorded reason.
*/
type ProviderPathNote =
| "raster-failed"
| "pdf-needs-anthropic"
| "provider-error"
| "empty-transcription";
interface ProviderPathResult {
outcome: IndexOutcome | null;
note: ProviderPathNote | null;
}
/**
* Try the PROVIDER (vision) path for one already-loaded document. Returns a
* terminal outcome on success / decrypt-error, or `outcome: null` to signal
* "fall through to local" (no usable provider, budget out, or the provider
* cannot read this particular file) — with a `note` naming the specific
* provider-side failure when there was one.
*/
async function tryProviderIndex(
userId: string,
document: LoadedDocument,
provider: ResolvedIndexProvider,
): Promise<ProviderPathResult> {
const { pick } = provider;
if (!pick || !provider.consentOk) return { outcome: null, note: null };
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// A decrypt failure is terminal (local would fail the same way); anything
// else falls through to the local path — a text-layer PDF on a
// non-Anthropic account is read locally for free. The PDF-specific
// failures keep their name as the note.
if (vision.reason === "decryptFailed") {
return {
outcome: { indexed: false, reason: "decrypt-error" },
note: null,
};
}
if (vision.reason === "rasterFailed") {
return { outcome: null, note: "raster-failed" };
}
if (vision.reason === "pdfNeedsAnthropic") {
return { outcome: null, note: "pdf-needs-anthropic" };
}
return { outcome: null, note: null };
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentTranscribe.maxTokens,
dateKey,
provider.dailyCap,
provider.costOwner,
"coach",
);
// Budget exhausted → fall through to the free local path rather than stall;
// a text-layer PDF stays searchable even once the AI allowance is spent.
if (!reservation.allowed) return { outcome: null, note: null };
try {
const { text } = await transcribeDocument({
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// Refs #776 — the empty-transcription guard: a provider answer with no
// text must NEVER become a "successful" empty index (it would mark the
// document indexed while making it unfindable). Fall through to local —
// a text-layer PDF still lands its text — and keep the note. The spend
// stays reconciled at the full amount: the provider was called.
if (text.trim().length === 0) {
return { outcome: null, note: "empty-transcription" };
}
const { tokenCount } = await upsertContentIndex({
userId,
documentId: document.id,
text,
source: "vision",
providerType: pick.providerType,
});
return {
outcome: { indexed: true, source: "vision", tokenCount },
note: null,
};
} catch {
// Refund the reservation and let the caller fall through to local — a
// transient provider miss must never leave a text-layer PDF unsearchable.
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return { outcome: null, note: "provider-error" };
}
}
/** Try the LOCAL (provider-free) text-layer path for one loaded document. */
async function tryLocalIndex(
userId: string,
document: LoadedDocument,
): Promise<IndexOutcome> {
let buffer: Buffer;
try {
buffer = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
return { indexed: false, reason: "decrypt-error" };
}
// Re-derive the MIME from the bytes (never trust the stored label), matching
// the provider path's posture; fall back to the stored MIME if unrecognised.
const mime = detectOcrMimeType(buffer) ?? document.mimeType;
const result = await localExtractText(buffer, mime);
if (result.ok) {
const { tokenCount } = await upsertContentIndex({
userId,
documentId: document.id,
text: result.text,
source: result.source,
providerType: null,
});
return { indexed: true, source: result.source, tokenCount };
}
if (result.reason === "unsupported") {
return { indexed: false, reason: "local-unsupported" };
}
if (result.reason === "error") {
return { indexed: false, reason: "decrypt-error" };
}
return { indexed: false, reason: "local-empty" };
}
/**
* Index one already-loaded document: provider-first, local-fallback. Reuses a
* pre-resolved provider context so a batch resolves the chain once.
*
* Refs #776 — this is the choke point the auto job and the corpus backfill
* share, so the attempt record is written HERE: every terminal outcome lands
* on the row via `recordIndexAttempt`, and a provider-path failure note
* survives a mute local fallback ("no text" is less actionable than "the
* provider errored" / "the PDF would not render").
*/
export async function indexLoadedDocument(
userId: string,
document: LoadedDocument,
provider: ResolvedIndexProvider,
): Promise<IndexOutcome> {
const providerResult = await tryProviderIndex(userId, document, provider);
let outcome: IndexOutcome;
if (providerResult.outcome) {
outcome = providerResult.outcome;
} else {
const local = await tryLocalIndex(userId, document);
if (
!local.indexed &&
providerResult.note &&
(local.reason === "local-empty" || local.reason === "local-unsupported")
) {
// The local path can only say "no text" — the provider path knows why
// the richer read never happened. Record the actionable reason.
outcome = { indexed: false, reason: providerResult.note };
} else {
outcome = local;
}
}
await recordIndexAttempt(userId, document.id, outcome);
return outcome;
}
/**
* Index ONE document by id (owner-scoped): resolve the provider, load the
* document, then run the provider-first/local-fallback tree. This is the entry
* point the per-document auto-index-on-upload job calls.
*/
export async function indexDocumentContent(
userId: string,
documentId: string,
): Promise<IndexOutcome> {
const document = await loadOwnedDocument(userId, documentId);
if (!document) return { indexed: false, reason: "not-found" };
const provider = await resolveIndexProvider(userId);
return indexLoadedDocument(userId, document, provider);
}