diff --git a/messages/de.json b/messages/de.json index 479802947..933e6e1f5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6725,6 +6725,7 @@ "linksExisting": "Verknüpft mit vorhandenem {name}", "duplicateWarning": "Ein passender Wert existiert bereits", "lowConfidence": "Bitte diesen Wert überprüfen", + "lowConfidenceBadge": "Geringe Sicherheit", "valueUnreadable": "Wert unleserlich — bitte manuell eingeben", "saveSelected": "{count} ausgewählte speichern", "discardAll": "Alle verwerfen", diff --git a/messages/en.json b/messages/en.json index f49afdad7..e7fe5729f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6725,6 +6725,7 @@ "linksExisting": "Links to existing {name}", "duplicateWarning": "A matching reading already exists", "lowConfidence": "Please double-check this value", + "lowConfidenceBadge": "Low confidence", "valueUnreadable": "Value unreadable — enter it manually", "saveSelected": "Save {count} selected", "discardAll": "Discard all", diff --git a/messages/es.json b/messages/es.json index 7113cf9a5..833990236 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6725,6 +6725,7 @@ "linksExisting": "Se vincula a {name} existente", "duplicateWarning": "Ya existe un valor coincidente", "lowConfidence": "Verifica este valor", + "lowConfidenceBadge": "Confianza baja", "valueUnreadable": "Valor ilegible — introdúcelo manualmente", "saveSelected": "Guardar {count} seleccionados", "discardAll": "Descartar todo", diff --git a/messages/fr.json b/messages/fr.json index 80cc9e0ea..8a3453623 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6725,6 +6725,7 @@ "linksExisting": "Lié au {name} existant", "duplicateWarning": "Une valeur correspondante existe déjà", "lowConfidence": "Veuillez vérifier cette valeur", + "lowConfidenceBadge": "Confiance faible", "valueUnreadable": "Valeur illisible — saisissez-la manuellement", "saveSelected": "Enregistrer {count} sélectionnées", "discardAll": "Tout abandonner", diff --git a/messages/it.json b/messages/it.json index 86e56afcd..734f6011a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6725,6 +6725,7 @@ "linksExisting": "Collegato a {name} esistente", "duplicateWarning": "Esiste già un valore corrispondente", "lowConfidence": "Controlla questo valore", + "lowConfidenceBadge": "Bassa attendibilità", "valueUnreadable": "Valore illeggibile — inseriscilo manualmente", "saveSelected": "Salva {count} selezionati", "discardAll": "Scarta tutto", diff --git a/messages/pl.json b/messages/pl.json index 94eb6acf4..b8a448c23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6725,6 +6725,7 @@ "linksExisting": "Łączy z istniejącym {name}", "duplicateWarning": "Pasujący wynik już istnieje", "lowConfidence": "Sprawdź tę wartość", + "lowConfidenceBadge": "Niska pewność", "valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie", "saveSelected": "Zapisz {count} wybranych", "discardAll": "Odrzuć wszystko", diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts index dabd2ac88..d12a12c5d 100644 --- a/src/app/api/admin/app-logs/__tests__/route.test.ts +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => { expect(json.data.events).toHaveLength(10); }); - it("rejects an invalid level value", async () => { + it("rejects an invalid level value with a 422 (not a thrown 500)", async () => { appendLogEvent(makeEvent()); - // Invalid `level` — Zod parse should throw and the apiHandler test - // mock rethrows it (we mock only `requireAdmin`, not the wrapper - // error-translator). We simply assert the call rejects. - await expect(GET(req("level=critical"))).rejects.toThrow(); + // Invalid `level` — the query is parsed with `safeParse` and returns the + // standard 422 multi-issue envelope rather than throwing (which the + // apiHandler catch ladder would otherwise surface as a 500 + incident). + const res = await GET(req("level=critical")); + expect(res.status).toBe(422); + const json = (await res.json()) as { + data: null; + error: string; + details: { issues: unknown[] }; + }; + expect(json.data).toBeNull(); + expect(json.error).toBe("Validation failed"); + expect(json.details.issues.length).toBeGreaterThan(0); }); }); diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index eee82faf4..c5e3e3478 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer"; import { redactSecrets } from "@/lib/logging/redact"; @@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.app-logs.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ traceId: searchParams.get("traceId") ?? undefined, level: searchParams.get("level") ?? undefined, action: searchParams.get("action") ?? undefined, @@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => { until: searchParams.get("until") ?? undefined, limit: searchParams.get("limit") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; const events = readLogBuffer({ traceId: parsed.traceId, diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 47c62061b..6ba1fb45a 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { redactSecrets } from "@/lib/logging/redact"; import { NextRequest } from "next/server"; @@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.audit-log.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ limit: searchParams.get("limit") ?? undefined, offset: searchParams.get("offset") ?? undefined, filter: searchParams.get("filter") ?? undefined, @@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => { since: searchParams.get("since") ?? undefined, until: searchParams.get("until") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; // Resolve perPage: clamp to allowed set, fall back to 50. const perPageResolved = diff --git a/src/app/api/admin/host-metrics/route.ts b/src/app/api/admin/host-metrics/route.ts index 28cf09803..8b84ec4e5 100644 --- a/src/app/api/admin/host-metrics/route.ts +++ b/src/app/api/admin/host-metrics/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.host-metrics.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ since: searchParams.get("since") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; const sinceMs = presetToMs(parsed.since); const cutoff = new Date(Date.now() - sinceMs); diff --git a/src/app/api/labs/ocr/capability/route.ts b/src/app/api/labs/ocr/capability/route.ts index 19c848b11..87df4115c 100644 --- a/src/app/api/labs/ocr/capability/route.ts +++ b/src/app/api/labs/ocr/capability/route.ts @@ -4,7 +4,7 @@ * Cheap probe (no provider call) the Labs UI uses to decide whether to show * the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }` * for the calling user's configured AI provider. The surface stays dark for - * codex-only / text-only-model users. + * users with no provider configured and no local-OCR opt-in. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts new file mode 100644 index 000000000..055a56a1f --- /dev/null +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -0,0 +1,129 @@ +/** + * v1.20.1 — POST /api/labs/ocr/extract, TEXT mode. + * + * 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. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAuth: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveTextProvider: vi.fn(), + resolveVisionProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-06-26"), + reserveBudget: vi.fn(), + reconcileSpend: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/labs/ocr-extract", async () => { + const actual = await vi.importActual( + "@/lib/labs/ocr-extract", + ); + return { ...actual, runOcrExtraction: vi.fn() }; +}); + +import { POST } from "../route"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { resolveTextProvider } from "@/lib/labs/ocr-capability"; +import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget"; +import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function textReq(text = "Glucose 95 mg/dL"): Request { + return new Request("http://localhost/api/labs/ocr/extract", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "text", text }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + labsLocalOcrEnabled: true, + } as never); + vi.mocked(resolveTextProvider).mockResolvedValue({ + chain: [{ providerType: "openai", instance: {} as never }], + pick: { + entry: { providerType: "openai", instance: {} as never }, + providerType: "openai", + }, + } as never); + vi.mocked(reserveBudget).mockResolvedValue({ + allowed: true, + reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0, + totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0, + } as never); +}); + +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); + + // The reservation must use the text ceiling — proven distinct from the + // vision ceiling so a future merge can't silently re-point it. + expect(AI_BUDGETS.ocrExtractText.maxTokens).toBeLessThan( + AI_BUDGETS.ocrExtract.maxTokens ?? Infinity, + ); + expect(reserveBudget).toHaveBeenCalledWith( + "user-1", + AI_BUDGETS.ocrExtractText.maxTokens, + "2026-06-26", + ); + }); + + 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", + ); + }); +}); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 201ee7211..e35b710d6 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -138,12 +138,14 @@ async function handleTextExtract( }); } - // Budget (text is cheaper than vision; reuse the same ceiling as the upper - // bound — a text-only structuring call always spends at most this). + // Budget — text mode is a plain text→JSON structuring pass, far cheaper than + // a vision call, so it reserves the proportionate text ceiling rather than + // the vision budget. Over-charging the vision rate for a text call would + // exhaust the day budget against spend that never happened. const dateKey = buildDateKey(); const reservation = await reserveBudget( userId, - AI_BUDGETS.ocrExtract.maxTokens, + AI_BUDGETS.ocrExtractText.maxTokens, dateKey, ); if (!reservation.allowed) { @@ -163,6 +165,8 @@ async function handleTextExtract( 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, @@ -171,12 +175,9 @@ async function handleTextExtract( ); return apiSuccess(result); } catch (err) { - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - ); + // 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); if (err instanceof OcrExtractError) { return apiError("Couldn't read the report. Try a clearer photo.", 422, { errorCode: "labs.ocr.extractFailed", diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index eed49fe60..640ff96bf 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -49,6 +49,17 @@ export function OcrRowEditor({ !isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD; const valueUnreadable = !isQualitative && row.value === null; + // The model self-scores each field it transcribed. Surface a single calm + // per-row flag when ANY tracked field came back below the threshold so a + // shaky read is visible at a glance — including on qualitative rows, which + // have no value-confidence footnote of their own. Informative, not alarming. + const lowRowConfidence = + row.confidence.analyte < CONFIDENCE_THRESHOLD || + row.confidence.unit < CONFIDENCE_THRESHOLD || + (isQualitative + ? row.confidence.value < CONFIDENCE_THRESHOLD + : lowValueConfidence); + // v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range // from THIS extracted row. The range is therefore load-bearing and worth a // second look. Surface the range confidence prominently for new numeric @@ -106,6 +117,12 @@ export function OcrRowEditor({ {t("labs.ocr.duplicateWarning")} ) : null} + {lowRowConfidence ? ( + + + {t("labs.ocr.lowConfidenceBadge")} + + ) : null} diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index e0e67d14e..c1b4538fe 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -109,4 +109,16 @@ export const AI_BUDGETS = { * gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap. */ ocrExtract: { temperature: 0, maxTokens: 4000 }, + + /** + * v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image + * (tesseract.js) and POSTs only the extracted TEXT, so the provider does a + * plain text→JSON structuring pass — no expensive vision input tokens. The + * output contract is identical (the same dozen-plus analyte rows), but the + * spend ceiling is far lower than the vision path's 4000, so the day-budget + * reservation should reflect that rather than over-charging a text call at + * the vision rate. 1500 tokens comfortably covers a dense report's JSON + * envelope. Temperature 0 — faithful structuring, not generation. + */ + ocrExtractText: { temperature: 0, maxTokens: 1500 }, } as const satisfies Record; diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 75026d815..f9502c7d9 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -20,8 +20,10 @@ * mutation or egress surface. * * The builder result is memoised by the 60s snapshot LRU keyed on - * `(userId, window, sources)`, so several tool calls in one turn (and the - * base-context inventory build) share the underlying reads. + * `(userId, window, sources)`. Each tool scopes to a different source set, so + * tool calls for distinct domains do NOT share a cache entry; sharing happens + * only when the SAME scope recurs (a repeated call, or a turn that re-enters + * the same domain) within the 60s window. */ import { z } from "zod/v4"; diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 6abf49dcd..ae3c493cf 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -47,6 +47,14 @@ import { /** Refresh the access token this many ms before `tokenExpiresAt`. */ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +/** + * Delimiter joining `(type, externalId)` into a single dedup map key. A + * control character keeps it unambiguous against any value a real type or + * externalId could contain, while staying a normal printable-safe constant + * (a literal NUL in the source made text tooling treat the file as binary). + */ +const DEDUP_KEY_DELIM = "\u0000"; + /** * Overlap window for the incremental sync, in ms. Fitbit finalises daily * summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the @@ -396,7 +404,10 @@ export async function upsertFitbitMeasurements( liveByKey = new Map( existing .filter((e) => e.externalId !== null) - .map((e) => [`${e.type}${e.externalId}`, { id: e.id }]), + .map((e) => [ + `${e.type}${DEDUP_KEY_DELIM}${e.externalId}`, + { id: e.id }, + ]), ); } catch (err) { // A probe failure must not strand the whole batch; fall back to treating @@ -421,7 +432,7 @@ export async function upsertFitbitMeasurements( const plannedCreateKeys = new Set(); for (const r of readings) { const type = r.type as MeasurementType; - const key = `${type}${r.externalId}`; + const key = `${type}${DEDUP_KEY_DELIM}${r.externalId}`; const live = liveByKey.get(key); if (live) { toUpdate.push({ id: live.id, r }); @@ -438,7 +449,9 @@ export async function upsertFitbitMeasurements( } else { // A duplicate fresh key inside the same batch — overwrite the planned // create's payload so last-write-wins, matching the prior upsert loop. - const idx = toCreate.findIndex((c) => `${c.type}${c.externalId}` === key); + const idx = toCreate.findIndex( + (c) => `${c.type}${DEDUP_KEY_DELIM}${c.externalId}` === key, + ); if (idx >= 0) { toCreate[idx] = { type, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 7b4286469..bb90fefcf 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -29,9 +29,7 @@ const capabilityResponse = z .object({ available: z.boolean(), mode: z.enum(["vision", "text"]).nullable(), - reason: z - .enum(["no-provider", "text-only-model", "enable-local-ocr"]) - .nullable(), + reason: z.enum(["no-provider", "enable-local-ocr"]).nullable(), pdfSupported: z.boolean(), }) .meta({ diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 8f8835d53..74a6584df 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -207,7 +207,7 @@ export interface OcrCapabilityDto { */ mode: "vision" | "text" | null; /** Why scanning is unavailable, when it is. */ - reason: "no-provider" | "text-only-model" | "enable-local-ocr" | null; + reason: "no-provider" | "enable-local-ocr" | null; /** Whether PDF uploads are accepted (Anthropic vision provider only). */ pdfSupported: boolean; }