Record why a document's index attempt failed and say so

A content-index attempt that failed (raster failure, a scan with no
text layer, a provider error) recorded its reason only in the server's
observability stream. In the vault, hasContentIndex false looked
identical for a document nobody ever tried and one that failed three
times, and the detail sheet had nothing to say about either.

Apply the summaryState pattern to the index. Two additive columns on
InboundDocument (migration 0333) record when the last attempt finished
and why it produced no index, as a closed reason set: local-empty,
local-unsupported, decrypt-error, raster-failed, provider-error,
pdf-needs-anthropic, empty-transcription. The writer sits at the choke
point the auto job and the corpus backfill share, and in the manual
index route; a success clears the reason. The provider path's specific
failure now survives the local fallback, since "no text" is less
actionable than "the PDF would not render". rasterizePdf names its
failure class so a build that cannot rasterize at all is distinguished
from a PDF that would not render. New guard in both index paths: an
empty provider transcription never becomes a successful empty index.

The detail sheet renders the reason in plain words (muted, six
locales). The DTO and OpenAPI carry the two fields; the disaster
recovery backup and restore carry them verbatim like their summary
siblings.

Refs #776
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 12:40:50 +02:00
1 parent 45dc93932a
commit 893db887e9
29 files changed
+786 -33

No files matched your search

@@ -49,6 +49,7 @@ import {
} from "@/lib/documents/ai-route-support";
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { upsertContentIndex } from "@/lib/documents/content-index";
import { recordIndexAttempt } from "@/lib/documents/index-document";
import {
DocumentDescribeError,
transcribeDocument,
@@ -93,6 +94,15 @@ async function finishIndex(
source: "vision" | "text-ocr",
tokenCount: number,
): Promise<Response> {
// Refs #776 — the manual route is the second writer of the attempt record
// (the auto job + backfill share `indexLoadedDocument`): a success clears
// any stored failure reason so the detail view stops explaining a problem
// that no longer exists.
await recordIndexAttempt(userId, documentId, {
indexed: true,
source,
tokenCount,
});
await auditLog("documents.inbound.index", {
userId,
ipAddress: getClientIp(request),
@@ -183,21 +193,44 @@ async function handleVisionIndex(
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
// Refs #776 — each failure is also recorded on the row so the detail view
// can explain the missing index after the toast is gone.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "pdf-needs-anthropic",
});
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
if (vision.reason === "rasterFailed") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "raster-failed",
});
return apiError("The PDF pages couldn't be rendered for scanning.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
if (vision.reason === "fileType") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "local-unsupported",
});
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "decrypt-error",
});
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -230,6 +263,22 @@ async function handleVisionIndex(
reservation.reserved,
dateKey,
);
// Refs #776 — the empty-transcription guard, same contract as the auto
// path (`tryProviderIndex`): a provider answer with no text must never
// become a "successful" empty index. The spend stays charged (the
// provider was called); the honest answer is an error plus the recorded
// reason.
if (text.trim().length === 0) {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "empty-transcription",
});
return apiError(
"The provider returned no text for this document. Try a clearer copy.",
422,
{ errorCode: "documents.inbound.extractFailed" },
);
}
const { tokenCount } = await upsertContentIndex({
userId,
documentId: document.id,
@@ -240,6 +289,10 @@ async function handleVisionIndex(
return finishIndex(request, userId, document.id, "vision", tokenCount);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "provider-error",
});
if (err instanceof DocumentDescribeError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",