From 23cc48475d46bc0eab1126fbc45fc308bd6fbba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:21:29 +0200 Subject: [PATCH 01/14] perf(insights): bound the comparison snapshot to a 400-day window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildComparisonSnapshotForUser read a user's entire measurement history per type with no date floor, then fed each series to summarize(). The snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the widest of those reaches into the [365, 395)-day window (ageMs < 395 days in meanOfWindow), so nothing older than 395 days can change a result. Add a 400-day measuredAt.gte lower bound to every per-type read in both copies of the builder — the page-blocking POST /api/insights/generate handler and the shared comprehensive-generate path the nightly pregenerate cron drives. The fan-out runs all seven snapshot types via Promise.all, so on multi-year accounts the unbounded scan was tens of thousands of rows per type. 400 days keeps a 5-day floor over the strict-less-than 395-day reach. --- src/app/api/insights/generate/route.ts | 22 ++- .../comparison-snapshot-window.test.ts | 146 ++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 22 ++- 3 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/lib/insights/__tests__/comparison-snapshot-window.test.ts 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/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts new file mode 100644 index 000000000..fb743feb9 --- /dev/null +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -0,0 +1,146 @@ +/** + * 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 (allow scheduling + // slack between the snapshot's clock read and the test's bracket). + const gteMs = measuredAt.gte.getTime(); + expect(gteMs).toBeLessThanOrEqual(before - 400 * DAY); + expect(gteMs).toBeGreaterThanOrEqual(after - 401 * 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 }, }); From e4c0af097003e2580fc6a6bb4588d8a28a60d928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:21:49 +0200 Subject: [PATCH 02/14] fix(illness): gate fever red-flag runs on calendar-adjacent days Symptom and vital points are present-days-only, so a sparse febrile series (e.g. days 10/15/20/25) was counted as one long run and falsely fired the sustained_fever escalation. Continue a run only when the next matching point is the calendar day immediately after the previous one, using the existing dayDiff helper; otherwise start a fresh run of one. --- src/lib/illness/__tests__/correlation.test.ts | 64 +++++++++++++++++++ src/lib/illness/correlation.ts | 29 +++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) 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) { From 3e4cc53ede3651658c391c5f49a80036be2de570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:21:54 +0200 Subject: [PATCH 03/14] fix(ai): exclude text-only o-series variants from vision capability The o1/o3/o4 prefix match also caught o1-mini, o1-preview and o3-mini, which have no image input. A user pinned to one was told OCR works, then the provider rejected the image block. Treat an o1/o3/o4 slug as vision-capable only when it is not a -mini or -preview variant, applied to both the OpenAI and Codex gates. --- .../ai/__tests__/vision-capability.test.ts | 23 ++++++++++++++ src/lib/ai/vision-capability.ts | 30 ++++++++++++++----- 2 files changed, 45 insertions(+), 8 deletions(-) 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/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) ); } From 672e3f0e07fd96652df60513873eb4fa437ea263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:25:12 +0200 Subject: [PATCH 04/14] fix(rollups): stabilise composed regression via centered identities Reformulate composeRegression from the determinant form (n*Sxx - Sx^2) to the mean-centered corrected-sum identities (Sxx = Sxx - Sx^2/n, ...). Algebraically identical to the prior form and to Postgres REGR_* / STDDEV_POP, but it subtracts the x mean at matched scale and avoids the catastrophic cancellation the determinant form suffers on the ~1e10 epoch-day x-axis. Also read the exact stored sum_value accumulator for the Sy term instead of reconstructing it as mean*count, dropping the AVG-to-multiply float residual; fall back to mean*count when the column is null. Composition-only: no migration, no schema change. The WEIGHT live-parity case stays bit-identical at the 1e-9 relative bound. The near-flat DST parity seed improves by ~30x but still exceeds 1e-9, so it remains quarantined with an updated note pointing at a window-local x-rescale as the real fix. --- src/lib/rollups/measurement-read.ts | 73 ++++++++++++++----- ...llup-regression-parity.integration.test.ts | 31 ++++++-- 2 files changed, 78 insertions(+), 26 deletions(-) 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/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); From e1e1b0e9853d7f66b3a338bc966428e460d42358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:31 +0200 Subject: [PATCH 05/14] fix(notifications): treat channel config decrypt/parse failure as a hard reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel whose stored config cannot be decrypted or JSON-parsed is a structurally-permanent failure, not a transient blip: the ciphertext/key pairing and the malformed body decode the same way on every retry. Routing it through the transient path burned the 5-failure backoff budget and ended on the misleading `give_up_after_5_failures` reason — during a key-rotation gap every channel hit this and auto-disabled for the wrong reason. Classify decrypt and parse failures as hard rejects so the channel auto-disables on first occurrence with the precise reason (`_config_decrypt_failed` / `_config_parse_failed`). Genuine transient failures (network, timeout, 5xx) keep the existing backoff path. --- .../__tests__/dispatcher.test.ts | 90 +++++++++++++++++++ src/lib/notifications/dispatcher.ts | 21 +++-- 2 files changed, 106 insertions(+), 5 deletions(-) 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", }; } 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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; } From 4c571b0e0419b05a4ac9059680449adad2d38fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:27:29 +0200 Subject: [PATCH 10/14] fix(insights): cancel the Coach upstream provider call on client disconnect The Coach SSE turn ran the provider call to completion before the stream existed and never wired it to the request's abort signal, so a mid-generation client disconnect still paid the full upstream token cost. Thread an optional `signal` through CompletionParams and singleUserTurn into each client's safeFetch (composed with the per-call timeout via AbortSignal.any), and pass `request.signal` from the chat route's no-tools path. An early disconnect now tears the upstream request down instead of generating into a closed connection. Omitting the signal leaves behaviour unchanged (timeout-only). --- src/app/api/insights/chat/route.ts | 4 ++++ src/lib/ai/anthropic-client.ts | 4 +++- src/lib/ai/codex-client.ts | 4 +++- src/lib/ai/local-client.ts | 8 +++++++- src/lib/ai/openai-client.ts | 4 +++- src/lib/ai/types.ts | 12 ++++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index aedc94be7..f418a1c7e 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -567,6 +567,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/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 97162de03..00c704701 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) { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2be9fcf8c..dcae2020a 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 }, ); } diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 477d9bebc..fa41db7d7 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) { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index e4a8c808d..f84cda5f4 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) { 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, }; } From c304268a9ea2405f328b708acc0d2180e0f28814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:27:42 +0200 Subject: [PATCH 11/14] fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four AI clients threw bare Errors on an empty reply (and, for Codex, on a stream-level failure) with no httpStatus, so the provider-chain classifier saw the same null-status shape as a genuine transport failure (ECONNRESET, DNS, timeout) and could not tell them apart in observability. Attach a sentinel `httpStatus: 0` plus a `kind` discriminator (`empty_response` / `stream_failed`) on those throws. The cascade is unchanged: `isHardProviderFailure` already treats a status <= 0 as a hard failure and walks to the next provider — this only sharpens the logs. --- src/lib/ai/__tests__/openai-client.test.ts | 68 ++++++++++++++++++++++ src/lib/ai/anthropic-client.ts | 11 +++- src/lib/ai/codex-client.ts | 41 +++++++++++-- src/lib/ai/local-client.ts | 11 +++- src/lib/ai/openai-client.ts | 14 ++++- 5 files changed, 138 insertions(+), 7 deletions(-) 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/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 00c704701..349db83d2 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -320,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/codex-client.ts b/src/lib/ai/codex-client.ts index dcae2020a..4bc717814 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -494,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(); @@ -597,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, }); @@ -606,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 @@ -629,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 fa41db7d7..eaf30ceff 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -161,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 f84cda5f4..26b291296 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -126,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 { From bf29ab1e0e01370c3b94de85ddb1814b03651e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:30:08 +0200 Subject: [PATCH 12/14] fix(insights): cancel the Coach tool-loop provider calls on disconnect Thread the request abort signal through runCoachToolLoop into each per-round completion so a mid-generation client disconnect tears down the upstream call instead of paying the full token cost. Completes the no-tools-path wiring to cover the tool-retrieval path as well. Regenerate the OpenAPI spec for the trimmed OCR-capability reason enum. --- docs/api/openapi.yaml | 1 - src/app/api/insights/chat/route.ts | 3 +++ src/lib/ai/coach/tools/loop.ts | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index befef0a1b..a4e4dbcec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -19630,7 +19630,6 @@ components: - type: string enum: - no-provider - - text-only-model - enable-local-ocr - type: "null" pdfSupported: diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index f418a1c7e..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; 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 }), From cd6fff973083e61d91501b9691833194b6f742c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:36:02 +0200 Subject: [PATCH 13/14] chore(release): v1.20.2 --- CHANGELOG.md | 22 ++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) 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 a4e4dbcec..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. 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/*` From 5e701cebc01754ed515ef52491910e78128bce59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:44:35 +0200 Subject: [PATCH 14/14] test(insights): bracket the snapshot window floor with the call's own clock The floor reads Date.now() inside buildComparisonSnapshotForUser, so it lands in [before - 400d, after - 400d] relative to the test's brackets. The prior upper bound used before - 400d, which fails when a millisecond ticks between the bracket read and the snapshot's clock read. --- .../__tests__/comparison-snapshot-window.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts index fb743feb9..22d52888b 100644 --- a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -76,11 +76,12 @@ describe("buildComparisonSnapshotForUser — bounded read window", () => { expect(measuredAt).toBeDefined(); expect(measuredAt.gte).toBeInstanceOf(Date); - // The floor is anchored on Date.now() - 400 days (allow scheduling - // slack between the snapshot's clock read and the test's bracket). + // 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).toBeLessThanOrEqual(before - 400 * DAY); - expect(gteMs).toBeGreaterThanOrEqual(after - 401 * DAY); + expect(gteMs).toBeGreaterThanOrEqual(before - 400 * DAY); + expect(gteMs).toBeLessThanOrEqual(after - 400 * DAY); } });