mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'wt/l5-narrative' into release/v1.30.15
This commit is contained in:
commit
f8c4032515
16 files changed
+451
-45
No files matched your search
@@ -5226,7 +5226,8 @@ model InsightNarrative {
|
||||
userId String @map("user_id")
|
||||
/// "week" | "month".
|
||||
period String
|
||||
/// Resolved UI locale of the prose ("de" | "en").
|
||||
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
|
||||
/// the prose is generated per locale, so the row is keyed by it.
|
||||
locale String
|
||||
/// UTC YYYY-MM-DD boundary the narrative was generated for.
|
||||
dateKey String @map("date_key")
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
listConversations,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
|
||||
import { instructionLocale } from "@/lib/ai/prompts/output-language";
|
||||
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -485,9 +486,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
void enqueueCoachMemoryRefresh({
|
||||
conversationId: workingConversationId,
|
||||
userId,
|
||||
// Coach memory prose is composed in de/en only (the snapshot's
|
||||
// coachLocale); collapse the wider UI locale union here.
|
||||
locale: locale === "en" ? "en" : "de",
|
||||
// Coach memory prose is composed in de/en only — it is MODEL-FACING
|
||||
// context (a rolling conversation summary + extracted durable facts),
|
||||
// not user-facing prose, so English is the correct target for every
|
||||
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
|
||||
// binary composed and keyed a French account's memory in German.
|
||||
locale: instructionLocale(locale),
|
||||
});
|
||||
}
|
||||
// v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
|
||||
@@ -37,8 +38,17 @@ const narrativeQuerySchema = z.object({
|
||||
/** A narrative read this recently is considered fresh; no warm is enqueued. */
|
||||
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
function narrowLocale(locale: string): "de" | "en" {
|
||||
return locale === "en" ? "en" : "de";
|
||||
/**
|
||||
* The narrative row is keyed `(user, period, locale)` across the full UI
|
||||
* locale union, so the resolved locale is carried through as-is.
|
||||
*
|
||||
* This used to be `locale === "en" ? "en" : "de"` — which sent every French,
|
||||
* Spanish, Italian and Polish reader to the German narrative row and the
|
||||
* German prompt behind it. `resolveServerLocale` already returns a validated
|
||||
* `Locale`; an unknown value falls back to the app default (`en`) there.
|
||||
*/
|
||||
function narrowLocale(locale: Locale): Locale {
|
||||
return locales.includes(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Regression-class ban for the German-default locale binary.
|
||||
*
|
||||
* The bug this pins had exactly one shape: `locale === "en" ? "en" : "de"`
|
||||
* (and its `=== "de" ? ... : ...` inversions with a `de` fallback). Every
|
||||
* occurrence sent French, Spanish, Italian and Polish accounts down the German
|
||||
* branch. This walks the source of the surfaces that carried it and fails on
|
||||
* the shape itself, so a future edit cannot quietly reintroduce it.
|
||||
*
|
||||
* Scoped to the files this change owns — the coach memory refresh, the coach
|
||||
* snapshot's narrative recall, the narrative route + generator + warm, and the
|
||||
* dashboard briefing recall. Blunt by design: it matches text, not behaviour,
|
||||
* which is precisely what makes it survive a refactor of the logic around it.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ROOT = resolve(__dirname, "../../../..");
|
||||
|
||||
const GUARDED_FILES = [
|
||||
"app/api/insights/chat/route.ts",
|
||||
"app/api/insights/narrative/route.ts",
|
||||
"lib/ai/coach/snapshot.ts",
|
||||
"lib/ai/coach/memory-snapshot.ts",
|
||||
"lib/ai/coach/coach-memory-refresh-worker.ts",
|
||||
"lib/dashboard/snapshot.ts",
|
||||
"lib/insights/narrative/period-narrative-generate.ts",
|
||||
"lib/jobs/period-narrative-warm.ts",
|
||||
"lib/jobs/period-narrative-shared.ts",
|
||||
];
|
||||
|
||||
/**
|
||||
* A ternary whose FALSE branch is the German literal — the de-default shape.
|
||||
* Deliberately narrow: it must not flag `locale === "de" ? deBody : enBody`,
|
||||
* which is the correct polarity (German only for German readers).
|
||||
*/
|
||||
const DE_DEFAULT_TERNARY = /\?\s*"[a-z]{2}"\s*:\s*"de"/;
|
||||
|
||||
/** `payload.locale ?? "de"` and friends — a German default by omission. */
|
||||
const DE_DEFAULT_NULLISH = /\?\?\s*"de"/;
|
||||
|
||||
/**
|
||||
* Read a guarded file with comments stripped.
|
||||
*
|
||||
* The comments at these call sites deliberately QUOTE the banned shape to
|
||||
* explain what was fixed; scanning them would make the guard self-tripping.
|
||||
* Only executable source is checked.
|
||||
*/
|
||||
function readCode(rel: string): string {
|
||||
return readFileSync(resolve(ROOT, rel), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("no German-default locale binary", () => {
|
||||
it.each(GUARDED_FILES)("%s carries no de-default ternary", (rel) => {
|
||||
expect(readCode(rel)).not.toMatch(DE_DEFAULT_TERNARY);
|
||||
});
|
||||
|
||||
it.each(GUARDED_FILES)("%s carries no de-default fallback", (rel) => {
|
||||
expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH);
|
||||
});
|
||||
|
||||
it("recognises the shape it bans", () => {
|
||||
// Guard the guard: the patterns must actually match the bug they describe,
|
||||
// so a typo cannot turn this file into a silent pass.
|
||||
expect(`locale === "en" ? "en" : "de"`).toMatch(DE_DEFAULT_TERNARY);
|
||||
expect(`payload.locale ?? "de"`).toMatch(DE_DEFAULT_NULLISH);
|
||||
// ...and must NOT match the correct polarity.
|
||||
expect(`instructionLocale(locale)`).not.toMatch(DE_DEFAULT_TERNARY);
|
||||
expect(`locale === "de" ? DE_BODY : EN_BODY`).not.toMatch(
|
||||
DE_DEFAULT_TERNARY,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -168,4 +168,19 @@ describe("buildCoachMemoryBlock", () => {
|
||||
await buildCoachMemoryBlock(USER, PROFILE, NOW, "en");
|
||||
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en");
|
||||
});
|
||||
|
||||
// The narrative row is keyed by the full UI locale union. A French account's
|
||||
// recall must read the `fr` row — narrowing it to `de` (the old behaviour)
|
||||
// recalled a German narrative, and narrowing it to `en` would read a row
|
||||
// that account never has.
|
||||
it.each(["fr", "es", "it", "pl"] as const)(
|
||||
"reads the %s narrative row without narrowing the locale",
|
||||
async (locale) => {
|
||||
readPeriodNarrative.mockResolvedValue(null);
|
||||
buildPeriodNarrativeContext.mockResolvedValue(readyContext([]));
|
||||
|
||||
await buildCoachMemoryBlock(USER, PROFILE, NOW, locale);
|
||||
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", locale);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -19,7 +19,10 @@ export async function runCoachMemoryRefresh(
|
||||
payload: CoachMemoryRefreshPayload,
|
||||
): Promise<void> {
|
||||
const { conversationId, userId } = payload;
|
||||
const locale = payload.locale ?? "de";
|
||||
// A payload without a locale (an older queued job) composes ENGLISH memory,
|
||||
// not German — the memory prose is model-facing context and English is the
|
||||
// fallback body for every locale that has no reviewed one.
|
||||
const locale = payload.locale ?? "en";
|
||||
|
||||
let summaryStatus = "error";
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const COACH_MEMORY_REFRESH_QUEUE = "coach-memory-refresh";
|
||||
export interface CoachMemoryRefreshPayload {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
/** Locale to compose the summary / facts prose in; defaults to "de". */
|
||||
/** Locale to compose the summary / facts prose in; defaults to "en". */
|
||||
locale?: "de" | "en";
|
||||
}
|
||||
|
||||
|
||||
@@ -558,7 +558,10 @@ function deterministicFactText(
|
||||
subject: string,
|
||||
locale: string | undefined,
|
||||
): string {
|
||||
const de = locale?.toLowerCase().startsWith("de") ?? true;
|
||||
// German only for a German reader; every other locale — and an absent one —
|
||||
// composes ENGLISH. The fact text is model-facing memory, so English is the
|
||||
// fallback body; the previous `?? true` made an unknown locale German.
|
||||
const de = locale?.toLowerCase().startsWith("de") ?? false;
|
||||
switch (kind) {
|
||||
case "allergy":
|
||||
return de
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type BandTransition,
|
||||
} from "@/lib/insights/narrative/period-narrative";
|
||||
import type { BaselineProfile } from "@/lib/insights/derived";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { buildCoachFactsBlock } from "./facts";
|
||||
import {
|
||||
buildCoachPlansBlock,
|
||||
@@ -138,7 +139,12 @@ export async function buildCoachMemoryBlock(
|
||||
userId: string,
|
||||
_profile: BaselineProfile,
|
||||
now: Date,
|
||||
locale: "de" | "en",
|
||||
/**
|
||||
* The reader's UI locale — the narrative row is keyed by it, across the full
|
||||
* six-locale union. Narrowing it here would read a row the narrative
|
||||
* pipeline never writes for that user.
|
||||
*/
|
||||
locale: Locale,
|
||||
): Promise<CoachMemoryBlock | null> {
|
||||
let priorNarrative: PriorNarrativeRecall | undefined;
|
||||
// Sub-source 1: the latest period-narrative headline + driver recall.
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CoachDataCluster,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
@@ -339,9 +340,13 @@ async function buildCoachSnapshotImpl(
|
||||
);
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
// Resolve the UI locale for the rolling-profile narrative recall. The
|
||||
// narrative rows are keyed by ("de" | "en"); default to "de" (the app
|
||||
// default locale) when the user never picked one.
|
||||
const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de";
|
||||
// narrative rows are keyed by the full UI locale union, so the stored value
|
||||
// is carried through as-is; an unset or unknown value falls back to the app
|
||||
// default (`en`), never to German. The former `=== "en" ? "en" : "de"`
|
||||
// binary made a French account recall a German narrative row.
|
||||
const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale)
|
||||
? (prefsRow?.locale as Locale)
|
||||
: defaultLocale;
|
||||
const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
|
||||
const { sources: scopedSources, window } = resolveScope(
|
||||
scope,
|
||||
|
||||
@@ -1046,10 +1046,12 @@ function localisedMeasurementLabel(
|
||||
return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
/** Narrow a 6-locale `Locale` to the de/en the narrative pipeline generates. */
|
||||
function narrativeLocale(locale: Locale): "de" | "en" {
|
||||
return locale === "de" ? "de" : "en";
|
||||
}
|
||||
/**
|
||||
* The narrative row is keyed by the full six-locale union — the pipeline
|
||||
* generates and stores a row per UI locale — so the briefing memory recall
|
||||
* reads the caller's locale directly. This used to narrow to de/en, which
|
||||
* would now read a row a fr/es/it/pl account never has.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Assemble the full snapshot in ONE `Promise.all`. Every sub-read is
|
||||
@@ -1079,8 +1081,8 @@ export async function buildDashboardSnapshot(
|
||||
/**
|
||||
* v1.21.2 — active locale for the A4 briefing memory prose. Defaults to
|
||||
* English; the route resolves it from the request (cookie / header / user
|
||||
* preference). The recall headline is read locale-specific; non-de locales
|
||||
* fall back to the English narrative read inside `buildCoachMemoryBlock`.
|
||||
* preference). The recall headline is read locale-specific: the narrative
|
||||
* row `buildCoachMemoryBlock` reads is keyed by this exact locale.
|
||||
*/
|
||||
locale?: Locale;
|
||||
/**
|
||||
@@ -1269,7 +1271,7 @@ export async function buildDashboardSnapshot(
|
||||
user.id,
|
||||
narrativeProfile,
|
||||
now,
|
||||
narrativeLocale(locale),
|
||||
locale,
|
||||
),
|
||||
).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("buildNarrativeUserPrompt", () => {
|
||||
expect(prompt).toContain("WEIGHT");
|
||||
expect(prompt).toContain("ACTIVITY_STEPS ~ SLEEP_DURATION");
|
||||
expect(prompt).toContain("12 pairs tested");
|
||||
expect(NARRATIVE_PROMPT_VERSION).toBe("1.13.0");
|
||||
expect(NARRATIVE_PROMPT_VERSION).toBe("1.14.0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Output-language contract for the PERIOD NARRATIVE surface.
|
||||
*
|
||||
* The bug this pins: the read route narrowed every non-English locale to `de`,
|
||||
* so a French, Spanish, Italian or Polish reader received a German
|
||||
* retrospective composed from the German instruction body. The fix keeps the
|
||||
* two reviewed bodies (de, en) and lets the other four ride the English body
|
||||
* with their language named and their OWN reply-language directive appended.
|
||||
*
|
||||
* Three things are asserted here, in ascending strength:
|
||||
* 1. the four locales name their language and carry the verbatim directive
|
||||
* from the translator-facing `safety-contracts.<locale>.yaml`;
|
||||
* 2. no non-German prompt contains a German-body sentinel — the "nothing
|
||||
* silently falls back to German" assertion this whole change exists for;
|
||||
* 3. the German and English prompts are byte-identical to their pre-change
|
||||
* form, modulo the prompt-version string, pinned by content hash.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
buildNarrativeSystemPrompt,
|
||||
generatePeriodNarrative,
|
||||
NARRATIVE_PROMPT_VERSION,
|
||||
} from "@/lib/insights/narrative/period-narrative-generate";
|
||||
import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative";
|
||||
|
||||
/** The four locales that ride the English body. */
|
||||
const RIDING_LOCALES: Locale[] = ["fr", "es", "it", "pl"];
|
||||
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
fr: "French",
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sentinels that only ever appear in the GERMAN instruction body. A hit on a
|
||||
* non-German prompt means that locale fell back to German — the exact bug.
|
||||
*/
|
||||
const GERMAN_SENTINELS = [
|
||||
"AUSGABEFORMAT",
|
||||
"Antworte",
|
||||
"auf Deutsch",
|
||||
"Feste Regeln",
|
||||
"Prompt-Version",
|
||||
];
|
||||
|
||||
/**
|
||||
* sha256 of the composed prompt with the version string normalised away.
|
||||
*
|
||||
* The version is deliberately excluded: bumping `NARRATIVE_PROMPT_VERSION` is
|
||||
* a required part of any prompt change and is the ONE byte the de/en prompts
|
||||
* are allowed to move by. Everything else is frozen.
|
||||
*/
|
||||
function promptHash(locale: "de" | "en"): string {
|
||||
const normalised = buildNarrativeSystemPrompt(locale)
|
||||
.split(NARRATIVE_PROMPT_VERSION)
|
||||
.join("VERSION");
|
||||
return createHash("sha256").update(normalised, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes captured from the prompts as they stood at NARRATIVE_PROMPT_VERSION
|
||||
* 1.13.0, before the output-language change. If either moves, the de or en
|
||||
* prompt drifted — which this change promised would not happen.
|
||||
*/
|
||||
const FROZEN_PROMPT_HASHES: Record<"de" | "en", string> = {
|
||||
de: "be667f16beeb44f51a81fedd929716b2249579fe914b45bb8cba0cd9e64a8c6d",
|
||||
en: "bb0b080e21a2b9729bdf0047ae221c326ec29ecd74d0999f19ea8deabcbf078a",
|
||||
};
|
||||
|
||||
describe("period-narrative output language", () => {
|
||||
it("keeps the German and English prompts byte-identical modulo the version", () => {
|
||||
expect(promptHash("de")).toBe(FROZEN_PROMPT_HASHES.de);
|
||||
expect(promptHash("en")).toBe(FROZEN_PROMPT_HASHES.en);
|
||||
});
|
||||
|
||||
it("appends no directive to the two reviewed bodies", () => {
|
||||
expect(buildNarrativeSystemPrompt("de")).not.toContain("OUTPUT LANGUAGE:");
|
||||
expect(buildNarrativeSystemPrompt("en")).not.toContain("OUTPUT LANGUAGE:");
|
||||
// The English body must not grow a language rule for its own locale.
|
||||
expect(buildNarrativeSystemPrompt("en")).not.toContain(
|
||||
"Write the summary in English.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(RIDING_LOCALES)(
|
||||
"names the language and carries the verbatim directive for %s",
|
||||
(locale) => {
|
||||
const prompt = buildNarrativeSystemPrompt(locale);
|
||||
const directive = loadSafetyContracts(locale).reply_language_directive;
|
||||
|
||||
expect(prompt).toContain(
|
||||
`Write the summary in ${LANGUAGE_NAMES[locale]}.`,
|
||||
);
|
||||
expect(prompt).toContain(`OUTPUT LANGUAGE: ${directive}`);
|
||||
// The directive is the LAST instruction the model reads.
|
||||
expect(prompt.trimEnd().endsWith(directive.trimEnd())).toBe(true);
|
||||
// The scaffolding is the English body, not a translation of it.
|
||||
expect(prompt).toContain("Hard rules:");
|
||||
},
|
||||
);
|
||||
|
||||
it("specifically composes the French prompt in English with the French directive", () => {
|
||||
const prompt = buildNarrativeSystemPrompt("fr");
|
||||
expect(prompt).toContain("Write the summary in French.");
|
||||
expect(prompt).toContain(
|
||||
loadSafetyContracts("fr").reply_language_directive,
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"You summarise one person's health-tracking PERIOD",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(locales.filter((l) => l !== "de"))(
|
||||
"leaks no German instruction body into the %s prompt",
|
||||
(locale) => {
|
||||
const prompt = buildNarrativeSystemPrompt(locale);
|
||||
for (const sentinel of GERMAN_SENTINELS) {
|
||||
expect(prompt).not.toContain(sentinel);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the German prompt German", () => {
|
||||
const prompt = buildNarrativeSystemPrompt("de");
|
||||
expect(prompt).toContain("Feste Regeln:");
|
||||
expect(prompt).toContain("Prompt-Version:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("period-narrative locale storage", () => {
|
||||
function readyContext(): PeriodNarrativeContext {
|
||||
return {
|
||||
status: "ready",
|
||||
period: "week",
|
||||
metricDeltas: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
current: 80,
|
||||
prior: 81,
|
||||
delta: -1,
|
||||
deltaPercent: -1.2,
|
||||
currentDays: 6,
|
||||
priorDays: 7,
|
||||
},
|
||||
],
|
||||
bandTransitions: [],
|
||||
drivers: [],
|
||||
coincidentFlags: [],
|
||||
pairsTested: 12,
|
||||
fdrQ: 0.1,
|
||||
provenance: {
|
||||
metrics: ["WEIGHT"],
|
||||
window: {
|
||||
from: "2026-05-01T00:00:00.000Z",
|
||||
to: "2026-05-15T00:00:00.000Z",
|
||||
},
|
||||
computedAt: "2026-05-15T05:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("keys the row by the reader's own locale and prompts in their language", async () => {
|
||||
const upsert = vi.fn(async (args: { create: { locale: string } }) => args);
|
||||
const prisma = {
|
||||
user: { findUnique: vi.fn(async () => ({ timezone: "Europe/Berlin" })) },
|
||||
insightNarrative: { findUnique: vi.fn(async () => null), upsert },
|
||||
};
|
||||
let capturedSystemPrompt = "";
|
||||
const runCompletion = vi.fn(async (args: { systemPrompt: string }) => {
|
||||
capturedSystemPrompt = args.systemPrompt;
|
||||
return {
|
||||
kind: "ok" as const,
|
||||
content: "Votre semaine.",
|
||||
providerType: "mock",
|
||||
};
|
||||
});
|
||||
|
||||
const outcome = await generatePeriodNarrative("user-1", {
|
||||
period: "week",
|
||||
locale: "fr",
|
||||
force: true,
|
||||
buildContext: vi.fn(async () => readyContext()) as never,
|
||||
runCompletion: runCompletion as never,
|
||||
prisma: prisma as never,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ status: "generated", providerType: "mock" });
|
||||
// The row is stored under `fr` — NOT collapsed into the de or en row, so a
|
||||
// French retrospective is never served to a German or English reader.
|
||||
expect(upsert.mock.calls[0]?.[0]).toMatchObject({
|
||||
create: { locale: "fr" },
|
||||
where: { userId_period_locale: { locale: "fr" } },
|
||||
});
|
||||
expect(capturedSystemPrompt).toContain("Write the summary in French.");
|
||||
expect(capturedSystemPrompt).not.toContain("AUSGABEFORMAT");
|
||||
// The provider cache action is locale-scoped too.
|
||||
expect(runCompletion.mock.calls[0]?.[0]).toMatchObject({
|
||||
cacheAction: "insights.narrative.week.fr",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,12 @@ import {
|
||||
DETERMINISTIC_PROVIDER_TYPE,
|
||||
} from "@/lib/insights/narrative/period-narrative-deterministic";
|
||||
import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts";
|
||||
import {
|
||||
instructionLocale,
|
||||
targetLanguageName,
|
||||
withOutputLanguage,
|
||||
} from "@/lib/ai/prompts/output-language";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
validateNarrativeText,
|
||||
buildNarrativeCorrection,
|
||||
@@ -51,8 +57,15 @@ import {
|
||||
* Stable identifier for the narrative prompt revision. Bumped whenever the
|
||||
* prompt below changes so the cross-feature attribution aggregator can slice
|
||||
* quality per (provider × prompt) and a prompt change is observable.
|
||||
*
|
||||
* 1.14.0 — output language: French, Spanish, Italian and Polish readers used
|
||||
* to fall to the German instruction body (the route narrowed every non-`en`
|
||||
* locale to `de`), so they received German retrospectives. They now compose
|
||||
* the English body with their language named in an added rule and the
|
||||
* locale's own reply-language directive appended last. The German and English
|
||||
* prompts are unchanged apart from this version string (test-pinned).
|
||||
*/
|
||||
export const NARRATIVE_PROMPT_VERSION = "1.13.0" as const;
|
||||
export const NARRATIVE_PROMPT_VERSION = "1.14.0" as const;
|
||||
|
||||
/** A narrative cached this recently is served without regenerating. */
|
||||
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
|
||||
@@ -67,8 +80,14 @@ export type NarrativeGenerateOutcome =
|
||||
interface GenerateOptions {
|
||||
/** Which period to narrate. */
|
||||
period: NarrativePeriod;
|
||||
/** Resolved UI locale for the prompt + row. */
|
||||
locale: "de" | "en";
|
||||
/**
|
||||
* Resolved UI locale for the prompt + row.
|
||||
*
|
||||
* The full six-locale union: the row is keyed `(user, period, locale)`, so a
|
||||
* French reader's French retrospective is stored under `fr` rather than
|
||||
* mislabelled as German or English prose.
|
||||
*/
|
||||
locale: Locale;
|
||||
/** Skip the freshness short-circuit and force a fresh generation. */
|
||||
force?: boolean;
|
||||
/** Injected clock for deterministic tests; defaults to now. */
|
||||
@@ -105,7 +124,24 @@ function dateKeyFor(now: Date, tz: string): string {
|
||||
|
||||
// ── prompt ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_PROMPT_EN = `You summarise one person's health-tracking PERIOD (a week or a month) for that person.
|
||||
/**
|
||||
* Placeholder for the extra output-language rule inside the English body.
|
||||
*
|
||||
* Empty for `en` (whose body is written in the language it asks for), so the
|
||||
* composed English prompt stays byte-stable; for the four locales that ride
|
||||
* the English body it renders one more hard rule naming the reader's
|
||||
* language. The German body carries no token — it names its language by being
|
||||
* written in it.
|
||||
*/
|
||||
const LANGUAGE_RULE_TOKEN = "{{LANGUAGE_RULE}}";
|
||||
|
||||
function languageRule(locale: Locale): string {
|
||||
return locale === "de" || locale === "en"
|
||||
? ""
|
||||
: `\n- Write the summary in ${targetLanguageName(locale)}.`;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT_EN_TEMPLATE = `You summarise one person's health-tracking PERIOD (a week or a month) for that person.
|
||||
Prompt version: ${NARRATIVE_PROMPT_VERSION}.
|
||||
|
||||
Hard rules:
|
||||
@@ -115,7 +151,7 @@ Hard rules:
|
||||
- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). When the period earned it, name one genuine win plainly; keep it warm and second-person, never a reflexive compliment.
|
||||
- SHAPE: lead with the period's one-line verdict (the overall read in plain words), then connect the top movers and surviving associations into a short arc, and close on continuity or one watch-item — synthesize, do not list each metric in turn.
|
||||
- 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis.
|
||||
- If the context is thin, say plainly that there is little to report this period rather than inventing detail.
|
||||
- If the context is thin, say plainly that there is little to report this period rather than inventing detail.${LANGUAGE_RULE_TOKEN}
|
||||
|
||||
${composeSharedContracts("en", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
|
||||
|
||||
@@ -133,14 +169,33 @@ Feste Regeln:
|
||||
|
||||
${composeSharedContracts("de", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
|
||||
|
||||
/**
|
||||
* The system prompt for one reader.
|
||||
*
|
||||
* German readers compose the reviewed German body; every other locale
|
||||
* composes the English body and is told, in its own language, which language
|
||||
* to write in. The former `locale === "de" ? DE : EN` binary was fed by a
|
||||
* route that narrowed every non-`en` locale to `de`, so French, Spanish,
|
||||
* Italian and Polish readers received German retrospectives.
|
||||
*/
|
||||
export function buildNarrativeSystemPrompt(locale: Locale): string {
|
||||
const body =
|
||||
instructionLocale(locale) === "de"
|
||||
? SYSTEM_PROMPT_DE
|
||||
: SYSTEM_PROMPT_EN_TEMPLATE.split(LANGUAGE_RULE_TOKEN).join(
|
||||
languageRule(locale),
|
||||
);
|
||||
return withOutputLanguage(body, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only view of the composed system prompts (incl. the appended shared
|
||||
* contracts), so the cross-surface coverage test can assert fragment presence
|
||||
* without re-deriving the composition.
|
||||
*/
|
||||
export const SYSTEM_PROMPTS_FOR_TEST: Record<"de" | "en", string> = {
|
||||
de: SYSTEM_PROMPT_DE,
|
||||
en: SYSTEM_PROMPT_EN,
|
||||
de: buildNarrativeSystemPrompt("de"),
|
||||
en: buildNarrativeSystemPrompt("en"),
|
||||
};
|
||||
|
||||
/** Render the typed context into a compact, model-readable block. */
|
||||
@@ -249,6 +304,10 @@ export async function generatePeriodNarrative(
|
||||
options: GenerateOptions,
|
||||
): Promise<NarrativeGenerateOutcome> {
|
||||
const { locale } = options;
|
||||
// The de/en body language the user prompt, the deterministic fallback and
|
||||
// the grounding correction are composed in. The ROW stays keyed by the full
|
||||
// `locale`; only the prose scaffolding collapses to a reviewed body.
|
||||
const bodyLocale = instructionLocale(locale);
|
||||
const force = options.force === true;
|
||||
const now = options.now ?? new Date();
|
||||
const prisma = options.prisma ?? defaultPrisma;
|
||||
@@ -289,8 +348,8 @@ export async function generatePeriodNarrative(
|
||||
userId,
|
||||
cacheAction: `insights.narrative.${period}.${locale}`,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
|
||||
userPrompt: buildNarrativeUserPrompt(context, locale),
|
||||
systemPrompt: buildNarrativeSystemPrompt(locale),
|
||||
userPrompt: buildNarrativeUserPrompt(context, bodyLocale),
|
||||
temperature: AI_BUDGETS.narrative.temperature,
|
||||
maxTokens: AI_BUDGETS.narrative.maxTokens,
|
||||
// v1.18.7 / v1.22 (W6) — the reference/diff path pins the deterministic
|
||||
@@ -363,7 +422,7 @@ export async function generatePeriodNarrative(
|
||||
// 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);
|
||||
const text = buildDeterministicNarrative(context, bodyLocale);
|
||||
await persist(text, DETERMINISTIC_PROVIDER_TYPE);
|
||||
return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE };
|
||||
}
|
||||
@@ -395,10 +454,10 @@ export async function generatePeriodNarrative(
|
||||
userId,
|
||||
cacheAction: `insights.narrative.${period}.${locale}`,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
|
||||
systemPrompt: buildNarrativeSystemPrompt(locale),
|
||||
userPrompt:
|
||||
buildNarrativeUserPrompt(context, locale) +
|
||||
buildNarrativeCorrection(findings, locale),
|
||||
buildNarrativeUserPrompt(context, bodyLocale) +
|
||||
buildNarrativeCorrection(findings, bodyLocale),
|
||||
temperature: AI_BUDGETS.narrative.temperature,
|
||||
maxTokens: AI_BUDGETS.narrative.maxTokens,
|
||||
seed: REFERENCE_AI_SEED,
|
||||
@@ -430,7 +489,7 @@ export async function generatePeriodNarrative(
|
||||
/** The narrative row, decrypted for a read. */
|
||||
export interface NarrativeRead {
|
||||
period: NarrativePeriod;
|
||||
locale: "de" | "en";
|
||||
locale: Locale;
|
||||
text: string;
|
||||
dateKey: string;
|
||||
provenance: NarrativeProvenancePayload | null;
|
||||
@@ -448,7 +507,7 @@ export interface NarrativeRead {
|
||||
export async function readPeriodNarrative(
|
||||
userId: string,
|
||||
period: NarrativePeriod,
|
||||
locale: "de" | "en",
|
||||
locale: Locale,
|
||||
prisma: PrismaClient = defaultPrisma,
|
||||
): Promise<NarrativeRead | null> {
|
||||
const row = await prisma.insightNarrative.findUnique({
|
||||
@@ -475,7 +534,7 @@ export async function readPeriodNarrative(
|
||||
|
||||
return {
|
||||
period: row.period as NarrativePeriod,
|
||||
locale: row.locale as "de" | "en",
|
||||
locale: row.locale as Locale,
|
||||
text,
|
||||
dateKey: row.dateKey,
|
||||
provenance,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm";
|
||||
|
||||
@@ -33,8 +34,8 @@ export interface PeriodNarrativePayload {
|
||||
userId?: string;
|
||||
/** Period to warm on the single-user path. */
|
||||
period?: NarrativePeriod;
|
||||
/** Locale to warm; defaults to "de" when absent. */
|
||||
locale?: "de" | "en";
|
||||
/** Locale to warm; defaults to the app default (`en`) when absent. */
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,7 @@ export interface PeriodNarrativePayload {
|
||||
export async function enqueueNarrativeWarm(payload: {
|
||||
userId: string;
|
||||
period: NarrativePeriod;
|
||||
locale: "de" | "en";
|
||||
locale: Locale;
|
||||
}): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type NarrativeGenerateOutcome,
|
||||
} from "@/lib/insights/narrative/period-narrative-generate";
|
||||
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
PERIOD_NARRATIVE_CRON,
|
||||
@@ -128,11 +129,16 @@ export async function findNarrativeCandidates(
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-German locales resolve to ENGLISH (matching
|
||||
* `status-shared.normalizeLocale` and the no-key fallback routing).
|
||||
* The user's stored locale, validated against the shipped UI locale union.
|
||||
*
|
||||
* The narrative row is keyed `(user, period, locale)`, so the nightly warm has
|
||||
* to write the SAME key the read route reads. Collapsing fr/es/it/pl to `en`
|
||||
* here would leave those users' rows permanently cold on the nightly path.
|
||||
* An unset or unknown value falls back to the app default (`en`), never to
|
||||
* German.
|
||||
*/
|
||||
function normalizeLocale(value: string | null): "de" | "en" {
|
||||
return value === "de" ? "de" : "en";
|
||||
function normalizeLocale(value: string | null): Locale {
|
||||
return locales.includes(value as Locale) ? (value as Locale) : defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,7 +260,7 @@ export async function warmOneNarrative(
|
||||
if (!flags.briefing && !flags.insightStatus) return null;
|
||||
return generatePeriodNarrative(payload.userId, {
|
||||
period: payload.period,
|
||||
locale: payload.locale ?? "de",
|
||||
locale: payload.locale ?? defaultLocale,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user