feat(labs): add local-OCR text mode for image-blind AI providers

Add an opt-in in-browser OCR path so a user whose provider cannot read
images (ChatGPT-OAuth/Codex, a text-only model) can still scan a lab
report. The capability probe gains a `mode` field: `vision` when the
provider reads the image directly, `text` when local OCR is enabled and
any provider is configured, or null. The extract route grows a text
branch that accepts the in-browser-OCR'd text as JSON and structures it
through the existing schema + review/commit flow; the raw image never
reaches the server in text mode. New `labsLocalOcrEnabled` user column
(migration 0184, default off) gates it, with a dedicated PATCH endpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:50:06 +02:00
1 parent fa37941add
commit 3d039d5220
13 files changed
+634 -77

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
/**
* v1.18.10 — per-user local-OCR opt-in endpoint.
*
* GET /api/auth/me/labs-local-ocr — current flag.
* PATCH /api/auth/me/labs-local-ocr — body `{ labsLocalOcrEnabled: boolean }`.
*
* When `true`, a user whose AI provider cannot read images (ChatGPT-OAuth/Codex,
* a text-only model) can still scan a paper lab report: the image is OCR'd in
* the browser via tesseract.js and only the extracted TEXT is forwarded to the
* text-only provider for structuring. The raw image never leaves the device in
* this mode. Less accurate than native vision — the mandatory review screen is
* the safety backstop.
*
* Mirrors the `diabetes` / `unit-preference` per-user-scalar pattern: 60/min
* rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment). Idempotent — always returns the
* resolved next state so the client can hard-set the optimistic update.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({
labsLocalOcrEnabled: z.boolean(),
});
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type LabsLocalOcrResponse = {
labsLocalOcrEnabled: boolean;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.labsLocalOcr.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { labsLocalOcrEnabled: true },
});
const payload: LabsLocalOcrResponse = {
labsLocalOcrEnabled: row?.labsLocalOcrEnabled ?? false,
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`labs-local-ocr:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
// The body is a single boolean — bound the parse so a malformed or oversized
// payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.labsLocalOcrEnabled;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { labsLocalOcrEnabled: true },
});
await prisma.user.update({
where: { id: user.id },
data: { labsLocalOcrEnabled: next },
});
await auditLog("user.labsLocalOcr.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous?.labsLocalOcrEnabled ?? false,
next,
},
});
annotate({
action: { name: "auth.me.labsLocalOcr.patch" },
meta: { labsLocalOcrEnabled: next },
});
const payload: LabsLocalOcrResponse = { labsLocalOcrEnabled: next };
return apiSuccess(payload);
});