diff --git a/CHANGELOG.md b/CHANGELOG.md index 19791fbb2..c3eaf3052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.20.2] — 2026-06-26 — Hardening across insights, safety flags, and integrations + +A patch release. A broad pass over the server tier and the AI surfaces: a few correctness and safety fixes, a numerical hardening of the trend tier, and several smaller robustness and cost improvements. No schema changes. + +### Added + +- The lab-report review screen marks low-confidence rows so an uncertain reading is easy to spot before it is saved. + +### Changed + +- The trend regression (slope, r² and variability) is composed from mean-centered sums, which removes a floating-point cancellation on long, near-flat windows and keeps the rollup result in line with the live computation. Each accumulator now uses the exact stored sum rather than reconstructing it. + +### Fixed + +- The symptom journal's "sustained fever" flag — which can raise an urgent prompt to seek care — now counts only consecutive calendar days. A sparse series of isolated febrile entries no longer trips it, so the alert reflects a genuine multi-day run. +- Insight generation no longer reads a full measurement history per metric to compute its 30-day comparisons; it reads only the window those averages need. Generating insights on a long-history account is markedly faster, on both the page request and the overnight pre-generation. +- Photo and PDF scanning is no longer offered for text-only reasoning models that cannot read an image, so a scan attempt fails clearly up front instead of erroring at the provider. +- A notification channel whose stored configuration cannot be read now disables itself immediately with a precise reason, instead of retrying a permanent error several times and ending on a misleading message. This surfaces during an encryption-key rotation gap. +- The Coach cancels its in-flight model call when the browser tab disconnects mid-answer, rather than paying the full cost into a closed connection. +- Local-OCR text structuring reserves a budget proportionate to its real cost instead of the larger vision-scan ceiling, and refunds cleanly on a failed read. +- The admin diagnostic endpoints return a 422 for a malformed query string instead of a 500. + ## [1.20.1] — 2026-06-23 — Dashboard render-loop fix A patch release. One fix; no schema changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index befef0a1b..e0dec07ce 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.20.1 + version: 1.20.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -19630,7 +19630,6 @@ components: - type: string enum: - no-provider - - text-only-model - enable-local-ocr - type: "null" pdfSupported: 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/package.json b/package.json index cfca3d647..987fc9875 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.20.1", + "version": "1.20.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 4d042fcf2..603f8d944 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.20.1"; + /* @sw-version-fallback */ "v1.20.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index aedc94be7..341476274 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -549,6 +549,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, fallbackWindow: effectiveScope?.window, + // v1.20.1 — thread the abort signal so a mid-generation disconnect tears + // down the per-round provider calls instead of paying the full cost. + signal: request.signal, }); result = loop.result; workingProviderType = loop.workingProviderType; @@ -567,6 +570,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch user: userPrompt, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, + // v1.20.1 — thread the request's abort signal so a mid-generation + // client disconnect tears the upstream provider call down instead of + // paying the full token cost into a closed connection. + signal: request.signal, }), }); result = fallback.result; diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index a27c05e75..731779898 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -160,6 +160,14 @@ async function buildComparisonSnapshotForUser( ACTIVITY_STEPS: "", }; const types = Object.keys(typeToSnapshotKey) as MeasurementType[]; + // The snapshot only feeds summarize()'s avg30 / avg30LastMonth / + // avg30LastYear. The widest of those reaches back into the [365, 395)-day + // window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395 + // days can change a result. Bound the read at 400 days — a 5-day floor over + // the strict-less-than boundary — instead of scanning the full history, + // which on multi-year accounts is tens of thousands of rows per type on the + // page-blocking generate path and the nightly pregenerate cron. + const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000); const rows = await Promise.all( types.map(async (type) => { // SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the @@ -171,7 +179,12 @@ async function buildComparisonSnapshotForUser( if (type === "SLEEP_DURATION") { const [sleepRows, sleepTz, sleepPriority] = await Promise.all([ prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, @@ -195,7 +208,12 @@ async function buildComparisonSnapshotForUser( .map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })); } else { const measurements = await prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }); 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/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts index 170223801..2a94eed33 100644 --- a/src/lib/ai/__tests__/openai-client.test.ts +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -290,4 +290,72 @@ describe("OpenAIClient", () => { image_url: { url: "data:image/png;base64,AAAA" }, }); }); + + it("tags an empty-content reply with sentinel httpStatus 0 + kind for the chain classifier", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "" } }], + usage: { total_tokens: 5 }, + }), + }), + ); + + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + await client + .generateCompletion(singleUserTurn({ system: "s", user: "u" })) + .then( + () => { + throw new Error("expected an empty-content throw"); + }, + (err: unknown) => { + // The throw distinguishes an empty 200-OK reply from ECONNRESET: + // sentinel httpStatus 0 (still classified as a hard failure, so the + // cascade is unchanged) + a `kind` discriminator for observability. + expect((err as Error).message).toContain("empty content"); + expect((err as { httpStatus?: number }).httpStatus).toBe(0); + expect((err as { kind?: string }).kind).toBe("empty_response"); + }, + ); + }); + + it("threads the caller's abort signal onto the upstream fetch", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "ok" } }], + usage: { total_tokens: 1 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + const ctrl = new AbortController(); + await client.generateCompletion( + singleUserTurn({ system: "s", user: "u", signal: ctrl.signal }), + ); + + // safeFetch composes the caller signal with the timeout via AbortSignal.any, + // so a signal lands on the dispatched fetch init. + const init = mockFetch.mock.calls[0][1]; + expect(init.signal).toBeInstanceOf(AbortSignal); + + // Aborting the caller's controller propagates to the composed signal. + ctrl.abort(); + expect(init.signal.aborted).toBe(true); + }); }); diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 9a1e03c24..ec2fbb395 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -39,7 +39,10 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig(providerType, "gpt-4o-mini")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4.1")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4-turbo")).toBe(true); + // Full o-series reasoning models read images. expect(supportsVisionForConfig(providerType, "o1")).toBe(true); + expect(supportsVisionForConfig(providerType, "o3")).toBe(true); + expect(supportsVisionForConfig(providerType, "o4")).toBe(true); } }); @@ -49,6 +52,20 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig("openai", null)).toBe(false); }); + it("rejects text-only o-series variants (-mini / -preview) for openai", () => { + // o1-mini / o1-preview / o3-mini accept the `o*` prefix but have no image + // input — claiming vision here 400s the user at the image block. + for (const providerType of [ + "openai", + "admin-openai", + "admin-key", + ] as const) { + expect(supportsVisionForConfig(providerType, "o1-mini")).toBe(false); + expect(supportsVisionForConfig(providerType, "o1-preview")).toBe(false); + expect(supportsVisionForConfig(providerType, "o3-mini")).toBe(false); + } + }); + it("trusts local (operator opted in) regardless of model string", () => { expect(supportsVisionForConfig("local", "llava")).toBe(true); expect(supportsVisionForConfig("local", null)).toBe(true); @@ -72,6 +89,12 @@ describe("supportsVisionForConfig", () => { // The `-codex` specialist slugs are text-only despite the gpt-5 prefix. expect(supportsVisionForConfig("codex", "gpt-5-codex")).toBe(false); expect(supportsVisionForConfig("codex", "gpt-5.1-codex-mini")).toBe(false); + // Text-only o-series variants are excluded for codex too. + expect(supportsVisionForConfig("codex", "o1-mini")).toBe(false); + expect(supportsVisionForConfig("codex", "o3-mini")).toBe(false); + expect(supportsVisionForConfig("codex", "o1-preview")).toBe(false); + // Full o-series stays vision-capable. + expect(supportsVisionForConfig("codex", "o3")).toBe(true); }); it("never reports vision for none", () => { 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/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 97162de03..349db83d2 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -259,7 +259,9 @@ export class AnthropicClient implements AIProvider { // 60 s ceiling — see openai-client.ts for the rationale. // v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS // check so a private/metadata address is rejected (SSRF/rebinding). - { timeoutMs: 60_000, requirePublicHost: true }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, ); if (!res.ok) { @@ -318,7 +320,16 @@ export class AnthropicClient implements AIProvider { // A tool_use-only reply carries no text — that is valid (F1). Only an empty // reply with neither text NOR a tool call is an error. if (!rawText && !toolCalls) { - throw new Error("Anthropic returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty 200-OK reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Anthropic returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "anthropic", + }); + throw err; } // When we prefilled the assistant turn with `{`, the model continues 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/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 216c29b96..1f0dfb1d6 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -58,6 +58,8 @@ export async function runCoachToolLoop(args: { maxTokens?: number; fallbackWindow?: CoachScopeWindow; ledger?: ProviderHealthLedger; + /** Aborts the per-round provider calls on client disconnect. */ + signal?: AbortSignal; }): Promise { const { userId, @@ -68,6 +70,7 @@ export async function runCoachToolLoop(args: { maxTokens, fallbackWindow, ledger, + signal, } = args; const messages: AiMessage[] = [...args.messages]; @@ -92,6 +95,7 @@ export async function runCoachToolLoop(args: { messages, temperature, maxTokens, + signal, ...(offerTools ? { tools, toolChoice: "auto" as const } : { toolChoice: "none" as const }), diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2be9fcf8c..4bc717814 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -467,7 +467,9 @@ export class CodexClient implements AIProvider { // clients use so a long generation is not clipped by the 15 s // default while still bounding a tar-pit upstream. }, - { timeoutMs: 60_000 }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, signal: params.signal }, ); } @@ -492,7 +494,15 @@ export class CodexClient implements AIProvider { requestedSlug: string, ): Promise { if (!res.body) { - throw new Error("Codex returned no response body"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // a stream-level failure apart from a connect-time transport error. + const err = new Error("Codex returned no response body"); + Object.assign(err, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw err; } const reader = res.body.getReader(); @@ -595,6 +605,10 @@ export class CodexClient implements AIProvider { const e = new Error(message); Object.assign(e, { upstream: "codex", + // v1.20.1 — sentinel httpStatus + kind so the chain classifier + // distinguishes a stream-level failure from a transport error. + httpStatus: 0, + kind: "stream_failed", errorCode: err?.code ?? null, model: serverModel ?? requestedSlug, }); @@ -604,13 +618,25 @@ export class CodexClient implements AIProvider { const reason = parsed.response?.incomplete_details?.reason ?? "incomplete response"; - throw new Error(`Codex stream incomplete: ${reason}`); + const e = new Error(`Codex stream incomplete: ${reason}`); + Object.assign(e, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw e; } case "error": case "response.error": { const message = parsed.error?.message ?? "Codex stream returned an error event"; - throw new Error(message); + const e = new Error(message); + Object.assign(e, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw e; } default: // Unknown event types are tolerated and ignored, same @@ -627,7 +653,16 @@ export class CodexClient implements AIProvider { // A function-call-only reply carries no text — valid (F1). Only an empty // reply with neither text NOR a tool call is an error. if (!content && toolCalls.length === 0) { - throw new Error("Codex returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Codex returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "codex", + }); + throw err; } return { diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 477d9bebc..eaf30ceff 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -125,7 +125,13 @@ export class LocalOpenAICompatibleClient implements AIProvider { ...(params.responseFormat === "json" ? { format: "json" } : {}), }), }, - { timeoutMs: 60_000, requirePublicHost: !allowPrivate }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { + timeoutMs: 60_000, + requirePublicHost: !allowPrivate, + signal: params.signal, + }, ); if (!res.ok) { @@ -155,7 +161,16 @@ export class LocalOpenAICompatibleClient implements AIProvider { const content = json.choices?.[0]?.message?.content; if (!content) { - throw new Error("Local AI returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty 200-OK reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Local AI returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "local", + }); + throw err; } return { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index e4a8c808d..26b291296 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -90,7 +90,9 @@ export class OpenAIClient implements AIProvider { // v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin // the connect-time DNS check: a base URL resolving to a private/metadata // address is rejected, closing the SSRF/rebinding surface. - { timeoutMs: 60_000, requirePublicHost: true }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, ); if (!res.ok) { @@ -124,7 +126,19 @@ export class OpenAIClient implements AIProvider { // A reply with tool calls and no prose is valid (F1 tool loop); only an // empty reply with neither content NOR tool calls is an error. if (!content && !toolCalls) { - throw new Error("OpenAI returned empty content"); + // v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` + + // `kind` so the provider-chain classifier can tell an empty 200-OK reply + // apart from a genuine transport failure (ECONNRESET, DNS, timeout) in + // observability. The cascade is unchanged: `isHardProviderFailure` + // already treats `status <= 0` as a hard failure and walks to the next + // provider. + const err = new Error("OpenAI returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "openai", + }); + throw err; } return { diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 0e1cc8028..318becc18 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -271,6 +271,15 @@ export interface CompletionParams { * Mapped per provider; omitted from the wire when unset. */ toolChoice?: "auto" | "none"; + /** + * v1.20.1 — optional caller-owned cancellation signal. Threaded onto the + * client's `safeFetch` so a mid-generation client disconnect (the Coach SSE + * route wires `request.signal` here) tears the upstream provider request + * down instead of running it to completion and paying the full token cost + * into a closed connection. Composed with the per-client timeout inside + * `safeFetch`. Omitted → behaviour is unchanged (timeout-only). + */ + signal?: AbortSignal; } export interface CompletionResult { @@ -313,6 +322,8 @@ export function singleUserTurn(p: { documents?: CompletionDocument[]; tools?: AiToolDef[]; toolChoice?: "auto" | "none"; + /** v1.20.1 — caller-owned cancel signal threaded to the client fetch. */ + signal?: AbortSignal; }): CompletionParams { const images = p.images ?? []; const documents = p.documents ?? []; @@ -349,6 +360,7 @@ export function singleUserTurn(p: { responseFormat: p.responseFormat, tools: p.tools, toolChoice: p.toolChoice, + signal: p.signal, }; } diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index daf382900..baea4da03 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -41,18 +41,32 @@ function anthropicSupportsVision(model: string): boolean { /** * OpenAI vision models (also covers the operator's admin key + Codex when it * runs an OpenAI model — though Codex is excluded by `providerType`). `gpt-4o`, - * `gpt-4.1`, `gpt-4-turbo`, and the `o*` reasoning models read image content; - * `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an image block. + * `gpt-4.1`, `gpt-4-turbo`, and the full `o1` / `o3` / `o4` reasoning models + * read image content; `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an + * image block. + * + * The `o*` reasoning line has text-only variants that DO accept the `o*` prefix + * but reject an image block: `o1-mini` / `o1-preview` and `o3-mini` have no + * vision input (only the full `o1` / `o3` are multimodal). They must be + * excluded or a user pinned to one is told OCR works, then the provider 400s at + * the image block. We treat an `o1` / `o3` / `o4` slug as vision-capable only + * when it is NOT a `-mini` / `-preview` variant — the conservative default for + * any unverified narrow slug. */ +function isVisionReasoningSlug(m: string): boolean { + if (!(m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))) { + return false; + } + return !m.includes("-mini") && !m.includes("-preview"); +} + function openaiSupportsVision(model: string): boolean { const m = model.toLowerCase(); return ( m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") || - m.startsWith("o1") || - m.startsWith("o3") || - m.startsWith("o4") + isVisionReasoningSlug(m) ); } @@ -78,9 +92,9 @@ function codexSupportsVision(model: string): boolean { m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") || - m.startsWith("o1") || - m.startsWith("o3") || - m.startsWith("o4") + // Same `-mini` / `-preview` text-only exclusion as the OpenAI gate: a + // future codex slug rotation onto e.g. `o3-mini` must not claim vision. + isVisionReasoningSlug(m) ); } 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/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index e52963ed7..ce2032b3b 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -669,6 +669,70 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { expect(fever?.days).toBe(3); expect(fever?.worstValue).toBe(39.0); }); + + it("does NOT escalate a sparse febrile series with calendar GAPS (cries-wolf guard)", () => { + // Symptom/vital points are present-days-only (never zero-filled). A user + // who logs a fever on 01-10/01-15/01-20/01-25 has FOUR isolated febrile + // days with multi-day gaps between them — NOT a sustained run. The run + // scan must compare calendar-day deltas, not array adjacency, or it falsely + // fires sustained_fever (which surfaces a "seek medical care" string AND an + // urgent cross-channel push). No two of these days are calendar-adjacent → + // the longest run is 1 → NO escalation. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + dayLogFever: [ + { day: "2026-01-10", feverC: 39.2 }, + { day: "2026-01-15", feverC: 39.0 }, + { day: "2026-01-20", feverC: 38.8 }, + { day: "2026-01-25", feverC: 38.7 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find( + (f) => f.reason === "sustained_fever", + ); + expect(fever).toBeUndefined(); + }); + + it("DOES escalate a genuinely consecutive 3-calendar-day febrile run", () => { + // The mirror of the gap guard: three truly adjacent calendar days + // (01-10/01-11/01-12) all ≥ 38.5 → a real sustained fever → escalate. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + dayLogFever: [ + { day: "2026-01-10", feverC: 39.2 }, + { day: "2026-01-11", feverC: 38.9 }, + { day: "2026-01-12", feverC: 38.6 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find( + (f) => f.reason === "sustained_fever", + ); + expect(fever).toBeDefined(); + expect(fever?.days).toBe(3); + expect(fever?.worstValue).toBe(39.2); + }); }); describe("isAdverseDeviation", () => { diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 112790c99..583726981 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -968,22 +968,37 @@ function runFlag( let bestWorst: number | null = null; let run = 0; let runWorst: number | null = null; + let prevDay: string | null = null; for (const p of sorted) { if (predicate(p.mean)) { - run++; - runWorst = - runWorst === null - ? p.mean - : worst === "min" - ? Math.min(runWorst, p.mean) - : Math.max(runWorst, p.mean); + // A run is CALENDAR-consecutive days. Symptom/vital points are + // present-days-only (never zero-filled), so a sparse febrile series + // (e.g. days 01/10/15/20) must NOT count as one long run — that fires a + // false sustained_fever escalation. Continue the run only when this + // point is the calendar day immediately after the previous matching one; + // otherwise the current point starts a fresh run of 1. + const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1; + if (adjacent) { + run++; + runWorst = + runWorst === null + ? p.mean + : worst === "min" + ? Math.min(runWorst, p.mean) + : Math.max(runWorst, p.mean); + } else { + run = 1; + runWorst = p.mean; + } if (run > bestRun) { bestRun = run; bestWorst = runWorst; } + prevDay = p.day; } else { run = 0; runWorst = null; + prevDay = null; } } if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts new file mode 100644 index 000000000..22d52888b --- /dev/null +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -0,0 +1,147 @@ +/** + * v1.20.1 perf — the comparison-snapshot builder must bound its + * per-type measurement read to a ~400-day window instead of scanning a + * user's entire history. The snapshot only feeds summarize()'s avg30 / + * avg30LastMonth / avg30LastYear, and the widest of those reaches into + * the [365, 395)-day window, so nothing older than 395 days can change a + * result. These tests pin that: + * - every measurement read carries a `measuredAt.gte` lower bound, + * - the bound sits at ~400 days (5-day floor over the 395-day reach), + * - the three averages are unaffected for in-window data. + * + * The read fans out over all seven snapshot types on the page-blocking + * POST /api/insights/generate path AND the nightly pregenerate cron, so + * an unbounded scan is tens of thousands of rows per type on multi-year + * accounts. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const measurementFindMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + }, + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + }, +})); +// SLEEP_DURATION branch helpers — the test never exercises the sleep +// reconstruction maths, but the builder imports these unconditionally. +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => []), +})); + +import { buildComparisonSnapshotForUser } from "../comprehensive-generate"; + +const DAY = 86_400_000; + +beforeEach(() => { + vi.clearAllMocks(); + // Comparison toggle ON → the builder runs its per-type reads. The + // baseline value drives which prior-period mean the snapshot reports. + // `resolveDashboardLayout` only honours `comparisonBaseline` on a blob + // that carries a numeric `version` + a `widgets` array; otherwise it + // clamps to "none". + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "lastYear", + }, + }); + // No measurements by default; individual tests override per call. + measurementFindMany.mockResolvedValue([]); +}); + +describe("buildComparisonSnapshotForUser — bounded read window", () => { + it("bounds every measurement read with a ~400-day measuredAt.gte floor", async () => { + const before = Date.now(); + await buildComparisonSnapshotForUser("u1"); + const after = Date.now(); + + // One findMany per snapshot type (sleep takes a different select but + // the same where shape). Every call must carry the lower bound. + expect(measurementFindMany).toHaveBeenCalled(); + for (const call of measurementFindMany.mock.calls) { + const where = (call[0] as { where: Record }).where; + expect(where).toMatchObject({ userId: "u1", deletedAt: null }); + const measuredAt = where.measuredAt as { gte: Date }; + expect(measuredAt).toBeDefined(); + expect(measuredAt.gte).toBeInstanceOf(Date); + + // The floor is anchored on Date.now() - 400 days, read inside the call. + // Since before <= snapshotNow <= after, the floor brackets exactly to + // [before - 400d, after - 400d] — no tick-timing slack assumptions. + const gteMs = measuredAt.gte.getTime(); + expect(gteMs).toBeGreaterThanOrEqual(before - 400 * DAY); + expect(gteMs).toBeLessThanOrEqual(after - 400 * DAY); + } + }); + + it("keeps the floor strictly older than the 395-day reach of avg30LastYear", async () => { + const now = Date.now(); + await buildComparisonSnapshotForUser("u1"); + + // avg30LastYear reads points with age < 395 days. The bound must sit + // at least 5 days older so it never clips that window. + for (const call of measurementFindMany.mock.calls) { + const where = (call[0] as { where: { measuredAt: { gte: Date } } }).where; + const ageDays = (now - where.measuredAt.gte.getTime()) / DAY; + expect(ageDays).toBeGreaterThanOrEqual(395); + } + }); + + it("computes avg30 / avg30LastMonth / avg30LastYear unchanged for in-window data", async () => { + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "lastMonth", + }, + }); + + const now = Date.now(); + // WEIGHT is the first non-sleep type. Seed three points, one in each + // of the avg30 (current), avg30LastMonth ([30,60)) windows — all well + // inside the 400-day floor. + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + if (args.where.type !== "WEIGHT") return []; + return [ + { measuredAt: new Date(now - 5 * DAY), value: 80 }, // current + { measuredAt: new Date(now - 45 * DAY), value: 84 }, // lastMonth + ]; + }, + ); + + const snapshot = await buildComparisonSnapshotForUser("u1"); + expect(snapshot).not.toBeNull(); + const weight = snapshot!.metrics.find((m) => m.type === "weight"); + expect(weight).toBeDefined(); + // avg30 over the single current point. + expect(weight!.currentAvg).toBe(80); + // lastMonth baseline over the single [30,60) point. + expect(weight!.baselineAvg).toBe(84); + expect(weight!.delta).toBe(-4); + }); + + it("returns null without reading measurements when the comparison toggle is off", async () => { + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "none", + }, + }); + const snapshot = await buildComparisonSnapshotForUser("u1"); + expect(snapshot).toBeNull(); + expect(measurementFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index abd855e04..ab052a27e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -328,6 +328,14 @@ export async function buildComparisonSnapshotForUser( ACTIVITY_STEPS: "", }; const types = Object.keys(typeToSnapshotKey) as MeasurementType[]; + // The snapshot only feeds summarize()'s avg30 / avg30LastMonth / + // avg30LastYear. The widest of those reaches back into the [365, 395)-day + // window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395 + // days can change a result. Bound the read at 400 days — a 5-day floor over + // the strict-less-than boundary — instead of scanning the full history, + // which on multi-year accounts is tens of thousands of rows per type on the + // page-blocking generate path and the nightly pregenerate cron. + const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000); const rows = await Promise.all( types.map(async (type) => { // SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the @@ -339,7 +347,12 @@ export async function buildComparisonSnapshotForUser( if (type === "SLEEP_DURATION") { const [sleepRows, sleepTz, sleepPriority] = await Promise.all([ prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, @@ -363,7 +376,12 @@ export async function buildComparisonSnapshotForUser( .map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })); } else { const measurements = await prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }); diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 1795de825..f350ec7b5 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -502,6 +502,96 @@ describe("dispatchNotification — Telegram hard reject", () => { }); }); +describe("dispatchNotification — config parse/decrypt failure is a hard reject", () => { + it("malformed channel config auto-disables on first occurrence (not after 5 transient ticks)", async () => { + const channel = makeChannel({ + type: "NTFY", + // Not valid JSON → JSON.parse throws in sendToChannel. + config: "{ this is not json", + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + // The sender is never reached on a parse failure. + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + + // Channel auto-disabled immediately with the precise parse reason — + // NOT the misleading `give_up_after_5_failures`. + const updateCalls = ( + prisma.notificationChannel.update as unknown as ReturnType + ).mock.calls; + const disablePayload = updateCalls.find( + (c) => (c[0] as { data: { enabled?: boolean } }).data?.enabled === false, + ); + expect(disablePayload).toBeTruthy(); + expect( + (disablePayload?.[0] as { data: { disabledReason?: string } }).data + .disabledReason, + ).toBe("ntfy_config_parse_failed"); + + // It is logged as a hard reject, not a transient give-up. + expect(auditLog).toHaveBeenCalledWith( + "notification.channel.auto_disabled", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "ntfy_config_parse_failed", + kind: "hard_reject", + }), + }), + ); + }); + + it("a genuine transient failure still takes the transient path (stays enabled, backoff set)", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ serverUrl: "https://ntfy.example", topic: "t" }), + consecutiveFailures: 0, + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaNtfyMock.mockResolvedValueOnce({ + ok: false, + hardReject: false, + statusCode: 503, + reason: "ntfy_503", + }); + (prisma.notificationChannel.update as unknown as ReturnType) + .mockResolvedValueOnce({ consecutiveFailures: 1 }) + .mockResolvedValue({}); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + const updateCalls = ( + prisma.notificationChannel.update as unknown as ReturnType + ).mock.calls; + // No enabled=false update → channel stays enabled, backoff scheduled. + const disablePayload = updateCalls.find( + (c) => (c[0] as { data: { enabled?: boolean } }).data?.enabled === false, + ); + expect(disablePayload).toBeUndefined(); + const cooldownPayload = updateCalls.find( + (c) => + (c[0] as { data: { nextRetryAt?: Date } }).data?.nextRetryAt instanceof + Date, + ); + expect(cooldownPayload).toBeTruthy(); + }); +}); + describe("dispatchNotification — reminders reach Web Push without APNs", () => { it("a PWA-only self-hoster (WEB_PUSH channel, no APNS) still gets MEDICATION_REMINDER", async () => { const channel = makeChannel({ type: "WEB_PUSH" }); diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 9bbeadc27..cab12442e 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -263,9 +263,16 @@ async function sendToChannel( decrypted = decrypt(encryptedConfig); } catch { getEvent()?.addWarning(`Failed to decrypt ${type} channel config`); + // A config that can't be decrypted is a structurally-permanent failure, + // not a transient blip: the ciphertext / key pairing won't fix itself on + // a retry. Hard-reject so the channel auto-disables on first occurrence + // with an accurate reason, rather than burning the 5-failure transient + // budget and ending on the misleading `give_up_after_5_failures`. During + // a key-rotation gap every channel would otherwise auto-disable for the + // wrong reason. return { ok: false, - hardReject: false, + hardReject: true, reason: `${type.toLowerCase()}_config_decrypt_failed`, }; } @@ -277,9 +284,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as TelegramChannelConfig; } catch { getEvent()?.addWarning("Failed to parse Telegram channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "telegram_config_parse_failed", }; } @@ -291,9 +299,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as NtfyChannelConfig; } catch { getEvent()?.addWarning("Failed to parse ntfy channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "ntfy_config_parse_failed", }; } @@ -305,9 +314,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as WebhookChannelConfig; } catch { getEvent()?.addWarning("Failed to parse webhook channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "webhook_config_parse_failed", }; } @@ -319,9 +329,10 @@ async function sendToChannel( config = JSON.parse(decrypted) as EmailChannelConfig; } catch { getEvent()?.addWarning("Failed to parse email channel config"); + // Malformed config decodes the same on every retry — permanent. return { ok: false, - hardReject: false, + hardReject: true, reason: "email_config_parse_failed", }; } 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/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index 07ed3bbe7..ac2c43d96 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -63,8 +63,19 @@ export interface DailyMeanRow { */ export interface RegressionAccumulators { count: number; - /** Σy = mean·count (the writer stores mean + count; Σy is derived). */ + /** + * Per-bucket mean. Kept for the count/min/max/mean composition and as the + * Σy fallback (`mean·count`) when the exact `sumValue` accumulator is null. + */ mean: number; + /** + * Exact Σy = SUM(value) over the bucket's rows. The writer stores this in + * `sum_value` (migration pre-0190; `schema.prisma` `sumValue`); composing Σy + * from it instead of `mean·count` removes the ~1-ULP float-reorder residual + * the AVG→multiply round trip introduces. `null` on a row whose write + * predates the column, in which case the composer falls back to `mean·count`. + */ + sumValue?: number | null; sumX: number | null; sumXy: number | null; sumXx: number | null; @@ -83,15 +94,32 @@ export interface ComposedRegression { * v1.20.0 F6 — compose a windowed OLS slope / r² / population-sd from the * summed regression accumulators of a set of DAY buckets. * - * The six terms (`n = Σcount`, `Σx`, `Σy = Σ(mean·count)`, `Σxy`, `Σxx`, - * `Σyy`) are ADDITIVE across buckets, so summing the per-bucket - * accumulators over the window and evaluating the closed form yields a - * result BIT-IDENTICAL to Postgres `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` - * over the same raw rows (Postgres folds the same accumulators): + * The six terms (`n = Σcount`, `Σx`, `Σy`, `Σxy`, `Σxx`, `Σyy`) are ADDITIVE + * across buckets, so summing the per-bucket accumulators over the window and + * evaluating the closed form yields a result that matches Postgres + * `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` over the same raw rows (Postgres + * folds the same accumulators). * - * slope = (n·Σxy − Σx·Σy) / (n·Σxx − Σx²) - * r² = (n·Σxy − Σx·Σy)² / ((n·Σxx − Σx²)(n·Σyy − Σy²)) - * sd_pop = sqrt(Σyy/n − (Σy/n)²) + * The composition uses the MEAN-CENTERED (corrected-sum) identities rather + * than the textbook determinant form `n·Σxx − Σx²`: + * + * Sxx = Σxx − Σx²/n + * Sxy = Σxy − Σx·Σy/n + * Syy = Σyy − Σy²/n + * slope = Sxy / Sxx + * r² = Sxy² / (Sxx·Syy) + * sd_pop = sqrt(Syy / n) + * + * This is algebraically identical to the determinant form (multiply numerator + * and denominator by n) and to Postgres' REGR_* / STDDEV_POP, but numerically + * stable: on the epoch-day x-axis (x ≈ 20 540) the determinant form computes + * `n·Σxx − Σx²` as a difference of two ~1e10 magnitudes and sheds ~10 + * significant digits to catastrophic cancellation before the divide. The + * centered form subtracts `Σx²/n` from `Σxx` at the same scale per term, so + * the cancellation is bounded by the true x-variance, not the absolute x + * magnitude. Σy is read from the exact stored `sumValue` accumulator when + * present (falling back to `mean·count`), removing the AVG→multiply ULP + * residual on the y side too. * * The caller MUST collapse overlapping sources to the canonical source * BEFORE handing rows here — the accumulators are per-source, so summing a @@ -138,9 +166,10 @@ export function composeRegression( } n += b.count; sumX += b.sumX; - // Σy is reconstructed from the stored mean·count, matching the live - // SUM(value) over the same rows. - sumY += b.mean * b.count; + // Σy is the exact stored SUM(value) when the bucket carries it; fall back + // to mean·count for pre-`sumValue` rows. The exact accumulator removes the + // AVG→multiply float-reorder residual. + sumY += b.sumValue ?? b.mean * b.count; sumXy += b.sumXy; sumXx += b.sumXx; sumYy += b.sumYy; @@ -148,18 +177,22 @@ export function composeRegression( if (n < 2) return MISS; - const denomX = n * sumXx - sumX * sumX; - const denomY = n * sumYy - sumY * sumY; - const cov = n * sumXy - sumX * sumY; + // Mean-centered (corrected-sum) identities. Sxx/Sxy/Syy are the determinant + // terms divided by n — algebraically identical to `n·Σxx − Σx²` etc., but + // they subtract `Σx²/n` from `Σxx` at matched scale, avoiding the + // catastrophic cancellation the determinant form suffers on the ~1e10 + // epoch-day x-axis. See the function header for the full rationale. + const sxx = sumXx - (sumX * sumX) / n; + const syy = sumYy - (sumY * sumY) / n; + const sxy = sumXy - (sumX * sumY) / n; - const slope = denomX === 0 ? null : cov / denomX; + const slope = sxx === 0 ? null : sxy / sxx; // REGR_R2 is null when either variance is zero (matches Postgres). - const r2 = - denomX === 0 || denomY === 0 ? null : (cov * cov) / (denomX * denomY); + const r2 = sxx === 0 || syy === 0 ? null : (sxy * sxy) / (sxx * syy); - // Population variance: Σyy/n − (Σy/n)². Clamp tiny negative values that + // Population variance: Syy/n = Σyy/n − (Σy/n)². Clamp tiny negative values // float rounding can produce when the variance is effectively zero. - const variance = sumYy / n - (sumY / n) * (sumY / n); + const variance = syy / n; const sdPop = variance <= 0 ? 0 : Math.sqrt(variance); return { slope, r2, sdPop }; 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; } diff --git a/tests/integration/rollup-regression-parity.integration.test.ts b/tests/integration/rollup-regression-parity.integration.test.ts index 250ee15d1..b6e1d9177 100644 --- a/tests/integration/rollup-regression-parity.integration.test.ts +++ b/tests/integration/rollup-regression-parity.integration.test.ts @@ -135,6 +135,7 @@ async function foldedAccumulators( select: { count: true, mean: true, + sumValue: true, sumX: true, sumXy: true, sumXx: true, @@ -144,6 +145,7 @@ async function foldedAccumulators( return rows.map((r) => ({ count: r.count, mean: r.mean, + sumValue: r.sumValue, sumX: r.sumX, sumXy: r.sumXy, sumXx: r.sumXx, @@ -285,12 +287,29 @@ describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => { expect(blended.slope).not.toBeCloseTo(live.slope!, 6); }); - // TODO(v1.20.1): rebuild this cross-implementation REGR parity assertion. It - // is a test-harness artifact on the DST same-UTC-day reading pair, not a - // product bug — the production rollup math is covered bit-identically by the - // WEIGHT case above (it folds the same raw rows the live REGR reads). See - // .planning/v1200-backlog.md. Quarantined so the otherwise-complete, audited - // release is not blocked; restore once the harness scope is reconstructed. + // TODO(v1.20.1, still quarantined): the DST residual is a test-harness + // conditioning artifact, NOT a product bug — production math is covered + // bit-identically by the WEIGHT case above (which folds the same raw rows + // live REGR reads and passes at the tight 1e-9 relative bound). + // + // Root cause (per the v1.20.1 perf-parity verification): the original + // quarantine attributed the residual to catastrophic cancellation in the + // determinant form `n·Σxx − Σx²` on the ~1e10 epoch-day x-axis. + // `composeRegression` now evaluates the mean-centered (corrected-sum) + // identities (`Sxx = Σxx − Σx²/n`, …), which subtract the x mean at matched + // scale and shrink this residual by ~30× (was failing at ~1e-6-class noise; + // now ~9e-8). It is still above the 1e-9 relative gauge for THIS deliberately + // near-flat 5-point seed: the remaining gap is the difference between the + // composed closed form and Postgres' OWN internal REGR accumulator algorithm + // on an ill-conditioned (near-zero x-variance) window, not a divergence in + // the stored data. Both answers round to the same value at the read tier's + // 2–3 dp. + // + // The real close is the migration-based x-rescale noted for backlog: store / + // compose with x offset to a window-local origin (Σx → O(window) instead of + // ~20 540) so the products never reach 1e10. Until that lands the test stays + // quarantined rather than masked behind a loosened tolerance. See + // .planning/v1200-backlog.md. it.skip("DST boundary: the epoch-day x-axis is UTC, so a spring-forward reading parity-matches", async () => { const prisma = getPrismaClient(); const user = await seedUser(prisma);