From 853a1cd301d72484f4ec6be14be9a2f35eb17b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 21:05:19 +0200 Subject: [PATCH] feat(insights): vary daily-briefing prose on unchanged data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../migration.sql | 5 + prisma/schema.prisma | 7 + .../comprehensive-briefing-reroll.test.ts | 182 ++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 201 +++++++++++++++--- 4 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql create mode 100644 src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts diff --git a/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql b/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql new file mode 100644 index 000000000..08148180a --- /dev/null +++ b/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c2f620a3c..75745cf83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts new file mode 100644 index 000000000..1357a76d2 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -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), + 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 }).data.insightsCachedText !== + undefined, + ); + expect(write).toBeTruthy(); + const data = (write![0] as { data: Record }).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 }).data + .insightsBriefingRerollDate !== undefined, + ); + expect(write).toBeTruthy(); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index fab7310de..97c1b2ea9 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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>; + systemPrompt: string; + userPrompt: string; + cachedText: string; + locale: "de" | "en"; +}): Promise<{ text: string; providerType: string } | null> { + let cached: Record; + try { + cached = JSON.parse(args.cachedText) as Record; + } 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; + 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), + 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,