From fb54b4603458797aa75de542175a9921815474f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:47 +0200 Subject: [PATCH 1/4] fix(labs): reserve a proportionate budget for text-mode OCR Text-mode OCR structures browser-extracted text rather than running a vision call, so it reserved and reconciled the full vision budget even though the spend is far lower. Add an ocrExtractText ceiling and reserve that in text mode, and refund the reservation in full on a clean extract failure instead of charging the reserved estimate. --- .../labs/ocr/extract/__tests__/route.test.ts | 129 ++++++++++++++++++ src/app/api/labs/ocr/extract/route.ts | 19 +-- src/lib/ai/ai-budgets.ts | 12 ++ 3 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 src/app/api/labs/ocr/extract/__tests__/route.test.ts 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/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; From 561a0694cfb5db316ff3cbef64458f7c85adea88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:53 +0200 Subject: [PATCH 2/4] fix(admin): return 422 for a malformed query string The app-logs, audit-log and host-metrics admin endpoints parsed their query with a throwing parse(), so a bad parameter surfaced as a 500 and a reported incident. Switch each to safeParse and return the standard 422 validation envelope, matching how body parsing is handled. --- .../admin/app-logs/__tests__/route.test.ts | 19 ++++++++++++++----- src/app/api/admin/app-logs/route.ts | 6 ++++-- src/app/api/admin/audit-log/route.ts | 6 ++++-- src/app/api/admin/host-metrics/route.ts | 6 ++++-- 4 files changed, 26 insertions(+), 11 deletions(-) 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); From 1c93a670f05c3f0efe01f1e9f75d3b5a57f40caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:59 +0200 Subject: [PATCH 3/4] feat(labs): surface low-confidence OCR rows in review The extraction already self-scores each field; the review screen only hinted at a shaky numeric value. Add a calm per-row low-confidence badge when any tracked field scores below the threshold, including qualitative rows that had no confidence cue. Informative secondary badge, no alarm colour. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/components/labs/ocr-row-editor.tsx | 17 +++++++++++++++++ 7 files changed, 23 insertions(+) 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/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} From e023571a08509aff87bcbfa31b031ba32e39487d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:27:05 +0200 Subject: [PATCH 4/4] chore(labs): tidy OCR capability enum, Fitbit key delimiter, Coach docblock Drop the unreachable text-only-model reason from the OCR capability enum and its DTO type. Replace the literal NUL delimiter in the Fitbit dedup map keys with a named constant so text tooling stops treating the file as binary (behaviour unchanged). Correct the Coach tool-executor docblock, which overstated cross-tool snapshot-cache sharing. --- src/app/api/labs/ocr/capability/route.ts | 2 +- src/lib/ai/coach/tools/executor.ts | 6 ++++-- src/lib/fitbit/sync.ts | 19 ++++++++++++++++--- src/lib/openapi/routes/ocr.ts | 4 +--- src/lib/validations/labs-ocr.ts | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) 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/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; }