feat(insights): vary daily-briefing prose on unchanged data

The content-hash gate re-stamps the prior text whenever the snapshot is
unchanged, so the "daily" briefing read byte-stable day-over-day. Decouple
phrasing freshness from data freshness for the dailyBriefing.paragraph
alone: on a hash-unchanged hit, re-roll just the paragraph once per
calendar day at a higher, seedless temperature, keeping every structured
finding verbatim from the cached payload.

A new `insightsBriefingRerollDate` column gates it to at most one extra
call per day, and a re-roll miss still stamps the day so a failing
provider cannot be re-driven on every page-open. The hash gate stays
fully intact for the status cards (reference assessments — monotony is
fine).
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:05:19 +02:00
1 parent a61b7fe4ad
commit 853a1cd301
4 files changed
+365 -30

No files matched your search

@@ -0,0 +1,5 @@
-- v1.18.7 — track the daily-briefing phrasing re-roll date.
-- The content-hash gate keeps the briefing findings byte-stable on
-- unchanged data; this column lets the paragraph be re-rolled once per
-- calendar day (UTC) for phrasing variety without re-paying for findings.
ALTER TABLE "users" ADD COLUMN "insights_briefing_reroll_date" TEXT;
+7
View File
@@ -71,6 +71,13 @@ model User {
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
// v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
// The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
// but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
// higher, seedless temperature so the prose reads fresh day-over-day
// without re-paying for the findings. This stamps the day a re-roll ran so
// the gate stays at most one extra call per day.
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -0,0 +1,182 @@
/**
* v1.18.7 (MEDIUM-3) — daily-briefing phrasing re-roll on unchanged data.
*
* The content-hash gate keeps the FINDINGS byte-stable when the snapshot is
* unchanged, but the daily-briefing PARAGRAPH is re-rolled at most once per
* calendar day at a higher, seedless temperature so the prose reads fresh.
* These tests pin: the re-roll fires when today's date has not been stamped,
* it preserves every cached field except the paragraph, it is seedless +
* higher-temperature, and it does NOT fire a second time the same day.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const findUnique = vi.fn();
const userUpdate = vi.fn();
const resolveProviderChain = vi.fn();
const resolveProvider = vi.fn();
const runRawCompletionWithFallback = vi.fn();
const extractFeatures = vi.fn();
const invalidateUserInsights = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
},
auditLog: { deleteMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: (...a: unknown[]) =>
runRawCompletionWithFallback(...a),
}));
vi.mock("@/lib/insights/features", () => ({
FeaturesPayloadTooLargeError: class extends Error {
sizeBytes = 0;
},
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
}));
vi.mock("@/lib/insights/glp1-plateau", () => ({
detectGlp1Plateau: vi.fn(async () => null),
buildGlp1PlateauPrompt: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
buildAboutMeInsightBlock: vi.fn(() => ""),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: (...a: unknown[]) => invalidateUserInsights(...a),
}));
import { generateComprehensiveInsight } from "../comprehensive-generate";
import { hashInsightSnapshot } from "../snapshot-hash";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
const FEATURES_HASH = hashInsightSnapshot({
features: compactSections(FEATURES as unknown as Record<string, unknown>),
aboutMe: null,
comparisonBaseline: "none",
generationLocale: "de",
});
const todayKey = new Date().toISOString().slice(0, 10);
/** A cached payload carrying a real briefing paragraph + sibling findings. */
const CACHED = JSON.stringify({
summary: "stable summary",
dailyBriefing: {
paragraph: "Yesterday's phrasing.",
keyFindings: [{ headline: "BP steady", tone: "good" }],
},
});
beforeEach(() => {
vi.clearAllMocks();
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
resolveProvider.mockResolvedValue({ type: "none" });
extractFeatures.mockResolvedValue(FEATURES);
userUpdate.mockResolvedValue({});
});
describe("daily-briefing re-roll on unchanged data", () => {
it("re-rolls only the paragraph, preserves findings, seedless + 0.6 temp", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: CACHED,
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH,
insightsBriefingRerollDate: null, // never re-rolled today
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify({
dailyBriefing: { paragraph: "Today's fresh phrasing." },
}),
tokensUsed: 50,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "rerolled", providerType: "openai" });
// Seedless + higher temperature.
const params = runRawCompletionWithFallback.mock.calls[0][0].params;
expect(params.temperature).toBe(0.6);
expect(params.seed).toBeUndefined();
// The cache write swaps only the paragraph; findings + summary preserved.
const write = userUpdate.mock.calls.find(
(c) =>
(c[0] as { data: Record<string, unknown> }).data.insightsCachedText !==
undefined,
);
expect(write).toBeTruthy();
const data = (write![0] as { data: Record<string, unknown> }).data;
const stored = JSON.parse(data.insightsCachedText as string);
expect(stored.dailyBriefing.paragraph).toBe("Today's fresh phrasing.");
expect(stored.dailyBriefing.keyFindings).toEqual([
{ headline: "BP steady", tone: "good" },
]);
expect(stored.summary).toBe("stable summary");
expect(data.insightsBriefingRerollDate).toBe(todayKey);
expect(invalidateUserInsights).toHaveBeenCalledWith("u1");
});
it("does NOT re-roll a second time the same calendar day", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: CACHED,
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH,
insightsBriefingRerollDate: todayKey, // already re-rolled today
});
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "unchanged" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("falls back to a plain refresh (still stamps the day) when the re-roll JSON is unusable", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: CACHED,
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH,
insightsBriefingRerollDate: null,
});
runRawCompletionWithFallback.mockResolvedValue({
result: { content: "not json at all", tokensUsed: 5, model: "m" },
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "unchanged" });
// The day is stamped so a broken provider can't be re-driven all day.
const write = userUpdate.mock.calls.find(
(c) =>
(c[0] as { data: Record<string, unknown> }).data
.insightsBriefingRerollDate !== undefined,
);
expect(write).toBeTruthy();
});
});
+171 -30
View File
@@ -133,9 +133,107 @@ export type GenerateOutcome =
* (GET read, nightly discovery) treat the cache as current.
*/
| { status: "unchanged" }
/**
* v1.18.7 — the snapshot was unchanged (findings byte-stable) but the
* daily-briefing PARAGRAPH was re-rolled at a higher, seedless
* temperature for phrasing variety. At most one re-roll per calendar day.
*/
| { status: "rerolled"; providerType: string }
| { status: "skipped"; reason: "no-provider" | "no-consent" }
| { status: "failed"; reason: string };
/** Higher, seedless temperature for the daily-briefing phrasing re-roll. */
const BRIEFING_REROLL_TEMPERATURE = 0.6;
/** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */
function buildRerollDateKey(at: Date = new Date()): string {
return at.toISOString().slice(0, 10);
}
/**
* Re-roll ONLY the daily-briefing paragraph on a hash-unchanged hit.
*
* Runs the same comprehensive prompt at a higher, seedless temperature, then
* keeps the cached payload verbatim and swaps in just the freshly-generated
* `dailyBriefing.paragraph`. So the structured findings stay byte-stable
* (reference assessments, monotony is fine) while the prose the user reads at
* the top of /insights varies day-over-day. Returns null on any miss (no
* provider success, invalid JSON, no cached/new paragraph) so the caller
* falls back to the plain timestamp refresh.
*/
async function rerollBriefingParagraph(args: {
userId: string;
chain: Awaited<ReturnType<typeof resolveProviderChain>>;
systemPrompt: string;
userPrompt: string;
cachedText: string;
locale: "de" | "en";
}): Promise<{ text: string; providerType: string } | null> {
let cached: Record<string, unknown>;
try {
cached = JSON.parse(args.cachedText) as Record<string, unknown>;
} catch {
return null;
}
const cachedBriefing = cached.dailyBriefing;
// Nothing to re-roll when the cached payload carries no briefing paragraph.
if (
cachedBriefing === null ||
typeof cachedBriefing !== "object" ||
typeof (cachedBriefing as { paragraph?: unknown }).paragraph !== "string"
) {
return null;
}
let result;
let providerType: string;
try {
const fallback = await runRawCompletionWithFallback({
userId: args.userId,
providers: args.chain,
params: {
systemPrompt: args.systemPrompt,
userPrompt: args.userPrompt,
// Seedless on purpose: the seed would pin the same phrasing, which is
// exactly what we are varying. Higher temperature for prose variety.
temperature: BRIEFING_REROLL_TEMPERATURE,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
responseFormat: "json",
},
});
result = fallback.result;
providerType = fallback.workingProvider.providerType;
} catch {
return null;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(stripJsonFences(result.content)) as Record<
string,
unknown
>;
} catch {
return null;
}
const freshBriefing = parsed.dailyBriefing;
const freshParagraph =
freshBriefing && typeof freshBriefing === "object"
? (freshBriefing as { paragraph?: unknown }).paragraph
: undefined;
if (typeof freshParagraph !== "string" || freshParagraph.trim().length === 0) {
return null;
}
// Keep the cached payload verbatim; swap in only the fresh paragraph.
const mergedBriefing = {
...(cachedBriefing as Record<string, unknown>),
paragraph: freshParagraph,
};
const merged = { ...cached, dailyBriefing: mergedBriefing };
return { text: JSON.stringify(merged), providerType };
}
/**
* Comparison-snapshot builder — shared with the on-demand route. Returns
* null when the user's comparison toggle is off (most users), so the
@@ -532,6 +630,7 @@ export async function generateComprehensiveInsight(
insightsCachedText: true,
insightsExcludeMetrics: true,
insightsSnapshotHash: true,
insightsBriefingRerollDate: true,
// The comparison-baseline setting joins the snapshot fingerprint
// (see the hash construction below), so it must be readable BEFORE
// the gate — the comparison snapshot itself is only built after.
@@ -643,20 +742,15 @@ export async function generateComprehensiveInsight(
comparisonBaseline,
generationLocale: locale,
});
if (
dbUser?.insightsCachedText &&
dbUser.insightsSnapshotHash === snapshotHash
) {
await prisma.user.update({
where: { id: userId },
data: { insightsCachedAt: new Date() },
});
annotate({
action: { name: "insights.generate.skipped_unchanged" },
meta: { locale },
});
return { status: "unchanged" };
}
// v1.12.7 (B5) — the curated SOURCES block for the metrics present, shared
// by both the full generation below and the unchanged-data re-roll.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
const plateauContext = await detectGlp1Plateau(userId);
@@ -669,25 +763,72 @@ export async function generateComprehensiveInsight(
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
// v1.15.20 — fold the user-authored "about me" self-description
// (Settings → AI) into the nightly briefing exactly like the
// on-demand route. Null (no text / undecryptable) costs nothing.
// (Fetched above the hash gate — it is part of the fingerprint.)
if (aboutMe) {
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
}
const systemPrompt = buildSystemPromptWithReferences(locale, referenceMetrics);
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this generation actually carries, so a normative claim ("target < 140/90")
// can cite a real `referenceId` the schema + UI footnote already support.
// Returns the plain prompt unchanged when no applicable metric is present.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
if (
dbUser?.insightsCachedText &&
dbUser.insightsSnapshotHash === snapshotHash
) {
// v1.18.7 (MEDIUM-3) — findings are byte-stable, but re-roll the daily-
// briefing PARAGRAPH once per calendar day so the prose reads fresh
// day-over-day. Gated on `insightsBriefingRerollDate` so it costs at
// most one extra call/day; the higher, seedless temperature varies the
// phrasing while the structured findings are preserved verbatim from the
// cached payload. Any miss falls through to the plain timestamp refresh.
const todayKey = buildRerollDateKey();
if (dbUser.insightsBriefingRerollDate !== todayKey) {
const rerolled = await rerollBriefingParagraph({
userId,
chain,
systemPrompt,
userPrompt,
cachedText: dbUser.insightsCachedText,
locale,
});
if (rerolled) {
await prisma.user.update({
where: { id: userId },
data: {
insightsCachedAt: new Date(),
insightsCachedText: rerolled.text,
insightsBriefingRerollDate: todayKey,
},
});
invalidateUserInsights(userId);
annotate({
action: { name: "insights.generate.briefing_rerolled" },
meta: { locale, provider: rerolled.providerType },
});
return { status: "rerolled", providerType: rerolled.providerType };
}
// A re-roll miss still stamps the day so a persistently failing
// provider cannot be re-driven on every page-open; the next day retries.
await prisma.user.update({
where: { id: userId },
data: {
insightsCachedAt: new Date(),
insightsBriefingRerollDate: todayKey,
},
});
annotate({
action: { name: "insights.generate.skipped_unchanged" },
meta: { locale, rerollAttempted: true },
});
return { status: "unchanged" };
}
await prisma.user.update({
where: { id: userId },
data: { insightsCachedAt: new Date() },
});
annotate({
action: { name: "insights.generate.skipped_unchanged" },
meta: { locale },
});
return { status: "unchanged" };
}
let result;
let workingProviderType: string;
@@ -696,7 +837,7 @@ export async function generateComprehensiveInsight(
userId,
providers: chain,
params: {
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
systemPrompt,
userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,