Merge pull request #362 from MBombeck/release/v1.20.2

Release v1.20.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-26 09:52:24 +02:00
commit e9686bb929
42 files changed
+927 -95

No files matched your search

+22
View File
@@ -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.
+1 -2
View File
@@ -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:
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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);
});
});
+4 -2
View File
@@ -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,
+4 -2
View File
@@ -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 =
+4 -2
View File
@@ -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);
+7
View File
@@ -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;
+20 -2
View File
@@ -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 },
});
+1 -1
View File
@@ -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";
@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
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<typeof import("@/lib/labs/ocr-extract")>(
"@/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",
);
});
});
+10 -9
View File
@@ -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",
+17
View File
@@ -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")}
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
{t("labs.ocr.lowConfidenceBadge")}
</Badge>
) : null}
</div>
</div>
</div>
@@ -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);
});
});
@@ -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", () => {
+12
View File
@@ -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<string, AiBudget>;
+13 -2
View File
@@ -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
+4 -2
View File
@@ -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";
+4
View File
@@ -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<CoachToolLoopResult> {
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 }),
+40 -5
View File
@@ -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<CompletionResult> {
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 {
+17 -2
View File
@@ -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 {
+16 -2
View File
@@ -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 {
+12
View File
@@ -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,
};
}
+22 -8
View File
@@ -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)
);
}
+16 -3
View File
@@ -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<string>();
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,
@@ -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", () => {
+22 -7
View File
@@ -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) {
@@ -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<string, unknown> }).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();
});
});
+20 -2
View File
@@ -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 },
});
@@ -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<typeof vi.fn>
).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<typeof vi.fn>)
.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<typeof vi.fn>
).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" });
+16 -5
View File
@@ -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",
};
}
+1 -3
View File
@@ -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({
+53 -20
View File
@@ -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 };
+1 -1
View File
@@ -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;
}
@@ -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);