mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(documents): review and extract lab values on the document itself
The server chain existed end to end (extract stages PENDING facts, per-fact
edit clears the low-confidence gate, confirm is the only write into the
structured stores) but the document detail offered no control over any of
it: it counted observation facts and linked to Labs. A user whose automatic
staging run was skipped had to re-upload the same PDF through Labs > Scan a
report.
- The detail sheet gains a staged-facts block with honest states: pending
facts open an inline review (approve/reject per fact; a low-confidence
fact is checked and saved first via the existing per-fact edit); a
lab-filed, already-read document with nothing staged offers "Extract lab
values" over its stored text; otherwise nothing renders.
- The extract route gains { mode: "stored" }: the same text-structuring
pass the automatic worker runs, over the document's own content index.
No re-upload, no second read of the original; full gauntlet including
the shared document-AI bucket with charge-at-dispatch honesty; 422
notIndexed when no stored text exists.
- A manual "Read with AI" / "Read again" now continues into the same
lab auto-staging the background worker performs; the response reports
labFactsStaged and the toast names the values waiting for review.
- Unchecked values are rejected and the copy says so; an unverified
low-confidence fact receives no decision and stays pending rather than
being silently discarded. Confirmation remains the only write into
Labs, conditions, and medications; approved facts carry the
committedRecordId link back to the source document (existing model, no
schema change).
- The "lab values from this document" link now counts APPROVED facts
only — it claims values that actually exist in Labs.
- Copy in all six locales; OpenAPI regenerated for the new extract mode
and the index response field.
This commit is contained in:
1 parent
8e16df6894
commit
fd104dd0ac
20 files changed
+1681
-64
No files matched your search
@@ -8,16 +8,31 @@ import { NextRequest } from "next/server";
|
||||
* 422s on the ENHANCEMENT only — the already-stored document is left intact
|
||||
* (no fact-staging transaction, no status flip). This is the inverse of the
|
||||
* old behaviour where the absence of a provider blocked the upload itself.
|
||||
*
|
||||
* STORED mode — `{ mode: "stored" }` structures the document's OWN stored
|
||||
* extracted text (its content index) into staged facts: the manual recovery
|
||||
* for a skipped/failed automatic staging run. Pins that the stored text is
|
||||
* the input (the encrypted original is never touched — no re-upload), that
|
||||
* no stored text 422s (`documents.inbound.notIndexed`) before any bucket
|
||||
* charge, that the shared document-AI bucket applies, and that the
|
||||
* approved-facts re-extraction refusal covers this mode too.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tx = {
|
||||
extractedFact: { deleteMany: vi.fn() },
|
||||
inboundDocument: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
__tx: tx,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
@@ -28,6 +43,19 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
encryptFactData: vi.fn(() => new Uint8Array([1])),
|
||||
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
|
||||
serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
loadDocumentChatText: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
@@ -54,6 +82,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }),
|
||||
refundRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -76,8 +105,28 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { runInboundExtraction } from "@/lib/documents/extract";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
|
||||
const tx = (
|
||||
prisma as unknown as {
|
||||
__tx: {
|
||||
extractedFact: { deleteMany: ReturnType<typeof vi.fn> };
|
||||
inboundDocument: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).__tx;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -94,6 +143,17 @@ function visionReq(id: string): NextRequest {
|
||||
);
|
||||
}
|
||||
|
||||
function storedReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/extract`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "stored" }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
@@ -106,6 +166,11 @@ beforeEach(() => {
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
@@ -161,3 +226,96 @@ describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadDocumentChatText).mockResolvedValue({
|
||||
text: "Hemoglobin 13.9 g/dL reference range 12-16",
|
||||
source: "verbatim",
|
||||
} as never);
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
pick: {
|
||||
providerType: "anthropic",
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(runInboundExtraction).mockResolvedValue({
|
||||
providerType: "anthropic",
|
||||
reportDate: "2026-08-01",
|
||||
facts: [
|
||||
{
|
||||
factType: "OBSERVATION",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
data: { label: "Hemoglobin", value: 13.9, unit: "g/dL" },
|
||||
provenance: {
|
||||
sourceText: "",
|
||||
anchored: false,
|
||||
sourceOffset: null,
|
||||
page: null,
|
||||
confidence: 0.95,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
tx.inboundDocument.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "doc-1",
|
||||
facts: [{ id: "fact-1" }],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("structures the stored text and stages PENDING facts, never touching the original", async () => {
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
// The stored extracted text is the input…
|
||||
expect(runInboundExtraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ocrText: "Hemoglobin 13.9 g/dL reference range 12-16",
|
||||
}),
|
||||
);
|
||||
// …and the encrypted original is never read — no re-upload, no re-scan.
|
||||
expect(decryptDocumentContent).not.toHaveBeenCalled();
|
||||
// Staged through the guarded transaction (prior PENDING leftovers cleared,
|
||||
// status flipped) — nothing here writes a structured store.
|
||||
expect(tx.extractedFact.deleteMany).toHaveBeenCalled();
|
||||
expect(tx.inboundDocument.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: "EXTRACTED" }),
|
||||
}),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"documents.inbound.extract",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ mode: "stored" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s (notIndexed) without stored text, before any bucket charge", async () => {
|
||||
vi.mocked(loadDocumentChatText).mockResolvedValue(null as never);
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.notIndexed");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes the shared document-AI bucket honestly", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30 * 60_000,
|
||||
});
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses once any fact is APPROVED, like every other extract mode", async () => {
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(1 as never);
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(409);
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,14 @@
|
||||
* facts — but now against a row that already exists. Absent a provider this 422s
|
||||
* the ENHANCEMENT only; the stored document is untouched and remains filed.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* Three modes:
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
* and run the vision-capable provider over it.
|
||||
* - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the
|
||||
* browser OCR'd the document locally and posts only the text to structure.
|
||||
* - STORED (application/json): `{ mode: "stored" }` — structure the
|
||||
* document's own stored extracted text (its content index), the manual
|
||||
* recovery for a skipped/failed automatic staging run. No re-upload.
|
||||
*
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. The staged facts land PENDING for the mandatory
|
||||
@@ -61,10 +64,14 @@ import {
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
refundDocumentAiSlot,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
inboundStoredExtractSchema,
|
||||
inboundTextExtractSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -177,7 +184,16 @@ export const POST = apiHandler(
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextExtract(request, user.id, document);
|
||||
// One body read for both JSON modes, BEFORE any bucket charge — a
|
||||
// malformed body never needs a refund because nothing was charged yet.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (inboundStoredExtractSchema.safeParse(body).success) {
|
||||
return handleStoredExtract(request, user.id, document);
|
||||
}
|
||||
return handleTextExtract(request, user.id, document, body);
|
||||
}
|
||||
return handleVisionExtract(request, user.id, document);
|
||||
},
|
||||
@@ -188,6 +204,7 @@ async function handleTextExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
body: unknown,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -216,14 +233,6 @@ async function handleTextExtract(
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -289,6 +298,103 @@ async function handleTextExtract(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORED mode — structure the document's OWN stored extracted text (the
|
||||
* content index the read/index step produced) into staged facts.
|
||||
*
|
||||
* This is the manual recovery for a skipped or failed automatic staging run:
|
||||
* the same text-structuring pass the auto worker performs, but user-triggered
|
||||
* from the document detail. No re-upload, no second read of the original, no
|
||||
* browser OCR — so the local-OCR opt-in is not required here. Everything else
|
||||
* runs the full gauntlet: provider resolve → consent → rate limit (charged
|
||||
* only when the provider is actually dispatched) → budget → stage PENDING for
|
||||
* the mandatory review-then-confirm step. Nothing is committed here.
|
||||
*/
|
||||
async function handleStoredExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const chat = await loadDocumentChatText(userId, document.id);
|
||||
if (!chat || !chat.text.trim()) {
|
||||
return apiError("Read the document first, then extract.", 422, {
|
||||
errorCode: "documents.inbound.notIndexed",
|
||||
});
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: chat.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "stored",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "stored" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run the stored original through the vision provider. */
|
||||
async function handleVisionExtract(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock("@/lib/documents/content-index", () => ({
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
maybeAutoStageLabFacts: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ staged: false, reason: "not-lab" }),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
@@ -79,6 +84,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
@@ -260,3 +266,54 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index — staging continuation", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "hemoglobin 14.2 reference range 12-16",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("a manual read continues into the same lab staging the auto worker performs", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
|
||||
staged: true,
|
||||
facts: 4,
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(maybeAutoStageLabFacts).toHaveBeenCalledWith("user-1", "doc-1");
|
||||
const body = await res.json();
|
||||
expect(body.data.labFactsStaged).toBe(4);
|
||||
});
|
||||
|
||||
it("reports zero staged facts as an honest zero, never an error", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
|
||||
staged: false,
|
||||
reason: "not-lab",
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.labFactsStaged).toBe(0);
|
||||
});
|
||||
|
||||
it("a staging failure never fails the index that just succeeded", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockRejectedValue(new Error("boom"));
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toMatchObject({ indexed: true, labFactsStaged: 0 });
|
||||
});
|
||||
|
||||
it("does not reach staging when the index itself was refused", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(maybeAutoStageLabFacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
refundDocumentAiSlot,
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
import {
|
||||
DocumentDescribeError,
|
||||
@@ -101,7 +102,17 @@ async function finishIndex(
|
||||
action: { name: "documents.contentIndex.upsert" },
|
||||
meta: { documentId, source, tokens: tokenCount },
|
||||
});
|
||||
return apiSuccess({ documentId, indexed: true, tokenCount });
|
||||
// A manual read continues into the SAME lab staging the automatic index
|
||||
// worker performs, so a skipped or failed auto run is recoverable per
|
||||
// document without a re-upload. Every guard lives inside the helper (both
|
||||
// modules on, still STORED with no facts, provider + consent, looks like a
|
||||
// lab report) — a non-lab document is a tagged no-op, and a staging failure
|
||||
// never fails the index that just succeeded.
|
||||
const staging = await maybeAutoStageLabFacts(userId, documentId).catch(
|
||||
() => null,
|
||||
);
|
||||
const labFactsStaged = staging?.staged === true ? staging.facts : 0;
|
||||
return apiSuccess({ documentId, indexed: true, tokenCount, labFactsStaged });
|
||||
}
|
||||
|
||||
/** TEXT mode — index browser-OCR'd text (no provider egress). */
|
||||
|
||||
Reference in new issue
Block a user