mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(demo): stamp the baked briefing so the readers serve it
Every briefing reader serves `dailyBriefing` only when the payload's `briefingGeneratedAt` falls on the reader's today. The demo seed wrote the comprehensive payload without that stamp and without the language tag, so on a fresh seed the advisor read returned no briefing, the dashboard snapshot stayed "preparing", and a warm was enqueued that can only fail against the placeholder key. The /insights card sat empty while the hero said "Updated today". The seed now writes the cache the way a generation does: the payload stamped through `withBriefingGeneratedAt`, the cache stamp from the same instant, and the `en` tag. The baked paragraph drops its "Good morning" opener, since the hero already greets by the hour. The hero's freshness caption dates the subtitle, so it now shows only when the subtitle is a briefing. The cache stamp also moves when a warm keeps yesterday's text, which the read then withholds; the caption then sat under the generic fallback sentence. `seed()` is exported behind a main-module guard, and an integration test runs the real seed and reads its output back through the advisor read, the dashboard snapshot, the status cards and the period narratives.
This commit is contained in:
1 parent
59a6ee9d05
commit
2b9315ad8b
4 files changed
+224
-19
No files matched your search
+35
-10
@@ -78,6 +78,8 @@
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import pg from "pg";
|
||||
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
|
||||
@@ -88,6 +90,7 @@ import {
|
||||
getKeyFingerprint,
|
||||
} from "../src/lib/crypto";
|
||||
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
|
||||
import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at";
|
||||
import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
|
||||
import {
|
||||
DEFAULT_CHART_OVERLAY_PREFS,
|
||||
@@ -416,7 +419,11 @@ function daylightHours(lat: number, doy: number): number {
|
||||
|
||||
// ── Main ─────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
/**
|
||||
* Seed the demo tenant. Exported so an integration test can run the real
|
||||
* seed and read its output back through the app's own readers.
|
||||
*/
|
||||
export async function seed() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
@@ -3369,11 +3376,15 @@ async function seed() {
|
||||
const bakedProvider = "anthropic";
|
||||
|
||||
// ── Comprehensive insight + daily briefing ──
|
||||
// Stored sealed on users.insights_cached_text_encrypted (JSON). The GET
|
||||
// read opens and parses it and
|
||||
// returns it as-is; setting insights_cached_at = NOW() keeps it inside the
|
||||
// 24 h freshness window for the dashboard-snapshot briefing path. The shape
|
||||
// is the union of insightResultSchema (summary / findings / correlations /
|
||||
// Stored sealed on users.insights_cached_text_encrypted (JSON), written
|
||||
// the way a generation writes it (`briefing-request/commit.ts`): the
|
||||
// payload stamped with its generation moment, the cache stamp, and the
|
||||
// language tag. Every briefing reader serves `dailyBriefing` only when
|
||||
// that `briefingGeneratedAt` falls on the reader's today, so a payload
|
||||
// without it reads as text of unknown age: the advisor card and the
|
||||
// dashboard headline stay empty and a warm is asked for, which on the
|
||||
// demo can only fail against the placeholder key. The shape is the union
|
||||
// of insightResultSchema (summary / findings / correlations /
|
||||
// recommendations / dataQuality / disclaimer / classification) and the
|
||||
// richer optional blocks the dashboard validates (dailyBriefing with
|
||||
// paragraph + signalsOfDay + keyFindings, trendAnnotations).
|
||||
@@ -3461,8 +3472,10 @@ async function seed() {
|
||||
"This is a reasoned observation of your own data, not medical advice " +
|
||||
"or a diagnosis. Discuss any concerns with your doctor.",
|
||||
dailyBriefing: {
|
||||
// No salutation: the hero above it already greets by the hour, and
|
||||
// a demo filmed in the evening read "Good evening" over "Good morning".
|
||||
paragraph:
|
||||
"Good morning. Today's picture is a calm one: your most recent " +
|
||||
"Today's picture is a calm one: your most recent " +
|
||||
"blood pressure sits comfortably in the optimal band, resting heart " +
|
||||
"rate is low, and last night's sleep landed right around your " +
|
||||
"target. Weight is holding at its new steady place and your mood " +
|
||||
@@ -3552,10 +3565,12 @@ async function seed() {
|
||||
},
|
||||
};
|
||||
|
||||
const briefingWrittenAt = new Date();
|
||||
await client.query(
|
||||
`UPDATE users
|
||||
SET insights_cached_text_encrypted = $2,
|
||||
insights_cached_at = NOW(),
|
||||
insights_cached_at = $5,
|
||||
insights_cached_locale = 'en',
|
||||
insights_snapshot_hash = $3,
|
||||
insights_briefing_reroll_date = $4
|
||||
WHERE id = $1`,
|
||||
@@ -3563,7 +3578,11 @@ async function seed() {
|
||||
userId,
|
||||
// Sealed like every write since v1.43.7.
|
||||
Buffer.from(
|
||||
sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
|
||||
sealInsightsCachedText(
|
||||
JSON.stringify(
|
||||
withBriefingGeneratedAt(comprehensiveInsight, briefingWrittenAt),
|
||||
),
|
||||
),
|
||||
),
|
||||
// A stable fingerprint. The 24 h cache window keeps the row fresh on
|
||||
// its own; this value just has to be present + non-null so a
|
||||
@@ -3573,6 +3592,7 @@ async function seed() {
|
||||
// Mark today's briefing re-roll as already done so the once-per-day
|
||||
// re-roll path is a no-op (it would fail against the placeholder key).
|
||||
todayKey,
|
||||
briefingWrittenAt,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3841,4 +3861,9 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
seed();
|
||||
}
|
||||
@@ -302,7 +302,7 @@ describe("<HeroStrip>", () => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
briefing={sampleBriefing}
|
||||
updatedAt={fiveMinutesAgo}
|
||||
now={morningLocal}
|
||||
/>,
|
||||
@@ -314,6 +314,32 @@ describe("<HeroStrip>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The caption dates the subtitle. The read withholds a briefing written on
|
||||
* an earlier day while the cache stamp still reads today (a warm that kept
|
||||
* the old text re-stamps the row), so with no briefing the hero showed
|
||||
* "Updated today" under the generic fallback sentence and the card below
|
||||
* it said there was no briefing.
|
||||
*/
|
||||
it("withholds the freshness caption when there is no briefing", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(morningLocal);
|
||||
try {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
updatedAt={fiveMinutesAgo}
|
||||
now={morningLocal}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-generated"');
|
||||
expect(html).not.toContain("Updated today");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT render the generated caption when updatedAt is missing", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-generated"');
|
||||
|
||||
@@ -135,9 +135,14 @@ export function HeroStrip({
|
||||
// "yesterday" / "on DD.MM."), matching the briefing + per-metric cards so the
|
||||
// hero no longer reads relative while the cards below it read absolute. The
|
||||
// day boundary follows the user's profile timezone.
|
||||
const generatedLine = updatedAt
|
||||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
|
||||
: null;
|
||||
// The caption dates the SUBTITLE, so it shows only when the subtitle is a
|
||||
// briefing. The cache stamp moves on every warm, including one that keeps
|
||||
// yesterday's text, which the read then withholds: without a briefing it
|
||||
// put "Updated today" under the generic fallback sentence.
|
||||
const generatedLine =
|
||||
briefing && updatedAt
|
||||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -201,11 +206,9 @@ export function HeroStrip({
|
||||
)}
|
||||
{generatedLine && (
|
||||
<>
|
||||
{briefing && (
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span data-slot="insights-hero-strip-generated">
|
||||
{generatedLine}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The demo seed bakes model-written text into the same rows a generation
|
||||
* writes, because the public demo has no live provider. That only works if
|
||||
* the seed writes exactly what the readers expect: a payload without its
|
||||
* `briefingGeneratedAt` stamp reads as text of unknown age, and every
|
||||
* briefing surface then serves nothing while a "today" timestamp sits on
|
||||
* the page.
|
||||
*
|
||||
* This runs the real `scripts/seed-demo.ts` against the test database and
|
||||
* reads its output back through the app's own readers: the advisor briefing
|
||||
* behind the `/insights` card and hero, the dashboard snapshot headline, the
|
||||
* per-metric status cards and the period narratives.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// A warm on the demo can only fail against the seeded placeholder key, so a
|
||||
// fresh seed must not ask for one.
|
||||
const enqueueForceWarm = vi.fn();
|
||||
vi.mock("@/lib/jobs/insight-pregenerate-shared", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@/lib/jobs/insight-pregenerate-shared")
|
||||
>()),
|
||||
enqueueForceWarm: (...args: unknown[]) => enqueueForceWarm(...args),
|
||||
}));
|
||||
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
const { seed } = await import("../../scripts/seed-demo");
|
||||
await seed();
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { username: "demo" },
|
||||
select: { id: true },
|
||||
});
|
||||
userId = user.id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("demo seed, baked AI texts read back through the app", () => {
|
||||
it("serves today's briefing on the advisor read, with no warm asked for", async () => {
|
||||
const { readAdvisorBriefing } =
|
||||
await import("@/lib/insights/briefing-request/read");
|
||||
|
||||
const read = await readAdvisorBriefing({
|
||||
request: new NextRequest("http://localhost/api/insights/generate"),
|
||||
userId,
|
||||
userLocale: "en",
|
||||
});
|
||||
|
||||
const briefing = (read.insights as Record<string, unknown> | null)
|
||||
?.dailyBriefing as {
|
||||
paragraph: string;
|
||||
keyFindings: unknown[];
|
||||
signalsOfDay?: Array<{ sourceMetric: string }>;
|
||||
} | null;
|
||||
expect(briefing).not.toBeNull();
|
||||
expect(briefing?.paragraph).toMatch(/^Today's picture is a calm one/);
|
||||
expect(briefing?.keyFindings).toHaveLength(4);
|
||||
// Sleep rests on no single dated reading, so it is kept whatever the
|
||||
// hour; the reading-backed signals follow today's seeded readings.
|
||||
expect(briefing?.signalsOfDay?.map((s) => s.sourceMetric)).toContain(
|
||||
"sleep",
|
||||
);
|
||||
expect(read.revalidating).toBe(false);
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves the same briefing as the dashboard headline", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const { readDashboardSnapshotCached } =
|
||||
await import("@/lib/dashboard/snapshot-read");
|
||||
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
|
||||
expect(locale).toBe("en");
|
||||
expect(body.briefingState).toBe("ready");
|
||||
expect(body.briefing?.paragraph).toMatch(/^Today's picture is a calm one/);
|
||||
});
|
||||
|
||||
it("serves every baked status card as today's note", async () => {
|
||||
const { readFreshStatusText, statusCacheAction } =
|
||||
await import("@/lib/insights/status-cache");
|
||||
const { userDayKey } = await import("@/lib/tz/format");
|
||||
const todayKey = userDayKey(new Date(), "Europe/Berlin");
|
||||
|
||||
for (const scope of [
|
||||
"blood-pressure",
|
||||
"pulse",
|
||||
"weight",
|
||||
"bmi",
|
||||
"mood",
|
||||
"medication-compliance",
|
||||
"general",
|
||||
]) {
|
||||
const hit = await readFreshStatusText({
|
||||
userId,
|
||||
cacheAction: statusCacheAction(scope, "en"),
|
||||
todayKey,
|
||||
force: false,
|
||||
});
|
||||
expect(hit?.text, scope).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("serves both baked period narratives", async () => {
|
||||
const { readPeriodNarrative } =
|
||||
await import("@/lib/insights/narrative/period-narrative-generate");
|
||||
|
||||
for (const period of ["week", "month"] as const) {
|
||||
const read = await readPeriodNarrative(userId, period, "en");
|
||||
expect(read?.text, period).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user