mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
fa37941add
commit
3d039d5220
13 files changed
+634
-77
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user