mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(documents): bound an extraction that runs inside the request
Without Prefer: respond-async the extract route ran the read with no schedule, so a twenty-page report read a page at a time, with a retry each, could make forty model calls in one HTTP request. The iPhone app sends no Prefer header and reads the document detail from the answer, so the request keeps its 200 and its body. An extraction without a run deadline now plans at most three reads (six calls): a longer document has its pages or text shared out between them, and what does not fit is reported as extractionIncomplete on the document. The background run keeps reading it a page at a time. Refs #1257
This commit is contained in:
1 parent
958d230b9f
commit
c9481b672c
5 files changed
+100
-18
No files matched your search
+14
-11
@@ -19767,17 +19767,20 @@ paths:
|
||||
\"stored\" }` structures the document's own stored extracted text (its content index) — the manual recovery for
|
||||
a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. A long
|
||||
document is read a page or a part at a time under the same per-read limits as the lab scan (`LABS_OCR_EXTRACT`),
|
||||
each read after the first reserved against the day's budget on its own; an extraction in which no read produced
|
||||
a complete fact fails with 422 `documents.inbound.outputLimit` when the reads stopped at the provider's output
|
||||
limit, 429 `documents.inbound.budgetExceeded` when the budget refused the reads, and 422
|
||||
`documents.inbound.extractFailed` only when the provider's answers were not the schema. Extraction reproduces
|
||||
what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is
|
||||
the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the background: after the same
|
||||
quick refusals the route answers 202 with a run id, a second request while an extraction of this document from
|
||||
the same input is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves
|
||||
`DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete` when part of the document
|
||||
may be missing from the staged facts; not the document detail) as the run's `result`. Staging re-checks the
|
||||
document under a row lock, so a review finished while the read ran fails the run with
|
||||
each read after the first reserved against the day's budget on its own. Without `Prefer: respond-async` the
|
||||
extraction runs inside the request and makes at most three reads (six model calls): a longer document has its
|
||||
pages or text shared out between them, and what does not fit is left out and reported as `extractionIncomplete:
|
||||
true` on the answered document; the background run reads it a page at a time within its own deadline; an
|
||||
extraction in which no read produced a complete fact fails with 422 `documents.inbound.outputLimit` when the
|
||||
reads stopped at the provider's output limit, 429 `documents.inbound.budgetExceeded` when the budget refused the
|
||||
reads, and 422 `documents.inbound.extractFailed` only when the provider's answers were not the schema.
|
||||
Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores
|
||||
here; the confirm route is the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the
|
||||
background: after the same quick refusals the route answers 202 with a run id, a second request while an
|
||||
extraction of this document from the same input is queued or running answers with that run, and `GET
|
||||
/api/ai-runs/{id}` serves `DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete`
|
||||
when part of the document may be missing from the staged facts; not the document detail) as the run's `result`.
|
||||
Staging re-checks the document under a row lock, so a review finished while the read ran fails the run with
|
||||
`documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the
|
||||
review untouched; the synchronous form answers the same in that race."
|
||||
parameters:
|
||||
|
||||
@@ -30,9 +30,13 @@
|
||||
* answered here, then the route answers 202 with a run id. The run's result is
|
||||
* `{ documentId, factsStaged, status }` rather than the whole detail (the
|
||||
* facts are staged on the document; the client reads them from there).
|
||||
* Without the header the route behaves exactly as before; the iPhone app
|
||||
* relies on that. Both paths run one body (`executeDocumentExtract`), which
|
||||
* re-checks the document under a row lock before it stages anything.
|
||||
* Without the header the route answers the document detail as before; the
|
||||
* iPhone app relies on that. Both paths run one body (`executeDocumentExtract`),
|
||||
* which re-checks the document under a row lock before it stages anything.
|
||||
* Inside the request the reads are bounded (`SYNC_EXTRACT_MAX_READS`): a
|
||||
* twenty-page report read a page at a time with a retry each was forty model
|
||||
* calls in one HTTP request. What does not fit is reported on the document as
|
||||
* `extractionIncomplete`, and the background run reads it in full.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
|
||||
@@ -267,16 +267,39 @@ function extractionFailure(err: OcrExtractError): AiRunOutcome<never> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most reads an extraction makes inside one HTTP request (the extract
|
||||
* route without `Prefer: respond-async`, which the iPhone app sends). Each
|
||||
* read is up to `OCR_EXTRACT_MODEL_CALLS` model calls, so this bounds the
|
||||
* request at six calls where an unplanned read of a twenty-page report made
|
||||
* forty. A longer document is read in as many reads by sharing its pages or
|
||||
* text out between them, and what does not fit is reported as
|
||||
* `extractionIncomplete` on the document; the background run, which has a
|
||||
* deadline of its own, reads it a page at a time.
|
||||
*/
|
||||
export const SYNC_EXTRACT_MAX_READS = 3;
|
||||
|
||||
/** The read plan of a request: a fixed number of calls, nothing to arm. */
|
||||
export const SYNCHRONOUS_READ_SCHEDULE: OcrSchedule = {
|
||||
maxModelCalls: () => SYNC_EXTRACT_MAX_READS * OCR_EXTRACT_MODEL_CALLS,
|
||||
arm: async () => undefined,
|
||||
};
|
||||
|
||||
/** Extract and stage facts from one owned document. The caller authorised and charged it. */
|
||||
export async function executeDocumentExtract(args: {
|
||||
userId: string;
|
||||
document: LoadedDocument;
|
||||
input: DocumentExtractInput;
|
||||
origin: RunOrigin;
|
||||
/** The run's deadline; absent, the reads are planned without one. */
|
||||
/**
|
||||
* The run's deadline. Absent, the reads are planned for one request
|
||||
* ({@link SYNCHRONOUS_READ_SCHEDULE}): no extraction plans its reads
|
||||
* without a bound.
|
||||
*/
|
||||
schedule?: OcrSchedule;
|
||||
}): Promise<AiRunOutcome<DocumentExtraction>> {
|
||||
const { userId, document, input, origin, schedule } = args;
|
||||
const { userId, document, input, origin } = args;
|
||||
const schedule = args.schedule ?? SYNCHRONOUS_READ_SCHEDULE;
|
||||
const { pick, budget } = input;
|
||||
const servedBy = pick.entry.providerType as ProviderChainType;
|
||||
const { meter, reserveRead, settle } = meteredReadBudget(
|
||||
@@ -301,7 +324,7 @@ export async function executeDocumentExtract(args: {
|
||||
}
|
||||
const plan = await planInboundReads(source, schedule);
|
||||
if (!plan.ok) return refused(pdfUnreadable());
|
||||
await schedule?.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS);
|
||||
await schedule.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS);
|
||||
|
||||
let result: InboundExtractionResult;
|
||||
try {
|
||||
|
||||
@@ -1233,7 +1233,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Extract facts from a stored document",
|
||||
description:
|
||||
"Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (a PDF is rendered and read a page at a time; one that cannot be rendered needs a provider that reads PDFs natively, else 422 `documents.inbound.pdfNeedsAnthropic`); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. A long document is read a page or a part at a time under the same per-read limits as the lab scan (`LABS_OCR_EXTRACT`), each read after the first reserved against the day's budget on its own; an extraction in which no read produced a complete fact fails with 422 `documents.inbound.outputLimit` when the reads stopped at the provider's output limit, 429 `documents.inbound.budgetExceeded` when the budget refused the reads, and 422 `documents.inbound.extractFailed` only when the provider's answers were not the schema. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while an extraction of this document from the same input is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves `DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete` when part of the document may be missing from the staged facts; not the document detail) as the run's `result`. Staging re-checks the document under a row lock, so a review finished while the read ran fails the run with `documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the review untouched; the synchronous form answers the same in that race.",
|
||||
"Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (a PDF is rendered and read a page at a time; one that cannot be rendered needs a provider that reads PDFs natively, else 422 `documents.inbound.pdfNeedsAnthropic`); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. A long document is read a page or a part at a time under the same per-read limits as the lab scan (`LABS_OCR_EXTRACT`), each read after the first reserved against the day's budget on its own. Without `Prefer: respond-async` the extraction runs inside the request and makes at most three reads (six model calls): a longer document has its pages or text shared out between them, and what does not fit is left out and reported as `extractionIncomplete: true` on the answered document; the background run reads it a page at a time within its own deadline; an extraction in which no read produced a complete fact fails with 422 `documents.inbound.outputLimit` when the reads stopped at the provider's output limit, 429 `documents.inbound.budgetExceeded` when the budget refused the reads, and 422 `documents.inbound.extractFailed` only when the provider's answers were not the schema. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while an extraction of this document from the same input is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves `DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete` when part of the document may be missing from the staged facts; not the document detail) as the run's `result`. Staging re-checks the document under a row lock, so a review finished while the read ran fails the run with `documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the review untouched; the synchronous form answers the same in that race.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
|
||||
@@ -924,3 +924,55 @@ describe("an extraction that did not read the whole document", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a synchronous extraction stays inside one request", () => {
|
||||
/** A stored text of many reads' worth: forty lab lines per part. */
|
||||
async function longIndexedDocument(userId: string) {
|
||||
const document = await storeDocument(userId);
|
||||
const { upsertContentIndex } =
|
||||
await import("@/lib/documents/content-index");
|
||||
const lines = Array.from(
|
||||
{ length: 1200 },
|
||||
(_, i) => `Analyte ${i} ${(i % 90) + 10}.0 mg/dL reference 10-100`,
|
||||
);
|
||||
await upsertContentIndex({
|
||||
userId,
|
||||
documentId: document.id,
|
||||
text: lines.join("\n"),
|
||||
source: "text-ocr",
|
||||
providerType: null,
|
||||
});
|
||||
return document;
|
||||
}
|
||||
|
||||
it("reads a long document in a few reads and says what it left out", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await longIndexedDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const res = await postStoredExtract(document.id, false);
|
||||
expect(res.status).toBe(200);
|
||||
const { SYNC_EXTRACT_MAX_READS } =
|
||||
await import("@/lib/documents/ai-runs/extract-run");
|
||||
const args = vi.mocked(runInboundExtraction).mock.calls[0][0];
|
||||
expect(args.chunks.length).toBeGreaterThan(0);
|
||||
expect(args.chunks.length).toBeLessThanOrEqual(SYNC_EXTRACT_MAX_READS);
|
||||
expect(res.body.data).toMatchObject({ extractionIncomplete: true });
|
||||
});
|
||||
|
||||
it("reads it in full in the background", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await longIndexedDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const queued = await postStoredExtract(document.id, true);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
expect(await work(runId)).toBe("succeeded");
|
||||
const { SYNC_EXTRACT_MAX_READS } =
|
||||
await import("@/lib/documents/ai-runs/extract-run");
|
||||
const args = vi.mocked(runInboundExtraction).mock.calls[0][0];
|
||||
expect(args.chunks.length).toBeGreaterThan(SYNC_EXTRACT_MAX_READS);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user