diff --git a/CHANGELOG.md b/CHANGELOG.md index b927ae7b0..d663d7389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.12.12] — 2026-06-05 — the period retrospective is never empty + +### Added + +- **The period retrospective fills in even without an AI provider.** When no AI provider is configured (or before the first AI summary has been written), the "period in review" card now shows a concise, factual summary built from your own data — the biggest changes versus the prior period, any vitals that moved outside your typical range, and an honest, strictly non-causal count of statistical associations. It reads in German or English and is replaced in place the moment an AI summary is generated. + ## [1.12.11] — 2026-06-05 — settings notices read the same everywhere ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43e433df7..f52dc9819 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.12.11 + version: 1.12.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 7551d7932..f9cb44cf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.12.11", + "version": "1.12.12", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index 5b48b8868..ff7909ed4 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -18,7 +18,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; -import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; @@ -66,11 +65,14 @@ export const GET = apiHandler(async (request: NextRequest) => { existing !== null && Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS; - // Read-only: never block on the provider. Warm out of band only when stale - // / missing AND a provider is configured (a provider-less account costs one - // cheap chain-resolve and shows the no-key state instead). + // Read-only: never block on the provider. Warm out of band whenever the row + // is stale / missing. The generator produces AI prose when a provider is + // configured and a deterministic, non-causal fallback otherwise, so even a + // provider-less account (incl. the no-key demo) gets a non-empty + // retrospective on the next read. The enqueue's singletonKey + the 20 h + // freshness window bound this to at most one warm per period per ~day. let revalidating = false; - if (!isFresh && (await hasUsableStatusProvider(user.id))) { + if (!isFresh) { void enqueueNarrativeWarm({ userId: user.id, period, locale }); revalidating = true; } diff --git a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts new file mode 100644 index 000000000..ad6dd7576 --- /dev/null +++ b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; + +import { buildDeterministicNarrative } from "@/lib/insights/narrative/period-narrative-deterministic"; +import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative"; + +function ctx(over: Partial = {}): PeriodNarrativeContext { + return { + status: "ready", + period: "week", + metricDeltas: [], + bandTransitions: [], + drivers: [], + coincidentFlags: [], + pairsTested: 0, + fdrQ: 0.1, + provenance: { + metrics: [], + window: { from: "2026-05-01T00:00:00.000Z", to: "2026-05-08T00:00:00.000Z" }, + computedAt: "2026-05-08T05:00:00.000Z", + }, + ...over, + }; +} + +const WEIGHT_DOWN = { + type: "WEIGHT" as const, + unit: "kg", + current: 80, + prior: 81, + delta: -1, + deltaPercent: -1.2, + currentDays: 6, + priorDays: 7, +}; + +describe("buildDeterministicNarrative", () => { + it("leads with the ranked changes and labels the metric in English", () => { + const text = buildDeterministicNarrative( + ctx({ metricDeltas: [WEIGHT_DOWN] }), + "en", + ); + expect(text).toContain("the last week"); + expect(text).toContain("your weight"); + expect(text).toContain("1 kg"); + expect(text).toContain("1.2 %"); // dot decimal mark in English + }); + + it("localises labels, decimal mark, and period in German", () => { + const text = buildDeterministicNarrative( + ctx({ metricDeltas: [WEIGHT_DOWN] }), + "de", + ); + expect(text).toContain("der letzten Woche"); + expect(text).toContain("dein Gewicht"); + expect(text).toContain("1,2 %"); // comma decimal mark + }); + + it("ranks by relative magnitude and caps at three movers", () => { + const deltas = [ + { ...WEIGHT_DOWN, type: "WEIGHT" as const, delta: -1, deltaPercent: -1 }, + { + ...WEIGHT_DOWN, + type: "PULSE" as const, + unit: "bpm", + delta: 5, + deltaPercent: 8, + }, + { + ...WEIGHT_DOWN, + type: "RESTING_HEART_RATE" as const, + unit: "bpm", + delta: 2, + deltaPercent: 3, + }, + { + ...WEIGHT_DOWN, + type: "BODY_FAT" as const, + unit: "%", + delta: 0.1, + deltaPercent: 0.3, + }, + ]; + const text = buildDeterministicNarrative(ctx({ metricDeltas: deltas }), "en"); + // Pulse (+8%) is the strongest mover and must lead; the 0.3% body-fat + // mover is below the top three and must be dropped. + expect(text.indexOf("your pulse")).toBeGreaterThan(-1); + expect(text).not.toContain("body fat"); + expect(text.indexOf("your pulse")).toBeLessThan(text.indexOf("your weight")); + }); + + it("reports a held-steady period when nothing moved", () => { + expect(buildDeterministicNarrative(ctx(), "en")).toContain("held largely steady"); + expect(buildDeterministicNarrative(ctx(), "de")).toContain( + "weitgehend stabil", + ); + }); + + it("notes vitals that moved outside the typical range", () => { + const text = buildDeterministicNarrative( + ctx({ + bandTransitions: [ + { + type: "BLOOD_PRESSURE_SYS", + center: 140, + bandLow: 110, + bandHigh: 130, + direction: "above", + movedOut: true, + baselineDays: 10, + }, + ], + }), + "en", + ); + expect(text).toContain("systolic blood pressure"); + expect(text).toContain("above your typical range"); + }); + + it("mentions associations strictly non-causally and never invents one", () => { + const withDrivers = buildDeterministicNarrative( + ctx({ + drivers: [ + { + behaviour: "ACTIVITY_STEPS", + outcome: "SLEEP_DURATION", + r: 0.4, + qValue: 0.02, + n: 14, + interpretation: "x", + }, + ], + }), + "en", + ); + expect(withDrivers).toContain("not causal"); + expect(withDrivers).toContain("1 statistical association"); + // No drivers → no association sentence at all. + expect(buildDeterministicNarrative(ctx(), "en")).not.toContain( + "association", + ); + }); + + it("falls back to a prettified label for an unmapped metric type", () => { + const text = buildDeterministicNarrative( + ctx({ + metricDeltas: [ + { + ...WEIGHT_DOWN, + // A type with no entry in METRIC_LABELS. + type: "SOME_NEW_METRIC" as never, + unit: "x", + delta: 3, + deltaPercent: 4, + }, + ], + }), + "en", + ); + expect(text).toContain("your some new metric"); + }); +}); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 20c08b811..f4f1842c4 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -191,7 +191,7 @@ describe("generatePeriodNarrative — honesty floor", () => { expect(prisma.insightNarrative.upsert).not.toHaveBeenCalled(); }); - it("no-ops without a provider and never fabricates a story", async () => { + it("composes a deterministic, non-causal fallback when no provider is configured", async () => { const prisma = makePrisma(); const outcome = await generatePeriodNarrative("u1", { period: "week", @@ -201,8 +201,19 @@ describe("generatePeriodNarrative — honesty floor", () => { buildContext: async () => readyContext() as PeriodNarrativeResult, runCompletion: async () => ({ kind: "none" as const }), }); - expect(outcome).toEqual({ status: "skipped", reason: "no-provider" }); - expect(prisma.insightNarrative.upsert).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + status: "generated", + providerType: "deterministic", + }); + expect(prisma.insightNarrative.upsert).toHaveBeenCalledTimes(1); + // The persisted row carries the deterministic marker + non-empty prose. + const stored = prisma._get(); + expect(stored?.providerType).toBe("deterministic"); + const text = Buffer.from(stored!.encryptedContent) + .toString("utf8") + .replace(/^enc:/, ""); + expect(text).toContain("your weight"); + expect(text.toLowerCase()).toContain("not causal"); }); }); diff --git a/src/lib/insights/narrative/period-narrative-deterministic.ts b/src/lib/insights/narrative/period-narrative-deterministic.ts new file mode 100644 index 000000000..3373bbf18 --- /dev/null +++ b/src/lib/insights/narrative/period-narrative-deterministic.ts @@ -0,0 +1,211 @@ +/** + * v1.12.12 — deterministic, non-AI period-narrative fallback (iOS H2). + * + * The period-retrospective card depends on a generated narrative row. For an + * account with no usable AI provider (or before the first AI warm completes) + * that row never appears and the card stays empty. This module composes a + * short, factual, strictly NON-CAUSAL summary from the SAME structured + * `PeriodNarrativeContext` the AI prompt is built from — no provider call — + * so the card is never empty for an active account (and the no-key demo + * fills too). + * + * Persisted with `providerType = "deterministic"` so a later AI warm replaces + * it in place; the read route is unchanged (same envelope, same prose field). + * + * Pure + locale-bound (narratives are de/en only). Metric labels come from a + * self-contained bilingual map — no dependency on message-bundle key coverage + * — with a prettified-enum fallback for any unmapped type, so a newly added + * measurement type degrades gracefully instead of leaking an enum constant. + */ +import type { + PeriodNarrativeContext, + MetricDelta, +} from "./period-narrative"; + +/** Marker stored in `InsightNarrative.providerType` for a fallback row. */ +export const DETERMINISTIC_PROVIDER_TYPE = "deterministic" as const; + +type Locale = "de" | "en"; + +/** Bilingual labels for the metric types that surface in a narrative. */ +const METRIC_LABELS: Record = { + WEIGHT: { de: "dein Gewicht", en: "your weight" }, + BLOOD_PRESSURE_SYS: { + de: "dein systolischer Blutdruck", + en: "your systolic blood pressure", + }, + BLOOD_PRESSURE_DIA: { + de: "dein diastolischer Blutdruck", + en: "your diastolic blood pressure", + }, + PULSE: { de: "dein Puls", en: "your pulse" }, + RESTING_HEART_RATE: { de: "dein Ruhepuls", en: "your resting heart rate" }, + HEART_RATE_VARIABILITY: { + de: "deine Herzfrequenzvariabilität", + en: "your heart-rate variability", + }, + RESPIRATORY_RATE: { de: "deine Atemfrequenz", en: "your respiratory rate" }, + OXYGEN_SATURATION: { + de: "deine Sauerstoffsättigung", + en: "your oxygen saturation", + }, + BODY_FAT: { de: "dein Körperfettanteil", en: "your body fat" }, + MUSCLE_MASS: { de: "deine Muskelmasse", en: "your muscle mass" }, + TOTAL_BODY_WATER: { + de: "dein Körperwasser", + en: "your total body water", + }, + BONE_MASS: { de: "deine Knochenmasse", en: "your bone mass" }, + BMI: { de: "dein BMI", en: "your BMI" }, + SLEEP_DURATION: { de: "deine Schlafdauer", en: "your sleep duration" }, + ACTIVITY_STEPS: { de: "deine Schritte", en: "your step count" }, + ACTIVE_ENERGY: { de: "dein Aktivitätsumsatz", en: "your active energy" }, + BLOOD_GLUCOSE: { de: "dein Blutzucker", en: "your blood glucose" }, + VO2MAX: { de: "deine VO₂max", en: "your VO₂max" }, + BODY_TEMPERATURE: { + de: "deine Körpertemperatur", + en: "your body temperature", + }, + MOOD: { de: "deine Stimmung", en: "your mood" }, +}; + +/** Prettify an unmapped enum constant: RESTING_HEART_RATE → resting heart rate. */ +function prettifyType(type: string, locale: Locale): string { + const words = type.toLowerCase().replace(/_/g, " "); + return locale === "de" ? `dein Wert „${words}“` : `your ${words}`; +} + +function labelFor(type: string, locale: Locale): string { + return METRIC_LABELS[type]?.[locale] ?? prettifyType(type, locale); +} + +/** Format a signed number with the locale's decimal mark (no thousands sep). */ +function fmtSigned(n: number, locale: Locale): string { + const sign = n > 0 ? "+" : n < 0 ? "−" : "±"; // U+2212 minus for typography + const abs = Math.abs(n); + // Integers print verbatim; fractions print at 2dp with trailing zeros + // trimmed (so 1.20 → 1.2, 1.50 → 1.5) WITHOUT touching integer zeros. + const s = Number.isInteger(abs) + ? String(abs) + : abs + .toFixed(2) + .replace(/0+$/, "") + .replace(/\.$/, ""); + const localised = locale === "de" ? s.replace(".", ",") : s; + return `${sign}${localised}`; +} + +/** A delta is "notable" when it has a computable, non-zero change. */ +function isNotable(d: MetricDelta): boolean { + return d.delta !== null && d.delta !== 0; +} + +/** Rank notable deltas by relative magnitude (percent), falling back to abs. */ +function rankDeltas(deltas: MetricDelta[]): MetricDelta[] { + return deltas + .filter(isNotable) + .slice() + .sort((a, b) => { + const ap = a.deltaPercent !== null ? Math.abs(a.deltaPercent) : null; + const bp = b.deltaPercent !== null ? Math.abs(b.deltaPercent) : null; + if (ap !== null && bp !== null) return bp - ap; + if (ap !== null) return -1; + if (bp !== null) return 1; + return Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0); + }); +} + +function deltaPhrase(d: MetricDelta, locale: Locale): string { + const label = labelFor(d.type, locale); + const unit = d.unit ? ` ${d.unit}` : ""; + const amount = `${fmtSigned(d.delta as number, locale)}${unit}`; + const pct = + d.deltaPercent !== null && d.deltaPercent !== 0 + ? ` (${fmtSigned(d.deltaPercent, locale)} %)` + : ""; + return `${label} (${amount}${pct})`; +} + +/** + * Compose the deterministic narrative prose. Always returns a non-empty, + * factual paragraph for a ready context: changes lead, then any vitals that + * moved out of their typical range, then an honest, non-causal mention of how + * many statistical associations were noted. Returns the "held steady" line + * when nothing moved. + */ +export function buildDeterministicNarrative( + context: PeriodNarrativeContext, + locale: Locale, +): string { + const periodLabel = + context.period === "week" + ? locale === "de" + ? "der letzten Woche" + : "the last week" + : locale === "de" + ? "dem letzten Monat" + : "the last month"; + + const sentences: string[] = []; + + const ranked = rankDeltas(context.metricDeltas).slice(0, 3); + if (ranked.length > 0) { + const phrases = ranked.map((d) => deltaPhrase(d, locale)); + const joined = + locale === "de" ? joinList(phrases, "und") : joinList(phrases, "and"); + sentences.push( + locale === "de" + ? `In ${periodLabel} hat sich am deutlichsten verändert: ${joined}, jeweils im Vergleich zum vorherigen Zeitraum.` + : `Over ${periodLabel}, the clearest changes were ${joined}, each compared with the prior period.`, + ); + } else { + sentences.push( + locale === "de" + ? `In ${periodLabel} sind deine erfassten Werte weitgehend stabil geblieben.` + : `Over ${periodLabel}, your tracked metrics held largely steady.`, + ); + } + + const movedOut = context.bandTransitions.filter((b) => b.movedOut); + if (movedOut.length > 0) { + const parts = movedOut.map((b) => { + const label = labelFor(b.type, locale); + const where = + b.direction === "above" + ? locale === "de" + ? "über" + : "above" + : locale === "de" + ? "unter" + : "below"; + return locale === "de" + ? `${label} lag ${where} deinem üblichen Bereich` + : `${label} sat ${where} your typical range`; + }); + const joined = + locale === "de" ? joinList(parts, "und") : joinList(parts, "and"); + sentences.push(capitaliseFirst(`${joined}.`)); + } + + if (context.drivers.length > 0) { + const n = context.drivers.length; + sentences.push( + locale === "de" + ? `Außerdem ${n === 1 ? "wurde 1 statistischer Zusammenhang" : `wurden ${n} statistische Zusammenhänge`} festgestellt — rein beschreibend, nicht ursächlich.` + : `${n === 1 ? "1 statistical association was" : `${n} statistical associations were`} also noted — descriptive only, not causal.`, + ); + } + + return sentences.join(" "); +} + +/** Join a list with commas and a final conjunction, locale-agnostic glue. */ +function joinList(items: string[], conjunction: string): string { + if (items.length === 0) return ""; + if (items.length === 1) return items[0]; + return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`; +} + +function capitaliseFirst(s: string): string { + return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1); +} diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index d3a04e6d0..339eea9d0 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -35,6 +35,10 @@ import { type NarrativePeriod, type PeriodNarrativeContext, } from "@/lib/insights/narrative/period-narrative"; +import { + buildDeterministicNarrative, + DETERMINISTIC_PROVIDER_TYPE, +} from "@/lib/insights/narrative/period-narrative-deterministic"; /** * Stable identifier for the narrative prompt revision. Bumped whenever the @@ -261,9 +265,10 @@ export async function generatePeriodNarrative( maxTokens: 400, }); - if (completion.kind === "none") { - return { status: "skipped", reason: "no-provider" }; - } + // A provider error / timeout is non-fatal — the last good row stays as-is + // and the next read re-tries. We do NOT fall back to deterministic prose + // here: a configured provider that briefly fails should not overwrite (or + // shadow) its own richer narrative with the terse fallback. if (completion.kind === "timeout") { annotate({ action: { name: "insights.narrative.timeout" }, @@ -275,11 +280,6 @@ export async function generatePeriodNarrative( return { status: "failed", reason: "provider-error" }; } - const text = completion.content.trim(); - if (text.length === 0) { - return { status: "failed", reason: "empty" }; - } - const provenance: NarrativeProvenancePayload = { metrics: context.provenance.metrics, window: context.provenance.window, @@ -287,36 +287,52 @@ export async function generatePeriodNarrative( fdrQ: context.fdrQ, computedAt: context.provenance.computedAt, }; + const dateKey = dateKeyFor(now, tz); // Upsert the single (user, period, locale) row in place — delete/regenerate // clean by construction. The prose is held AES-256-GCM at rest. - const encryptedContent = encryptToBytes(text); - const dateKey = dateKeyFor(now, tz); - await prisma.insightNarrative.upsert({ - where: { userId_period_locale: { userId, period, locale } }, - create: { - userId, - period, - locale, - dateKey, - encryptedContent, - provenanceJson: JSON.stringify(provenance), - providerType: completion.providerType, - promptVersion: NARRATIVE_PROMPT_VERSION, - }, - update: { - dateKey, - encryptedContent, - provenanceJson: JSON.stringify(provenance), - providerType: completion.providerType, - promptVersion: NARRATIVE_PROMPT_VERSION, - }, - }); + const persist = async (text: string, providerType: string) => { + const encryptedContent = encryptToBytes(text); + await prisma.insightNarrative.upsert({ + where: { userId_period_locale: { userId, period, locale } }, + create: { + userId, + period, + locale, + dateKey, + encryptedContent, + provenanceJson: JSON.stringify(provenance), + providerType, + promptVersion: NARRATIVE_PROMPT_VERSION, + }, + update: { + dateKey, + encryptedContent, + provenanceJson: JSON.stringify(provenance), + providerType, + promptVersion: NARRATIVE_PROMPT_VERSION, + }, + }); + annotate({ + action: { name: "insights.narrative.generated" }, + meta: { period, locale, provider: providerType }, + }); + }; - annotate({ - action: { name: "insights.narrative.generated" }, - meta: { period, locale, provider: completion.providerType }, - }); + // No usable provider → compose the deterministic, non-causal fallback from + // the same structured context, so the retrospective card is never empty for + // an active account (iOS H2). A later AI warm overwrites this row in place. + if (completion.kind === "none") { + const text = buildDeterministicNarrative(context, locale); + await persist(text, DETERMINISTIC_PROVIDER_TYPE); + return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE }; + } + + const text = completion.content.trim(); + if (text.length === 0) { + return { status: "failed", reason: "empty" }; + } + await persist(text, completion.providerType); return { status: "generated", providerType: completion.providerType }; } diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 52039bfcd..067f2d266 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -11,9 +11,10 @@ * * Budget gate (mirrors `insight-pregenerate`): a per-user rate-limit bucket * (`period-narrative:`, one warm / 20 h) bounds nightly LLM cost, and - * a per-run batch cap bounds a single tick. The generator no-ops cleanly - * without a provider (one cheap chain-resolve, no LLM) and writes nothing on - * an insufficient context, so a provider-less / sparse account is near-free. + * a per-run batch cap bounds a single tick. Without a provider the generator + * makes no LLM call — it composes the deterministic, non-causal fallback from + * the same context instead — and writes nothing on an insufficient context, + * so a provider-less / sparse account costs only the context build. * * Recurring pg-boss task — never runs inside an HTTP request, never shells out * to `tsx` (CLAUDE.md DO-NOTs). @@ -219,8 +220,8 @@ function tally( * Single-user warm enqueued by the read-only GET on a cold/stale read. Runs * the generator directly for one (user, period, locale) WITHOUT the nightly * budget bucket (the enqueue's `singletonKey` is the anti-spam layer). The - * generator no-ops cleanly without a provider and writes nothing on an - * insufficient context, so this is bounded by construction. + * generator composes the deterministic fallback without a provider and writes + * nothing on an insufficient context, so this is bounded by construction. */ export async function warmOneNarrative( payload: PeriodNarrativePayload,