mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user