mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
37793298a8
59 files changed
+4736
-905
No files matched your search
@@ -194,6 +194,14 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
* claim the guard checks against the file.
|
||||
*/
|
||||
export const DATA_ROUTES: Readonly<Record<string, string>> = {
|
||||
// v1.40 — the poll for a background document AI run. A plain read of a row
|
||||
// the caller created: every capability question was asked when the run was
|
||||
// queued and again in the worker before anything left, and serving the
|
||||
// answer to a read the person already asked for calls no model. Not
|
||||
// `aiCapabilityToServe` either: a run result is the caller's own request
|
||||
// result, not stored model text shown later.
|
||||
"src/app/api/ai-runs/[id]/route.ts":
|
||||
"The caller's own background run: status, result, failure.",
|
||||
// The design's list of formerly AI-gated data reads and writes.
|
||||
"src/app/api/insights/derived/batch/route.ts":
|
||||
"Deterministic scores and assessments for the whole grid.",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* v1.40 — GET /api/ai-runs/{id}: where one background document AI run stands.
|
||||
*
|
||||
* A plain read of a row the caller created: the run id came back in the 202
|
||||
* of the route that queued it, and the row is scoped to the caller here, so
|
||||
* another account's run (or one that never existed, or one an hour past its
|
||||
* end) is the same 404. No AI gate: serving the answer to a read the person
|
||||
* already asked for calls no model, and every capability question was asked
|
||||
* when the run was queued and again in the worker before anything left.
|
||||
*
|
||||
* `result` is the body the synchronous route answers with (by `kind`), and
|
||||
* `error` carries the code and status it would have answered with, so a
|
||||
* client handles both paths through one table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { readAiRunForUser } from "@/lib/documents/ai-runs/store";
|
||||
import { AI_RUN_ERROR_CODES } from "@/lib/documents/ai-runs/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
const run = await readAiRunForUser(user.id, id);
|
||||
if (!run) {
|
||||
return apiError("Run not found", 404, {
|
||||
errorCode: AI_RUN_ERROR_CODES.notFound,
|
||||
});
|
||||
}
|
||||
const response = apiSuccess(run);
|
||||
// A poll answer is for one person at one instant.
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
if (run.retryAfterMs !== null) {
|
||||
response.headers.set(
|
||||
"Retry-After",
|
||||
String(Math.max(1, Math.ceil(run.retryAfterMs / 1000))),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
@@ -19,46 +19,52 @@
|
||||
* the lab / extract text mode already uses — no provider egress at all.
|
||||
*
|
||||
* Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4).
|
||||
*
|
||||
* v1.40 — `Prefer: respond-async` (RFC 7240) runs the read in the background
|
||||
* worker: every refusal that can be answered quickly is still answered here,
|
||||
* then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves
|
||||
* the same body this route answers with synchronously. Without the header the
|
||||
* route behaves exactly as before; the iPhone app relies on that. Both paths
|
||||
* run one body (`executeDocumentIndex`).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
checkDocumentAiRateLimit,
|
||||
documentAiRateLimited,
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
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 { recordIndexAttempt } from "@/lib/documents/index-document";
|
||||
import {
|
||||
DocumentDescribeError,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
acceptedRunResponse,
|
||||
outcomeResponse,
|
||||
prefersRespondAsync,
|
||||
} from "@/lib/documents/ai-runs/http";
|
||||
import {
|
||||
DOCUMENT_INDEX_RESERVE_TOKENS,
|
||||
executeDocumentIndex,
|
||||
} from "@/lib/documents/ai-runs/index-run";
|
||||
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
|
||||
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
@@ -82,57 +88,26 @@ export const POST = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const background = prefersRespondAsync(request);
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextIndex(request, user.id, document);
|
||||
return handleTextIndex(request, user.id, document, background);
|
||||
}
|
||||
return handleVisionIndex(request, user.id, document);
|
||||
return handleVisionIndex(request, user.id, document, background);
|
||||
},
|
||||
);
|
||||
|
||||
async function finishIndex(
|
||||
request: NextRequest,
|
||||
/**
|
||||
* A read of this document already queued or running: attach to it rather than
|
||||
* charge a second one. Background path only; the synchronous path keeps its
|
||||
* behaviour of one request, one read.
|
||||
*/
|
||||
async function liveRunResponse(
|
||||
userId: string,
|
||||
documentId: string,
|
||||
source: "vision" | "text-ocr",
|
||||
tokenCount: number,
|
||||
): Promise<Response> {
|
||||
// Refs #776 — the manual route is the second writer of the attempt record
|
||||
// (the auto job + backfill share `indexLoadedDocument`): a success clears
|
||||
// any stored failure reason so the detail view stops explaining a problem
|
||||
// that no longer exists.
|
||||
await recordIndexAttempt(userId, documentId, {
|
||||
indexed: true,
|
||||
source,
|
||||
tokenCount,
|
||||
});
|
||||
// Read with AI is the person reading the document on purpose, so an
|
||||
// import's hold on automatic AI reading (`aiRead=defer`) ends here, before
|
||||
// the lab staging below that would otherwise refuse it.
|
||||
await prisma.inboundDocument.updateMany({
|
||||
where: { id: documentId, userId, aiReadDeferred: true },
|
||||
data: { aiReadDeferred: false },
|
||||
});
|
||||
await auditLog("documents.inbound.index", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId, source, tokens: tokenCount },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "documents.contentIndex.upsert" },
|
||||
meta: { documentId, source, tokens: 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 });
|
||||
): Promise<Response | null> {
|
||||
const live = await findLiveDocumentRun(userId, documentId, "DOCUMENT_INDEX");
|
||||
return live ? acceptedRunResponse(live) : null;
|
||||
}
|
||||
|
||||
/** TEXT mode — index browser-OCR'd text (no provider egress). */
|
||||
@@ -140,6 +115,7 @@ async function handleTextIndex(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
background: boolean,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -151,6 +127,11 @@ async function handleTextIndex(
|
||||
});
|
||||
}
|
||||
|
||||
if (background) {
|
||||
const live = await liveRunResponse(userId, document.id);
|
||||
if (live) return live;
|
||||
}
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
@@ -174,14 +155,27 @@ async function handleTextIndex(
|
||||
);
|
||||
}
|
||||
|
||||
const { tokenCount } = await upsertContentIndex({
|
||||
userId,
|
||||
documentId: document.id,
|
||||
text: parsed.data.text,
|
||||
source: "text-ocr",
|
||||
providerType: null,
|
||||
});
|
||||
return finishIndex(request, userId, document.id, "text-ocr", tokenCount);
|
||||
if (background) {
|
||||
// The text is indexed in the worker, which then continues into the same
|
||||
// lab staging (model calls) the synchronous path makes.
|
||||
return startAiRun({
|
||||
userId,
|
||||
kind: "DOCUMENT_INDEX",
|
||||
documentId: document.id,
|
||||
params: { mode: "text" },
|
||||
input: Buffer.from(parsed.data.text, "utf8"),
|
||||
refundSlot: () => refundDocumentAiSlot(userId),
|
||||
});
|
||||
}
|
||||
|
||||
return outcomeResponse(
|
||||
await executeDocumentIndex({
|
||||
userId,
|
||||
document,
|
||||
input: { mode: "text", text: parsed.data.text },
|
||||
origin: { ipAddress: getClientIp(request), worker: false },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** VISION mode — transcribe the stored original, then index the text. */
|
||||
@@ -189,6 +183,7 @@ async function handleVisionIndex(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
background: boolean,
|
||||
): Promise<Response> {
|
||||
// Transcribing the stored original is model work; indexing text the browser
|
||||
// already read (the text mode above) is not, and stays open with AI off so
|
||||
@@ -197,59 +192,18 @@ async function handleVisionIndex(
|
||||
await requireAiCapability("documentAi", { pickDecides: true });
|
||||
const pick = await requireDocumentVisionProvider(userId);
|
||||
|
||||
if (background) {
|
||||
const live = await liveRunResponse(userId, document.id);
|
||||
if (live) return live;
|
||||
}
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const vision = await prepareVisionInput(document, pick.pdfSupported);
|
||||
if (!vision.ok) {
|
||||
// Preparation failed before any provider dispatch — the slot goes back.
|
||||
// Refs #776 — each failure is also recorded on the row so the detail view
|
||||
// can explain the missing index after the toast is gone.
|
||||
await refundDocumentAiSlot(userId);
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "pdf-needs-anthropic",
|
||||
});
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
if (vision.reason === "rasterFailed") {
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "raster-failed",
|
||||
});
|
||||
return apiError("The PDF pages couldn't be rendered for scanning.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
if (vision.reason === "fileType") {
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "local-unsupported",
|
||||
});
|
||||
return apiError(
|
||||
"This document can't be scanned. Use local OCR (text mode).",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.fileType" },
|
||||
);
|
||||
}
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "decrypt-error",
|
||||
});
|
||||
return apiError("Couldn't read the stored document.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
DOCUMENT_INDEX_RESERVE_TOKENS,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
@@ -261,66 +215,30 @@ async function handleVisionIndex(
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
const budget = {
|
||||
reserved: reservation.reserved,
|
||||
owner: reservation.owner,
|
||||
dateKey,
|
||||
};
|
||||
|
||||
try {
|
||||
const { text } = await transcribeDocument({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images: vision.images,
|
||||
documents: vision.documents,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
0,
|
||||
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
|
||||
);
|
||||
// Refs #776 — the empty-transcription guard, same contract as the auto
|
||||
// path (`tryProviderIndex`): a provider answer with no text must never
|
||||
// become a "successful" empty index. The spend stays charged (the
|
||||
// provider was called); the honest answer is an error plus the recorded
|
||||
// reason.
|
||||
if (text.trim().length === 0) {
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "empty-transcription",
|
||||
});
|
||||
return apiError(
|
||||
"The provider returned no text for this document. Try a clearer copy.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.extractFailed" },
|
||||
);
|
||||
}
|
||||
const { tokenCount } = await upsertContentIndex({
|
||||
if (background) {
|
||||
// The worker picks the provider again and re-checks the wire for it right
|
||||
// before the stored original leaves.
|
||||
return startAiRun({
|
||||
userId,
|
||||
kind: "DOCUMENT_INDEX",
|
||||
documentId: document.id,
|
||||
text,
|
||||
source: "vision",
|
||||
providerType: pick.providerType,
|
||||
});
|
||||
return finishIndex(request, userId, document.id, "vision", tokenCount);
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
|
||||
servedBy: null,
|
||||
reservedOwner: reservation.owner,
|
||||
});
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
reason: "provider-error",
|
||||
});
|
||||
if (err instanceof DocumentDescribeError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.contentIndex.failed" },
|
||||
meta: { reason: "provider_error", mode: "vision" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
params: { mode: "vision", budget },
|
||||
refundSlot: () => refundDocumentAiSlot(userId),
|
||||
});
|
||||
}
|
||||
|
||||
return outcomeResponse(
|
||||
await executeDocumentIndex({
|
||||
userId,
|
||||
document,
|
||||
input: { mode: "vision", pick, budget },
|
||||
origin: { ipAddress: getClientIp(request), worker: false },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
|
||||
* v1.20.1 — POST /api/labs/ocr/extract.
|
||||
*
|
||||
* Focus: the text-mode structuring pass reserves the proportionate text budget
|
||||
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
|
||||
* on a clean extraction failure it refunds the reservation in full rather than
|
||||
* charging it.
|
||||
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling.
|
||||
*
|
||||
* v1.40 — the route queues the read and answers 202; the read itself, its
|
||||
* budget settlement and its failures are pinned in
|
||||
* `src/lib/labs/__tests__/ocr-run.test.ts`. What stays here is everything the
|
||||
* route still refuses before anything is queued.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
@@ -48,6 +51,18 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/documents/ai-runs/start", () => ({
|
||||
startAiRun: vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: { runId: "run-1", status: "QUEUED", pollAfterMs: 1500 },
|
||||
error: null,
|
||||
}),
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
|
||||
"@/lib/labs/ocr-extract",
|
||||
@@ -56,6 +71,7 @@ vi.mock("@/lib/labs/ocr-extract", async () => {
|
||||
});
|
||||
|
||||
import { POST } from "../route";
|
||||
import { startAiRun } from "@/lib/documents/ai-runs/start";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -63,10 +79,9 @@ import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -113,10 +128,8 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
// The reservation must use the text ceiling — proven distinct from the
|
||||
// vision ceiling so a future merge can't silently re-point it.
|
||||
@@ -136,61 +149,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refunds the reservation in full on a clean extract failure", async () => {
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
new OcrExtractError("unreadable"),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
// actual spend reconciles to 0 — the failed structuring pass is refunded,
|
||||
// not charged at the reserved estimate.
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
0,
|
||||
"2026-06-26",
|
||||
0,
|
||||
{ servedBy: null, reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies provider failures instead of blaming image quality", async () => {
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
|
||||
|
||||
const res = await POST(textReq());
|
||||
const body = (await res.json()) as { error: string };
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(body.error).toContain("configured AI provider");
|
||||
});
|
||||
|
||||
it("never passes the upstream body on, to the caller or to the event", async () => {
|
||||
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
Object.assign(new Error("Local AI request failed (403)"), {
|
||||
httpStatus: 403,
|
||||
model: "llama3",
|
||||
bodyExcerpt: upstreamBody,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.text()).not.toContain("iam/security-credentials");
|
||||
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
|
||||
expect(events).not.toContain("iam/security-credentials");
|
||||
// The status and the model stay, so an operator can still tell why.
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
it("queues the text, sealed in the run, with the reservation it took", async () => {
|
||||
const res = await POST(textReq("Glucose 95 mg/dL"));
|
||||
expect(res.status).toBe(202);
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
expect(startAiRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
|
||||
userId: "user-1",
|
||||
kind: "LABS_OCR_EXTRACT",
|
||||
params: {
|
||||
mode: "text",
|
||||
budget: {
|
||||
reserved: AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
owner: "operator",
|
||||
dateKey: "2026-06-26",
|
||||
},
|
||||
},
|
||||
input: Buffer.from("Glucose 95 mg/dL", "utf8"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
describe("POST /api/labs/ocr/extract — vision uploads", () => {
|
||||
function pdfReq(): Request {
|
||||
// A minimal `%PDF-` header is all `detectOcrMimeType` needs to sniff a PDF.
|
||||
const bytes = new Uint8Array([
|
||||
@@ -225,50 +206,23 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("rasterizes a PDF for a non-Anthropic vision provider and sends the page images", async () => {
|
||||
vi.mocked(rasterizePdf).mockResolvedValue({
|
||||
ok: true,
|
||||
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
|
||||
pageCount: 1,
|
||||
});
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
|
||||
it("queues a PDF with its sniffed type; the worker renders it", async () => {
|
||||
const res = await POST(pdfReq());
|
||||
expect(res.status).toBe(200);
|
||||
// The whole PDF was read: no coverage note on the response.
|
||||
const whole = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(whole.data).not.toHaveProperty("pageCoverage");
|
||||
expect(rasterizePdf).toHaveBeenCalledOnce();
|
||||
// The rendered page images flow through as `input_image`s; no native PDF
|
||||
// document block is sent for a non-Anthropic provider.
|
||||
expect(runOcrExtraction).toHaveBeenCalledWith(
|
||||
expect(res.status).toBe(202);
|
||||
// Rendering is the worker's job now; nothing is read in the request.
|
||||
expect(rasterizePdf).not.toHaveBeenCalled();
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
expect(startAiRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
|
||||
documents: [],
|
||||
kind: "LABS_OCR_EXTRACT",
|
||||
params: expect.objectContaining({
|
||||
mode: "vision",
|
||||
mime: "application/pdf",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("says on the response when only the first pages of a long PDF were read", async () => {
|
||||
const tenPages = Array.from({ length: 10 }, () => ({
|
||||
mediaType: "image/jpeg" as const,
|
||||
dataBase64: "cGFnZQ==",
|
||||
}));
|
||||
vi.mocked(rasterizePdf).mockResolvedValue({
|
||||
ok: true,
|
||||
images: tenPages,
|
||||
pageCount: 23,
|
||||
});
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
|
||||
const res = await POST(pdfReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { pageCoverage?: { read: number; total: number } };
|
||||
};
|
||||
expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
|
||||
});
|
||||
|
||||
it("hands the slot back when the upload is not an image or PDF", async () => {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
@@ -287,36 +241,13 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to pdfNeedsAnthropic when rasterization fails", async () => {
|
||||
vi.mocked(rasterizePdf).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "render-failed",
|
||||
});
|
||||
|
||||
const res = await POST(pdfReq());
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string | null };
|
||||
expect(body.error).toBeTruthy();
|
||||
// A failed render never reaches the provider — and refunds the reservation.
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
0,
|
||||
"2026-06-26",
|
||||
0,
|
||||
{ servedBy: null, reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
|
||||
it("charges the ceiling the operator set", async () => {
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "40");
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
const res = await POST(textReq() as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(202);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"labs-ocr:user-1",
|
||||
40,
|
||||
@@ -366,15 +297,9 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the slot once the provider was actually called", async () => {
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
|
||||
const failed = await POST(textReq() as never);
|
||||
expect(failed.status).toBe(502);
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
const ok = await POST(textReq() as never);
|
||||
expect(ok.status).toBe(200);
|
||||
it("keeps the slot once the scan is queued", async () => {
|
||||
const queued = await POST(textReq() as never);
|
||||
expect(queued.status).toBe(202);
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
*
|
||||
* Read-only (NOT idempotent) extraction of a paper lab report into STRUCTURED
|
||||
* proposed rows for the mandatory human-review screen. Nothing is written to
|
||||
* the database here; the raw upload is never persisted or logged.
|
||||
* the person's record here; the raw upload is never logged.
|
||||
*
|
||||
* v1.40 — the read runs in the background worker. This route answers every
|
||||
* refusal it can answer quickly (capability, provider, rate bucket, budget, a
|
||||
* malformed or oversized upload, an unknown file type), seals the upload or
|
||||
* the text into a `DocumentAiRun` (AES-256-GCM, dropped the moment the run
|
||||
* finishes, the row deleted an hour later) and answers 202 with the run id.
|
||||
* `GET /api/ai-runs/{id}` serves the proposed rows. Only the web client calls
|
||||
* this route, so there is no synchronous form to keep.
|
||||
*
|
||||
* Two modes, dispatched on the request content-type:
|
||||
*
|
||||
@@ -22,9 +30,11 @@
|
||||
* re-check the capability for that pick (`requireLabsOcrProvider`: the
|
||||
* operator's switch, the labs module, and a document consent receipt for a
|
||||
* pick that leaves the machine) → rate-limit
|
||||
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
|
||||
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
|
||||
* handed back when the scan fails before the provider is called.
|
||||
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → queue the run.
|
||||
* The worker resolves the provider again, re-checks the wire, runs the
|
||||
* extraction and reconciles the budget (`src/lib/labs/ocr-run.ts`). A slot is
|
||||
* charged early so a 429 stays cheap, and it is handed back when the scan
|
||||
* fails before the provider is called.
|
||||
*
|
||||
* Extracted text is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside the document — the human review step is the safety
|
||||
@@ -35,12 +45,10 @@ import { Buffer } from "node:buffer";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -49,10 +57,10 @@ import {
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { startAiRun } from "@/lib/documents/ai-runs/start";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import { OCR_RESERVE_TOKENS } from "@/lib/labs/ocr-run";
|
||||
import {
|
||||
BodyTooLargeError,
|
||||
detectOcrMimeType,
|
||||
@@ -72,27 +80,6 @@ export const dynamic = "force-dynamic";
|
||||
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* The wide-event facts of a failed provider call: the status and the model,
|
||||
* never the upstream body. The base URL can be one a user typed, and what an
|
||||
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
|
||||
* the response to the caller carries neither.
|
||||
*/
|
||||
function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
const err = error as {
|
||||
httpStatus?: unknown;
|
||||
model?: unknown;
|
||||
};
|
||||
return {
|
||||
reason: "provider_error",
|
||||
...(mode ? { mode } : {}),
|
||||
...(typeof err.httpStatus === "number"
|
||||
? { upstreamStatus: err.httpStatus }
|
||||
: {}),
|
||||
...(typeof err.model === "string" ? { model: err.model } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -111,7 +98,7 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* TEXT mode (v1.18.10) — structure in-browser-OCR'd text via any configured
|
||||
* TEXT mode (v1.18.10) — queue in-browser-OCR'd text for any configured
|
||||
* provider. No image bytes reach the server; the raw image stayed on-device.
|
||||
*/
|
||||
async function handleTextExtract(
|
||||
@@ -173,7 +160,7 @@ async function handleTextExtract(
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
OCR_RESERVE_TOKENS.text,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
@@ -190,51 +177,27 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runOcrExtraction({
|
||||
userId,
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
// A clean structuring pass spent (at most) the reservation; the provider
|
||||
// already billed it, so reconcile against the reserved estimate.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
0,
|
||||
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
|
||||
);
|
||||
return apiSuccess(result);
|
||||
} catch (err) {
|
||||
// A failed structuring call produced no usable rows; mirror the vision
|
||||
// path and refund the reservation in full rather than charging it.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
|
||||
servedBy: null,
|
||||
reservedOwner: reservation.owner,
|
||||
});
|
||||
if (err instanceof OcrExtractError) {
|
||||
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "labs.ocr.extractFailed" },
|
||||
meta: providerFailureMeta(err, "text"),
|
||||
});
|
||||
return apiError(
|
||||
"The configured AI provider could not process this report. Check the provider configuration and retry.",
|
||||
502,
|
||||
{ errorCode: "labs.ocr.extractFailed" },
|
||||
);
|
||||
}
|
||||
// The structuring pass runs in the worker, which picks the provider again
|
||||
// and re-checks the wire for it before the text leaves.
|
||||
return startAiRun({
|
||||
userId,
|
||||
kind: "LABS_OCR_EXTRACT",
|
||||
params: {
|
||||
mode: "text",
|
||||
budget: {
|
||||
reserved: reservation.reserved,
|
||||
owner: reservation.owner,
|
||||
dateKey,
|
||||
},
|
||||
},
|
||||
input: Buffer.from(parsed.data.text, "utf8"),
|
||||
refundSlot: () => refundLabsOcrSlot(userId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* VISION mode — run a multipart photo / PDF through the user's vision-capable
|
||||
* provider. The image transits server memory ephemerally and is never stored.
|
||||
* VISION mode — queue a multipart photo / PDF for the user's vision-capable
|
||||
* provider. The upload is sealed into the run until the read finishes.
|
||||
*/
|
||||
async function handleVisionExtract(
|
||||
request: Request,
|
||||
@@ -258,7 +221,7 @@ async function handleVisionExtract(
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
OCR_RESERVE_TOKENS.vision,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
@@ -359,120 +322,23 @@ async function handleVisionExtract(
|
||||
});
|
||||
}
|
||||
|
||||
// 7. PDF handling — mirror the document vault's `prepareVisionInput`
|
||||
// (`src/lib/documents/ai-route-support.ts`) exactly:
|
||||
// - Anthropic (`pick.pdfSupported`) reads the native `document` block at
|
||||
// highest fidelity;
|
||||
// - every other vision provider (codex / any image-only wire) reads the
|
||||
// PDF via `rasterizePdf`, which renders the pages to JPEG `input_image`
|
||||
// parts the provider already handles. Rendering is pure local compute
|
||||
// (no egress). `rasterizePdf` never throws: a malformed / encrypted /
|
||||
// unrenderable PDF returns `{ ok: false }`, and we keep the shipped
|
||||
// `pdfNeedsAnthropic` reject as the graceful fallback.
|
||||
let images: {
|
||||
mediaType: "image/jpeg" | "image/png" | "image/webp";
|
||||
dataBase64: string;
|
||||
}[];
|
||||
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
|
||||
let pageCoverage: { read: number; total: number } | undefined;
|
||||
|
||||
if (mime === "application/pdf") {
|
||||
if (pick.pdfSupported) {
|
||||
images = [];
|
||||
documents = [
|
||||
{
|
||||
mediaType: "application/pdf",
|
||||
dataBase64: buffer.toString("base64"),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const raster = await rasterizePdf(buffer);
|
||||
if (!raster.ok) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.fileRejected" },
|
||||
meta: { reason: "pdf_rasterize_failed" },
|
||||
});
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
|
||||
servedBy: null,
|
||||
reservedOwner: reservation.owner,
|
||||
});
|
||||
await refundLabsOcrSlot(userId);
|
||||
return apiError(
|
||||
"Couldn't read this PDF; upload a photo instead.",
|
||||
422,
|
||||
{ errorCode: "labs.ocr.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
images = raster.images;
|
||||
documents = [];
|
||||
// A PDF longer than the page cap is read from its first pages only.
|
||||
// That used to live in the wide event alone; the person reviewing the
|
||||
// rows is the one who needs to know the rest of the report was not
|
||||
// read.
|
||||
if (raster.pageCount > raster.images.length) {
|
||||
pageCoverage = {
|
||||
read: raster.images.length,
|
||||
total: raster.pageCount,
|
||||
};
|
||||
annotate({
|
||||
action: { name: "labs.ocr.pagesCapped" },
|
||||
meta: { read: raster.images.length, total: raster.pageCount },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
|
||||
documents = [];
|
||||
}
|
||||
|
||||
// 8. Run the extraction. The actual token spend reconciles the reservation.
|
||||
let actualTokens = 0;
|
||||
try {
|
||||
const result = await runOcrExtraction({
|
||||
userId,
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images,
|
||||
documents,
|
||||
});
|
||||
// The orchestration does not surface token counts; reconcile against the
|
||||
// reserved estimate as the spend ceiling (the provider already billed it).
|
||||
actualTokens = reservation.reserved;
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
actualTokens,
|
||||
dateKey,
|
||||
0,
|
||||
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
|
||||
);
|
||||
return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
|
||||
} catch (err) {
|
||||
// Provider/extraction failure — the call may still have burned tokens, so
|
||||
// reconcile against the reserved estimate rather than refunding in full.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
actualTokens,
|
||||
dateKey,
|
||||
0,
|
||||
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
|
||||
);
|
||||
if (err instanceof OcrExtractError) {
|
||||
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "labs.ocr.extractFailed" },
|
||||
meta: providerFailureMeta(err),
|
||||
});
|
||||
return apiError(
|
||||
"The configured AI provider could not process this report. Check the provider configuration and retry.",
|
||||
502,
|
||||
{ errorCode: "labs.ocr.extractFailed" },
|
||||
);
|
||||
}
|
||||
// 7. Queue the read. The worker renders a PDF for a provider that cannot
|
||||
// read one natively, runs the extraction, and settles the reservation.
|
||||
return await startAiRun({
|
||||
userId,
|
||||
kind: "LABS_OCR_EXTRACT",
|
||||
params: {
|
||||
mode: "vision",
|
||||
mime,
|
||||
budget: {
|
||||
reserved: reservation.reserved,
|
||||
owner: reservation.owner,
|
||||
dateKey,
|
||||
},
|
||||
},
|
||||
input: buffer,
|
||||
refundSlot: () => refundLabsOcrSlot(userId),
|
||||
});
|
||||
} catch (err) {
|
||||
// A guard threw after the reservation (e.g. consent races) — refund fully.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
|
||||
|
||||
Reference in new issue
Block a user