mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge pull request #269 from MBombeck/release/v1.12.12
release/v1.12.12 — deterministic period-retrospective fallback
This commit is contained in:
9 files changed
+457
-49
No files matched your search
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user