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:
Marc-André Bombeck committed 2026-10-03 21:14:47 +02:00
1 parent eda5776886
commit bb58b40b2a
16 files changed
+1003 -352

No files matched your search

+66 -29
View File
@@ -16701,13 +16701,32 @@ paths:
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."
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
in: path
required: true
schema:
type: string
- &a37
name: Prefer
in: header
required: false
schema:
type: string
example: respond-async
description: "Send `respond-async` (RFC 7240) to run the read in the background: the route answers 202 with a run id
once every quick refusal has passed, and `GET /api/ai-runs/{id}` serves the result. Without it the route
answers synchronously with the result, as it always has; that form is retained for clients that have not
adopted runs, and its retirement will be announced one minor release ahead. A preference the route cannot
apply is ignored and the response then carries no `Preference-Applied`."
requestBody:
required: false
content:
@@ -16721,8 +16740,9 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/InboundExtractEnvelope"
"202": *a36
"401": *a1
"403": &a37
"403": &a38
description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries
`meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`).
`assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master
@@ -16746,7 +16766,7 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"503": &a38
"503": &a39
description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed.
Nothing was sent. Retry later."
content:
@@ -16776,18 +16796,7 @@ paths:
required: true
schema:
type: string
- &a39
name: Prefer
in: header
required: false
schema:
type: string
example: respond-async
description: "Send `respond-async` (RFC 7240) to run the read in the background: the route answers 202 with a run id
once every quick refusal has passed, and `GET /api/ai-runs/{id}` serves the result. Without it the route
answers synchronously with the result, as it always has; that form is retained for clients that have not
adopted runs, and its retirement will be announced one minor release ahead. A preference the route cannot
apply is ignored and the response then carries no `Preference-Applied`."
- *a37
requestBody:
required: false
content:
@@ -16824,10 +16833,10 @@ paths:
$ref: "#/components/schemas/DocumentSuggestEnvelope"
"202": *a36
"401": *a1
"403": *a37
"403": *a38
"422": *a4
"429": *a5
"503": *a38
"503": *a39
/api/documents/inbound/{id}/summary:
post:
tags:
@@ -16877,7 +16886,7 @@ paths:
type: boolean
default: false
description: With `persist=true`, explicitly replace an existing stored summary.
- *a39
- *a37
requestBody:
required: false
content:
@@ -16914,10 +16923,10 @@ paths:
$ref: "#/components/schemas/DocumentSummaryEnvelope"
"202": *a36
"401": *a1
"403": *a37
"403": *a38
"422": *a4
"429": *a5
"503": *a38
"503": *a39
/api/documents/inbound/{id}/index:
post:
tags:
@@ -16943,7 +16952,7 @@ paths:
required: true
schema:
type: string
- *a39
- *a37
requestBody:
required: false
content:
@@ -16980,10 +16989,10 @@ paths:
$ref: "#/components/schemas/DocumentIndexEnvelope"
"202": *a36
"401": *a1
"403": *a37
"403": *a38
"422": *a4
"429": *a5
"503": *a38
"503": *a39
/api/documents/inbound/{id}/chat:
post:
tags:
@@ -17023,10 +17032,10 @@ paths:
type: string
description: "SSE frames: `data: <json>\\n\\n`. See the operation description for the per-`type` frame shapes."
"401": *a1
"403": *a37
"403": *a38
"422": *a4
"429": *a5
"503": *a38
"503": *a39
get:
tags:
- Documents
@@ -17089,10 +17098,10 @@ paths:
schema:
$ref: "#/components/schemas/DocumentReindexEnvelope"
"401": *a1
"403": *a37
"403": *a38
"422": *a4
"429": *a5
"503": *a38
"503": *a39
/api/documents/inbound/{id}/facts/{factId}:
patch:
tags:
@@ -63669,6 +63678,7 @@ components:
- LABS_OCR_EXTRACT
- DOCUMENT_SUMMARY
- DOCUMENT_SUGGEST
- DOCUMENT_EXTRACT
documentId:
anyOf:
- type: string
@@ -63688,11 +63698,13 @@ components:
- $ref: "#/components/schemas/OcrExtractResponse"
- $ref: "#/components/schemas/DocumentSummaryResponse"
- $ref: "#/components/schemas/DocumentSuggestResponse"
- $ref: "#/components/schemas/DocumentExtractRunResult"
- type: "null"
description: "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."
`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:
anyOf:
- type: object
@@ -63951,6 +63963,31 @@ components:
required:
- suggestions
additionalProperties: false
DocumentExtractRunResult:
type: object
properties:
documentId:
type: string
factsStaged:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: 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:
type: string
enum:
- STORED
- EXTRACTING
- EXTRACTED
- FAILED
- CONFIRMED
- DISCARDED
required:
- documentId
- factsStaged
- status
additionalProperties: false
ListBiomarkersEnvelope:
type: object
properties: