mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
docs(api): describe the extraction capability refusals
The document, labs-scan, medication-extraction, fenced-chat, attachment, connection-test and auto-read operations document the capability they answer under, the refusal envelope and codes, and the consent rule. The two capability probes publish the capability beside the provider answer.
This commit is contained in:
1 parent
d239d31311
commit
005f645b8b
7 files changed
+524
-248
No files matched your search
+381
-223
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The refusals the extraction group answers with: reading a stored document,
|
||||
* scanning a lab report, and turning typed medication text into a schedule.
|
||||
*
|
||||
* Three capabilities under one operator switch ("Reading documents",
|
||||
* `documentAi`), one consent rule (any provider that leaves the machine needs
|
||||
* an `ai_extraction` or `ai_full` receipt), and one envelope. Written once so
|
||||
* the vault, the labs scan and the medication extractor describe the same
|
||||
* refusal in the same words.
|
||||
*/
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import { errorEnvelope } from "./shared";
|
||||
|
||||
const MODULE_BY_CAPABILITY: Partial<Record<AiCapabilityKey, string>> = {
|
||||
documentAi: "inboundDocuments",
|
||||
labsOcr: "labs",
|
||||
};
|
||||
|
||||
/**
|
||||
* The 403 sentence for one extraction capability (plus any capability the
|
||||
* route also answers under, such as `coach` for a fenced Coach turn).
|
||||
*/
|
||||
export function aiExtractionRefusalDescription(
|
||||
capability: AiCapabilityKey,
|
||||
...alsoUnder: AiCapabilityKey[]
|
||||
): string {
|
||||
const owningModule = MODULE_BY_CAPABILITY[capability];
|
||||
const also =
|
||||
alsoUnder.length === 0
|
||||
? ""
|
||||
: ` The route also answers under ${alsoUnder.map((key) => `\`${key}\``).join(", ")}, with the same envelope and that capability's own codes (\`assistant.disabled.coach\`, \`module.disabled\` with \`meta.module = "coach"\` when the Coach is off for the record).`;
|
||||
return (
|
||||
`AI refusal: the \`${capability}\` 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 switch). " +
|
||||
(owningModule
|
||||
? `\`module.disabled\` with \`meta.module = "${owningModule}"\` — the module is off for this record. `
|
||||
: "") +
|
||||
"`ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's record). " +
|
||||
"`consent.ai.required` — the provider this request would reach leaves the machine and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually picked, so it can differ from the published capability when a text-mode read takes a different provider than a vision read would." +
|
||||
also
|
||||
);
|
||||
}
|
||||
|
||||
/** The 503 an extraction route answers when the capability could not be resolved. */
|
||||
export const AI_EXTRACTION_UNAVAILABLE_DESCRIPTION =
|
||||
"`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later.";
|
||||
|
||||
type AiExtractionRefusals = {
|
||||
"403": {
|
||||
description: string;
|
||||
content: { "application/json": { schema: typeof errorEnvelope } };
|
||||
};
|
||||
"503": {
|
||||
description: string;
|
||||
content: { "application/json": { schema: typeof errorEnvelope } };
|
||||
};
|
||||
};
|
||||
|
||||
// Memoised so identical responses are one object: the YAML emitter aliases by
|
||||
// identity, and a fresh object per operation would print the paragraph again.
|
||||
const cache = new Map<string, AiExtractionRefusals>();
|
||||
|
||||
/** `{ "403", "503" }` for an extraction route. Spread before `stdResponses`. */
|
||||
export function aiExtractionRefusals(
|
||||
capability: AiCapabilityKey,
|
||||
...alsoUnder: AiCapabilityKey[]
|
||||
): AiExtractionRefusals {
|
||||
const key = [capability, ...alsoUnder].join(",");
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
const refusals: AiExtractionRefusals = {
|
||||
"403": {
|
||||
description: aiExtractionRefusalDescription(capability, ...alsoUnder),
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
cache.set(key, refusals);
|
||||
return refusals;
|
||||
}
|
||||
@@ -58,6 +58,11 @@ import {
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import {
|
||||
AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
aiExtractionRefusalDescription,
|
||||
aiExtractionRefusals,
|
||||
} from "./ai-extraction-refusals";
|
||||
|
||||
// The assigning form, deliberately: `schema.meta({...})` as a bare statement
|
||||
// returns a clone and registers nothing, so the component id would never
|
||||
@@ -936,7 +941,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Send a FENCED multi-document coach turn (streaming reply)",
|
||||
description:
|
||||
"v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a coach conversation, as Server-Sent Events (`text/event-stream`: one `data: <json>\\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields marker-scrubbed); the single picked provider is egress-consent-checked once per attached document (403 `consent.ai.required` if any fails); the reply is numerically grounded against the LIVE attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer.",
|
||||
"v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a coach conversation, as Server-Sent Events (`text/event-stream`: one `data: <json>\\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields marker-scrubbed); answers under the `coach` and `documentAi` capabilities, both re-checked for the single picked provider immediately before anything is sent (403 with the capability envelope when either is closed; `consent.ai.required` when that provider leaves the machine without an `ai_extraction` / `ai_full` receipt; no provider is the `documents.chat.provider.none` error frame); the reply is numerically grounded against the LIVE attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -959,7 +964,12 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"AI consent required for an external provider (`errorCode: consent.ai.required`). A 422 (`stdResponses`) covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`).",
|
||||
aiExtractionRefusalDescription("documentAi", "coach") +
|
||||
" A 422 (`stdResponses`) covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
@@ -971,7 +981,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Attach a document to a coach conversation",
|
||||
description:
|
||||
"v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer.",
|
||||
"v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. Module-gated on `inboundDocuments`; answers under the `coach` and `documentAi` capabilities (nothing is sent to a provider here, so a missing provider or consent receipt is left to the next turn); rate-limited. Detaching (DELETE) is never refused for AI reasons: it removes the person\'s own data. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1003,6 +1013,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "Conversation or document not found / not owned.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi", "coach"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
INBOUND_DOCUMENT_STATUSES,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { aiExtractionRefusals } from "./ai-extraction-refusals";
|
||||
import { aiCapabilityState } from "./profile";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -208,7 +210,7 @@ const inboundDocumentDetail = inboundDocument
|
||||
.meta({
|
||||
id: "InboundDocumentDetail",
|
||||
description:
|
||||
'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.',
|
||||
'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, reading documents with AI was closed for the record at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.',
|
||||
});
|
||||
|
||||
const listResponse = z
|
||||
@@ -440,7 +442,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Probe document-AI availability + egress",
|
||||
description:
|
||||
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active AI-consent receipt.',
|
||||
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active `ai_extraction` or `ai_full` consent receipt. `ai` is the `documentAi` capability for the record: when the operator turned reading documents off, the module is off, or the record is somebody else\'s, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the read offered (`ai.reason = "consent_required"`), because the read is where the person is asked for it.',
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Document-AI capability flags + egress class.",
|
||||
@@ -456,6 +458,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
egress: z.enum(["local", "external"]).nullable(),
|
||||
ai: aiCapabilityState,
|
||||
})
|
||||
.meta({ id: "DocumentAiCapability" }),
|
||||
"DocumentAiCapabilityEnvelope",
|
||||
@@ -492,7 +495,11 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
assistAvailable: z.boolean(),
|
||||
assistAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether a document read can run for this record now: a provider can serve it and the `documentAi` capability is not closed by the operator, the module or the sharing grant. The same answer as `available` on GET /api/documents/inbound/capability.",
|
||||
),
|
||||
contentIndex: z.object({
|
||||
enabled: z.boolean(),
|
||||
indexedCount: z.number(),
|
||||
@@ -739,7 +746,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. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — 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.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -784,6 +791,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 } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -793,7 +801,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Suggest filing metadata (drafts only)",
|
||||
description:
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -829,6 +837,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -838,7 +847,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Summarise or transcribe a document",
|
||||
description:
|
||||
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.',
|
||||
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
{
|
||||
@@ -903,6 +912,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -912,7 +922,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Build / refresh the content-search index",
|
||||
description:
|
||||
"Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs.",
|
||||
"Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs.",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -951,6 +961,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -960,7 +971,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Chat about a document (streaming reply)",
|
||||
description:
|
||||
"v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer.",
|
||||
"v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). Answers under the `documentAi` capability (403 with the capability envelope when it is closed; `consent.ai.required` for a provider that leaves the machine without an `ai_extraction` / `ai_full` receipt), budget- and rate-limited; no provider is the `documents.chat.provider.none` error frame. Reading the history (GET) stays available whatever the capability says. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -984,11 +995,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"AI consent required for an external provider (`errorCode: consent.ai.required`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -1044,7 +1051,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Index all documents for content search",
|
||||
description:
|
||||
"Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and the existing AI consent (403 otherwise). Returns immediately; the work runs off-request on the queue.",
|
||||
"Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, the `documentAi` capability (403 with the capability envelope), a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and an `ai_extraction` / `ai_full` consent receipt when that provider leaves the machine (403 `consent.ai.required`). Returns immediately; the work runs off-request on the queue, which re-checks the capability before every read.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
@@ -1060,6 +1067,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { aiExtractionRefusalDescription } from "../ai-extraction-refusals";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
@@ -1336,7 +1338,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary:
|
||||
"Extract scheduling fields from a free-text medication description",
|
||||
description:
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.",
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Answers under the `medicationExtract` capability (the operator's reading-documents switch; free-text medication input is health data, so any provider in the chain that leaves the machine needs an active `ai_extraction` or `ai_full` consent receipt). Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget (429 `coach.budget.exceeded`).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1360,9 +1362,13 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Upstream provider returned an empty, unparseable, or off-schema reply.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description: aiExtractionRefusalDescription("medicationExtract"),
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No AI provider configured for the calling user (or operator).",
|
||||
'No answer could be produced. `meta.errorCode = "ai.provider.none"` (with `meta.capability` and `meta.reason = "no_provider"`): no AI provider is configured for the calling user or the operator — the status predates the capability envelope and is kept for clients that branch on it. `ai.unavailable`: the capability could not be resolved and failed closed. No code: every provider in the chain failed.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
|
||||
@@ -17,6 +17,8 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { aiExtractionRefusals } from "./ai-extraction-refusals";
|
||||
import { aiCapabilityState } from "./profile";
|
||||
import {
|
||||
dataEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
@@ -40,11 +42,12 @@ const capabilityResponse = z
|
||||
mode: z.enum(["vision", "text"]).nullable(),
|
||||
reason: z.enum(["no-provider", "enable-local-ocr"]).nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
ai: aiCapabilityState,
|
||||
})
|
||||
.meta({
|
||||
id: "OcrCapabilityResponse",
|
||||
description:
|
||||
"Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the server-side rasterizer can render the pages for any other vision provider. No provider call is made.",
|
||||
"Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the server-side rasterizer can render the pages for any other vision provider. `ai` is the `labsOcr` capability for the record: when the operator turned reading documents off, the labs module is off, or the record is somebody else\'s, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the scan offered (`ai.reason = \"consent_required\"`), because the scan is where the person is asked for it. No provider call is made.",
|
||||
});
|
||||
|
||||
const extractConfidence = z.object({
|
||||
@@ -165,7 +168,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Extract lab readings from a photo, PDF, or OCR'd text",
|
||||
description:
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -200,6 +203,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("labsOcr"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1044,7 +1044,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Probe the AI provider connection",
|
||||
description:
|
||||
"Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on `<!DOCTYPE`. So a provider failure comes back as **200 with `ok: false`** plus a categorised, secret-free `reasonCode`. Branch on `ok`, not on the status. The provider's own error text and body excerpt are logged for the operator and never put on the wire.\n\nThe probe is metered on the same daily AI budget every other AI surface writes to, because with an empty body it can resolve the OPERATOR's shared key — unmetered, that would be invisible spend on a surface that exists to answer 'are my settings right?'. A probe that fails is refunded.\n\nTwo ceilings: 5 per minute and 50 per day, per user.",
|
||||
"Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on `<!DOCTYPE`. So a provider failure comes back as **200 with `ok: false`** plus a categorised, secret-free `reasonCode`. Branch on `ok`, not on the status. The provider's own error text and body excerpt are logged for the operator and never put on the wire.\n\nThe probe is metered on the same daily AI budget every other AI surface writes to, because with an empty body it can resolve the OPERATOR's shared key — unmetered, that would be invisible spend on a surface that exists to answer 'are my settings right?'. A probe that fails is refunded.\n\nTwo ceilings: 5 per minute and 50 per day, per user.\n\nWith the operator\'s master AI switch off the probe is refused (403 `assistant.disabled.enabled`, `meta.reason = \"operator_disabled\"`): no AI call leaves the server then, and this is an AI call. When the switches cannot be read the answer fails closed with 403 `ai.unavailable` (`meta.reason = \"check_failed\"`) rather than the usual 503, for the no-5xx reason above.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: aiTestRequest } },
|
||||
@@ -1059,6 +1059,11 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
'The operator\'s master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches could not be read (`ai.unavailable`). Nothing was sent.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
@@ -1071,12 +1076,12 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which.",
|
||||
'Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which; a resolved configuration with no provider at all carries `meta.errorCode = "ai.provider.none"`.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"One of three ceilings: 5 probes per minute, 50 per day, or the account's daily AI token budget is exhausted. The message distinguishes them.",
|
||||
'One of three ceilings: 5 probes per minute, 50 per day, or the account\'s daily AI token budget is exhausted. The message distinguishes them; the budget refusal also carries `meta.errorCode = "ai.budget.exceeded"`.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -1820,7 +1825,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Set the automatic document-reading opt-in",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_extraction` `ConsentReceipt` unless one that covers document reads (`ai_extraction` or `ai_full`) is already active. The receipt covers reading documents, lab reports and medication text and nothing else: the Coach and the AI analysis keep asking for their own consent (earlier releases minted `ai_full` here, and those receipts stay valid). DELETE /api/consent/ai/latest?kind=ai_extraction revokes it without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads; it is skipped while the `documentAi` capability is closed for the record. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
Reference in new issue
Block a user