Merge pull request #269 from MBombeck/release/v1.12.12

release/v1.12.12 — deterministic period-retrospective fallback
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 19:16:50 +02:00
commit 09bdd49791
9 files changed
+457 -49

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+7 -5
View File
@@ -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;
}
@@ -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> = {}): 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");
});
});
@@ -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");
});
});
@@ -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<string, { de: string; en: string }> = {
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);
}
@@ -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 };
}
+6 -5
View File
@@ -11,9 +11,10 @@
*
* Budget gate (mirrors `insight-pregenerate`): a per-user rate-limit bucket
* (`period-narrative:<userId>`, 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,