mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
42 files changed
+927
-95
No files matched your search
@@ -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,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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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} | ||||