mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Run fact extraction in the background, re-checking under a row lock
The extract route accepts `Prefer: respond-async` for all three inputs (text, stored, vision) and queues a DOCUMENT_EXTRACT run (added to migration 0370). The run's result is the count of staged facts and the document status; the facts themselves are read from the document. Without the header the route answers with the document detail as before; both paths run one shared body. A background read can take minutes, and a review the person finishes in the meantime must not have its approved facts replaced. Staging now locks the document row and asks again whether it may stage, refusing with the route's own alreadyConfirmed / alreadyPartlyConfirmed codes. The synchronous path gets the same re-check. The vault's stored-text extract uses the background path and shows the shared waiting line in place of its hint while the read runs.
This commit is contained in:
1 parent
eda5776886
commit
bb58b40b2a
16 files changed
+1003
-352
No files matched your search
@@ -7,16 +7,18 @@
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*
|
||||
* iOS coordination (v1.40): `POST /api/documents/inbound/{id}/index`,
|
||||
* `…/summary` and `…/suggest` keep their 200 bodies when the request carries
|
||||
* no `Prefer` header. A client that
|
||||
* sends `Prefer: respond-async` gets 202 with a run id and polls the run here;
|
||||
* the result is the same body the route would have answered with.
|
||||
* `…/summary`, `…/suggest` and `…/extract` keep their 200 bodies when the
|
||||
* request carries no `Prefer` header. A client that sends
|
||||
* `Prefer: respond-async` gets 202 with a run id and polls the run here; the
|
||||
* result is the body the route would have answered with, except for extract,
|
||||
* whose run carries `DocumentExtractRunResult`.
|
||||
* `POST /api/labs/ocr/extract` (web only) always answers 202.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
documentExtractRunResult,
|
||||
documentIndexResponse,
|
||||
documentSuggestResponse,
|
||||
documentSummaryResponse,
|
||||
@@ -32,6 +34,7 @@ const aiRun = z
|
||||
"LABS_OCR_EXTRACT",
|
||||
"DOCUMENT_SUMMARY",
|
||||
"DOCUMENT_SUGGEST",
|
||||
"DOCUMENT_EXTRACT",
|
||||
]),
|
||||
documentId: z
|
||||
.string()
|
||||
@@ -44,10 +47,11 @@ const aiRun = z
|
||||
ocrExtractResponse,
|
||||
documentSummaryResponse,
|
||||
documentSuggestResponse,
|
||||
documentExtractRunResult,
|
||||
])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
|
||||
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body). Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
|
||||
),
|
||||
error: z
|
||||
.object({
|
||||
|
||||
@@ -287,6 +287,23 @@ const documentUploadEnvelope = dataEnvelope(
|
||||
"DocumentUploadEnvelope",
|
||||
);
|
||||
|
||||
/**
|
||||
* A `DOCUMENT_EXTRACT` run's result. Smaller than the synchronous extract
|
||||
* body: the facts are staged on the document and read from it.
|
||||
*/
|
||||
export const documentExtractRunResult = z
|
||||
.object({
|
||||
documentId: z.string(),
|
||||
factsStaged: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How many facts were staged PENDING for review. Read them from `GET /api/documents/inbound/{id}`; the confirm route stays the only write into the record.",
|
||||
),
|
||||
status: statusEnum,
|
||||
})
|
||||
.meta({ id: "DocumentExtractRunResult" });
|
||||
|
||||
/** The suggest route's body; also a `DOCUMENT_SUGGEST` run's result. */
|
||||
export const documentSuggestResponse = z
|
||||
.object({
|
||||
@@ -919,7 +936,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 (PDF needs an Anthropic vision provider); 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. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
'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 (PDF needs an Anthropic vision provider); 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. 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 }`, 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",
|
||||
@@ -927,6 +944,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
preferRespondAsyncParameter,
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
@@ -964,6 +982,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and duplicate committed records, so the user must finish reviewing or discard the document first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiRunAccepted,
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user