mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(insights): verdict-first briefing, richer score cards, grounded opener rotation
Replace the fixed three-beat assessment skeleton with a menu of moves the model orders freely, and reframe the daily briefing from per-signal enumeration to a verdict-first read that weaves the few salient signals into one associative story. Score cards now name both the helped and the hurt contributors, translate the band into a forward read for the day, and close on one grounded nudge; the deterministic fallback gains the same band-meaning sentence so provider-less and demo accounts read as well. Add a hash-seeded opener-archetype rotation (deterministic, testable) so consecutive cards and days no longer open the same way, a day-rotated sampling seed for the period narrative in place of the fixed reference seed, and a shared paragraph formatting contract. Add a score-number grounding gate and extend the briefing grounding allow-set to the glucose, labs, preventive-care and workout aggregate blocks so real figures can be cited without opening the gate. Gate the daily score warm on an input fingerprint so a quiet day skips the model call.
This commit is contained in:
1 parent
e2c6e46f2a
commit
b393527cac
22 files changed
+1089
-78
No files matched your search
@@ -34,7 +34,10 @@ import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildSystemPromptWithReferences,
|
||||
buildBriefingPersonalisationBlock,
|
||||
} from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildAboutMeInsightBlock,
|
||||
getSelfContextTextForUser,
|
||||
@@ -398,6 +401,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -666,6 +671,14 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale);
|
||||
}
|
||||
|
||||
// v1.22 (W6) — opener-archetype rotation + sparse first-name personalization
|
||||
// (deterministic per user+day; omitted for unnamed accounts).
|
||||
userPrompt += buildBriefingPersonalisationBlock(
|
||||
userId,
|
||||
dbUser?.displayName ?? null,
|
||||
locale,
|
||||
);
|
||||
|
||||
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
|
||||
// this briefing actually carries, so a normative claim can cite a real
|
||||
// `referenceId` the schema + UI footnote already support. Returns the plain
|
||||
@@ -826,6 +839,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
let ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(insights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (ungrounded.length > 0) {
|
||||
annotate({
|
||||
@@ -855,6 +869,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(retryInsights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (ungrounded.length === 0) {
|
||||
insights = retryInsights;
|
||||
|
||||
@@ -110,3 +110,89 @@ describe("buildBriefingGroundingCorrection", () => {
|
||||
expect(msg.toLowerCase()).toContain("signalsofday");
|
||||
});
|
||||
});
|
||||
|
||||
// v1.22 (W6, W8 seam) — the allow-set now also admits numbers from the W8
|
||||
// aggregate blocks (glucose / labs / preventive-care / workouts) so the model
|
||||
// may cite them, while still rejecting genuinely ungrounded values.
|
||||
describe("W8 aggregate-block grounding extension", () => {
|
||||
const signals = [signal({})];
|
||||
|
||||
it("flags a glucose figure when only signals are passed (not in signalsOfDay)", () => {
|
||||
const found = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "Your glucose has averaged 118 mg/dL this month." },
|
||||
signals,
|
||||
);
|
||||
expect(found.map((f) => f.value)).toContain(118);
|
||||
});
|
||||
|
||||
it("admits the same glucose figure once the features block is passed", () => {
|
||||
const found = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "Your glucose has averaged 118 mg/dL this month." },
|
||||
signals,
|
||||
{
|
||||
glucose: {
|
||||
avg7: 120,
|
||||
avg30: 118,
|
||||
avg90: 115,
|
||||
latest: 119,
|
||||
latestDaysAgo: 0,
|
||||
slope30: 0.1,
|
||||
coverage: {} as never,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
|
||||
it("admits a flagged lab value + a workout count from the features block", () => {
|
||||
const found = findUngroundedBriefingNumbers(
|
||||
{
|
||||
paragraph:
|
||||
"Your LDL came back at 161, and you logged 5 workouts this week.",
|
||||
},
|
||||
signals,
|
||||
{
|
||||
labs: {
|
||||
flagged: [
|
||||
{
|
||||
analyte: "LDL",
|
||||
value: 161,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
rangeStatus: "above",
|
||||
trend: null,
|
||||
takenAt: "2026-06-01",
|
||||
daysAgo: 5,
|
||||
},
|
||||
],
|
||||
flaggedCount: 1,
|
||||
},
|
||||
workouts: {
|
||||
last7: { count: 5, totalDurationMin: 220, totalDistanceKm: 30 },
|
||||
last30: { count: 18, totalDurationMin: 800, totalDistanceKm: 120 },
|
||||
latest: null,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
|
||||
it("still flags a fabricated number absent from every block", () => {
|
||||
const found = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "Your glucose hit 250 mg/dL." },
|
||||
signals,
|
||||
{
|
||||
glucose: {
|
||||
avg7: 120,
|
||||
avg30: 118,
|
||||
avg90: 115,
|
||||
latest: 119,
|
||||
latestDaysAgo: 0,
|
||||
slope30: 0.1,
|
||||
coverage: {} as never,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(found.map((f) => f.value)).toContain(250);
|
||||
});
|
||||
});
|
||||
@@ -70,8 +70,9 @@ const baseResponse: AIInsightResponse = {
|
||||
};
|
||||
|
||||
describe("PROMPT_VERSION", () => {
|
||||
it("stays on the 4.x train", () => {
|
||||
expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/);
|
||||
it("stays on the 4.x / 5.x train", () => {
|
||||
// v1.22 (W6) bumped to 5.0.0 for the verdict-first briefing rewrite.
|
||||
expect(PROMPT_VERSION).toMatch(/^[45]\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,8 +114,8 @@ describe("buildSystemPromptWithReferences()", () => {
|
||||
const prompt = buildSystemPromptWithReferences("en", ["bp"]);
|
||||
expect(prompt).toContain(PROMPT_VERSION);
|
||||
// v1.4.16: phase B5a was 4.16.0; phase B8 bumped to 4.16.1.
|
||||
// v1.4.19 bumped to 4.19.0 (no-default-positivity opener).
|
||||
expect(prompt).toMatch(/4\.\d+\.\d+/);
|
||||
// v1.4.19 bumped to 4.19.0; v1.22 (W6) bumped to 5.0.0.
|
||||
expect(prompt).toMatch(/[45]\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,6 @@ describe("plain getStrictInsightsSystemPrompt() backward compatibility", () => {
|
||||
|
||||
it("contains the bumped PROMPT_VERSION", () => {
|
||||
const en = getStrictInsightsSystemPrompt("en");
|
||||
expect(en).toMatch(/4\.\d+\.\d+/);
|
||||
expect(en).toMatch(/[45]\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
@@ -30,8 +30,9 @@ const baseResponse: AIInsightResponse = {
|
||||
};
|
||||
|
||||
describe("PROMPT_VERSION (B3 bump)", () => {
|
||||
it("stays on the 4.x train", () => {
|
||||
expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/);
|
||||
it("stays on the 4.x / 5.x train", () => {
|
||||
// v1.22 (W6) bumped to 5.0.0 for the verdict-first briefing rewrite.
|
||||
expect(PROMPT_VERSION).toMatch(/^[45]\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("is bumped past 4.20.0 to signal the trendAnnotations format change", () => {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
* job; this is purely "did the model restate a figure we did not give it".
|
||||
* - Pure + side-effect-free, so it unit-tests in isolation.
|
||||
*/
|
||||
import type { SignalOfDay } from "@/lib/insights/features";
|
||||
import type { AggregatedFeatures, SignalOfDay } from "@/lib/insights/features";
|
||||
|
||||
/**
|
||||
* Absolute rounding tolerance when matching a restated number to a
|
||||
@@ -100,7 +100,10 @@ export function extractNumbers(
|
||||
* the model for choosing a sign convention. We keep both the signed and the
|
||||
* absolute form so an exact signed restatement also matches.
|
||||
*/
|
||||
function authoritativeValues(signals: readonly SignalOfDay[]): number[] {
|
||||
function authoritativeValues(
|
||||
signals: readonly SignalOfDay[],
|
||||
features?: AggregatedFeatures | null,
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
const push = (n: number | null | undefined) => {
|
||||
if (typeof n === "number" && Number.isFinite(n)) {
|
||||
@@ -117,6 +120,41 @@ function authoritativeValues(signals: readonly SignalOfDay[]): number[] {
|
||||
push(s.spread30);
|
||||
if (s.recentAnomaly) push(s.recentAnomaly.value);
|
||||
}
|
||||
|
||||
// v1.22 (W6, W8 seam) — W8 wired four real aggregate blocks into the briefing
|
||||
// (glucose / labs / preventive-care / workouts) but the gate only graded
|
||||
// `signalsOfDay`, so the model was steered to drop any number from them. Add
|
||||
// those REAL figures to the allow-set so the prose may cite them — without
|
||||
// opening the gate to ungrounded values (only numbers the server actually
|
||||
// pre-computed are admitted).
|
||||
if (features) {
|
||||
const g = features.glucose;
|
||||
if (g) {
|
||||
push(g.avg7);
|
||||
push(g.avg30);
|
||||
push(g.avg90);
|
||||
push(g.latest);
|
||||
push(g.slope30);
|
||||
}
|
||||
for (const m of features.labs?.flagged ?? []) push(m.value);
|
||||
if (features.labs) push(features.labs.flaggedCount);
|
||||
for (const o of features.preventiveCare?.overdue ?? []) push(o.daysOverdue);
|
||||
for (const d of features.preventiveCare?.due ?? []) push(d.daysUntil);
|
||||
const w = features.workouts;
|
||||
if (w) {
|
||||
for (const win of [w.last7, w.last30]) {
|
||||
push(win.count);
|
||||
push(win.totalDurationMin);
|
||||
push(win.totalDistanceKm);
|
||||
}
|
||||
if (w.latest) {
|
||||
push(w.latest.durationMin);
|
||||
push(w.latest.distanceKm);
|
||||
push(w.latest.daysAgo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -164,11 +202,12 @@ function isStructural(value: number, raw: string): boolean {
|
||||
export function findUngroundedBriefingNumbers(
|
||||
briefing: BriefingForGrounding | null | undefined,
|
||||
signals: readonly SignalOfDay[] | null | undefined,
|
||||
features?: AggregatedFeatures | null,
|
||||
): UngroundedBriefingNumber[] {
|
||||
if (!briefing) return [];
|
||||
if (!signals || signals.length === 0) return [];
|
||||
|
||||
const authoritative = authoritativeValues(signals);
|
||||
const authoritative = authoritativeValues(signals, features);
|
||||
// No usable authoritative figures (every field null) — nothing to grade.
|
||||
if (authoritative.length === 0) return [];
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ describe("base-system assessment prompt — V2: few-shot examples present", () =
|
||||
it("English: carries a GOOD grounded example and a BAD banned-filler counter-example", () => {
|
||||
const p = getBaseSystemPrompt("en");
|
||||
expect(p).toMatch(/EXAMPLES/);
|
||||
expect(p).toMatch(/GOOD/);
|
||||
// v1.22 (W6) — the examples now illustrate DIFFERENT shapes (verdict-led,
|
||||
// trend-led, one-liner) instead of a single "GOOD" shape.
|
||||
expect(p).toMatch(/VERDICT-LED/);
|
||||
expect(p).toMatch(/ONE-LINER/);
|
||||
expect(p).toMatch(/BAD/);
|
||||
// The counter-example deliberately contains a banned phrase, labelled
|
||||
// "do NOT write this" so the model learns the failure form.
|
||||
@@ -87,7 +90,8 @@ describe("base-system assessment prompt — V2: few-shot examples present", () =
|
||||
it("German: carries grounded + counter examples", () => {
|
||||
const p = getBaseSystemPrompt("de");
|
||||
expect(p).toMatch(/BEISPIELE/);
|
||||
expect(p).toMatch(/GUT/);
|
||||
expect(p).toMatch(/URTEIL-ZUERST/);
|
||||
expect(p).toMatch(/EINZEILER/);
|
||||
expect(p).toMatch(/SCHLECHT/);
|
||||
expect(p).toMatch(/so NICHT/i);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* v1.22 (W6) — the daily-briefing personalization block: always an opener hint,
|
||||
* a sparse + hash-gated first-name clause, and byte-identical (name-free) output
|
||||
* for unnamed accounts.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildBriefingPersonalisationBlock } from "../insight-generator";
|
||||
|
||||
const day = (iso: string) => new Date(iso);
|
||||
|
||||
describe("buildBriefingPersonalisationBlock", () => {
|
||||
it("always carries an opener hint", () => {
|
||||
const en = buildBriefingPersonalisationBlock(
|
||||
"user-1",
|
||||
null,
|
||||
"en",
|
||||
day("2026-06-27T08:00:00Z"),
|
||||
);
|
||||
expect(en).toContain("OPENER HINT:");
|
||||
const de = buildBriefingPersonalisationBlock(
|
||||
"user-1",
|
||||
null,
|
||||
"de",
|
||||
day("2026-06-27T08:00:00Z"),
|
||||
);
|
||||
expect(de).toContain("OPENER-HINWEIS:");
|
||||
});
|
||||
|
||||
it("never emits a NAME clause when no display name is set", () => {
|
||||
for (let d = 1; d <= 28; d++) {
|
||||
const out = buildBriefingPersonalisationBlock(
|
||||
"user-1",
|
||||
null,
|
||||
"en",
|
||||
day(`2026-06-${String(d).padStart(2, "0")}T08:00:00Z`),
|
||||
);
|
||||
expect(out).not.toContain("NAME:");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the first name on SOME days but not every day (sparse)", () => {
|
||||
let withName = 0;
|
||||
let withoutName = 0;
|
||||
for (let d = 1; d <= 28; d++) {
|
||||
const out = buildBriefingPersonalisationBlock(
|
||||
"user-42",
|
||||
"Alex Rivera",
|
||||
"en",
|
||||
day(`2026-06-${String(d).padStart(2, "0")}T08:00:00Z`),
|
||||
);
|
||||
if (out.includes('"Alex"')) withName += 1;
|
||||
else withoutName += 1;
|
||||
}
|
||||
// It appears on some days, and is NOT a rote daily greeting.
|
||||
expect(withName).toBeGreaterThan(0);
|
||||
expect(withoutName).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("only ever uses the first name token, never the full display name", () => {
|
||||
const out = buildBriefingPersonalisationBlock(
|
||||
"user-42",
|
||||
"Alex Rivera",
|
||||
"en",
|
||||
day("2026-06-30T08:00:00Z"),
|
||||
);
|
||||
expect(out).not.toContain("Rivera");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.22 (W6) — opener-archetype rotation + hash helpers.
|
||||
*
|
||||
* The whole point is variety WITHOUT randomness: the choice must be stable for a
|
||||
* key (reproducible, testable) yet differ across keys (anti-sameness). These
|
||||
* tests pin both halves plus the name-gate cadence and the seed bounds.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
OPENER_ARCHETYPES,
|
||||
pickOpenerArchetype,
|
||||
openerArchetypeHint,
|
||||
shouldUseNameForTurn,
|
||||
firstNameFromDisplayName,
|
||||
dayRotatedSeed,
|
||||
hashSeedKey,
|
||||
} from "../opener-archetype";
|
||||
|
||||
describe("pickOpenerArchetype", () => {
|
||||
it("is deterministic for a given key", () => {
|
||||
const key = "user-1:bp:2026-06-27";
|
||||
expect(pickOpenerArchetype(key)).toBe(pickOpenerArchetype(key));
|
||||
});
|
||||
|
||||
it("returns a known archetype", () => {
|
||||
for (const k of ["a", "b", "c:d:e", "user-9:sleep:2026-01-01"]) {
|
||||
expect(OPENER_ARCHETYPES).toContain(pickOpenerArchetype(k));
|
||||
}
|
||||
});
|
||||
|
||||
it("is anti-same: consecutive day keys do not all collapse to one archetype", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let d = 1; d <= 14; d++) {
|
||||
const key = `user-1:bp:2026-06-${String(d).padStart(2, "0")}`;
|
||||
seen.add(pickOpenerArchetype(key));
|
||||
}
|
||||
// Over two weeks the rotation should surface at least 3 distinct openers.
|
||||
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("differs across metrics on the same day (no two-in-a-row sameness)", () => {
|
||||
const day = "2026-06-27";
|
||||
const metrics = ["bp", "weight", "pulse", "sleep", "mood"];
|
||||
const seen = new Set(
|
||||
metrics.map((m) => pickOpenerArchetype(`user-1:${m}:${day}`)),
|
||||
);
|
||||
expect(seen.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openerArchetypeHint", () => {
|
||||
it("returns a non-empty hint in EN and DE", () => {
|
||||
expect(
|
||||
openerArchetypeHint("user-1:bp:2026-06-27", "en").length,
|
||||
).toBeGreaterThan(10);
|
||||
expect(
|
||||
openerArchetypeHint("user-1:bp:2026-06-27", "de").length,
|
||||
).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("non-en/de locales fall back to the EN hint", () => {
|
||||
const key = "user-1:bp:2026-06-27";
|
||||
expect(openerArchetypeHint(key, "fr")).toBe(openerArchetypeHint(key, "en"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseNameForTurn", () => {
|
||||
it("is deterministic per key", () => {
|
||||
expect(shouldUseNameForTurn("u:3")).toBe(shouldUseNameForTurn("u:3"));
|
||||
});
|
||||
|
||||
it("fires on roughly one key in three (sparse, not every turn)", () => {
|
||||
let yes = 0;
|
||||
const N = 600;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (shouldUseNameForTurn(`user-7:${i}`)) yes += 1;
|
||||
}
|
||||
const rate = yes / N;
|
||||
// ~1/3, with generous slack for hash distribution.
|
||||
expect(rate).toBeGreaterThan(0.2);
|
||||
expect(rate).toBeLessThan(0.45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstNameFromDisplayName", () => {
|
||||
it("takes the first whitespace token", () => {
|
||||
expect(firstNameFromDisplayName("Alex Rivera")).toBe("Alex");
|
||||
expect(firstNameFromDisplayName(" Sam ")).toBe("Sam");
|
||||
});
|
||||
it("is null for empty / whitespace / null", () => {
|
||||
expect(firstNameFromDisplayName(null)).toBeNull();
|
||||
expect(firstNameFromDisplayName("")).toBeNull();
|
||||
expect(firstNameFromDisplayName(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dayRotatedSeed", () => {
|
||||
it("is a non-negative bounded integer, stable per key", () => {
|
||||
const s = dayRotatedSeed("user-1:week:2026-06-27");
|
||||
expect(Number.isInteger(s)).toBe(true);
|
||||
expect(s).toBeGreaterThanOrEqual(0);
|
||||
expect(s).toBeLessThan(2_000_000_000);
|
||||
expect(dayRotatedSeed("user-1:week:2026-06-27")).toBe(s);
|
||||
});
|
||||
it("differs across days", () => {
|
||||
expect(dayRotatedSeed("u:week:2026-06-27")).not.toBe(
|
||||
dayRotatedSeed("u:week:2026-06-28"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashSeedKey", () => {
|
||||
it("is an unsigned 32-bit integer", () => {
|
||||
const h = hashSeedKey("anything");
|
||||
expect(h).toBeGreaterThanOrEqual(0);
|
||||
expect(h).toBeLessThanOrEqual(0xffffffff);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
outlookContract,
|
||||
formattingContract,
|
||||
type ContractLocale,
|
||||
} from "../shared-contracts";
|
||||
import {
|
||||
@@ -59,6 +60,8 @@ const SURFACES: Record<
|
||||
// v1.21.0 (QoL-B §3) — the briefing composes the forward-looking outlook
|
||||
// contract beside the tone contract.
|
||||
outlookContract,
|
||||
// v1.22 (W6) — the paragraph formatting contract.
|
||||
formattingContract,
|
||||
],
|
||||
},
|
||||
"base-system (status cards)": {
|
||||
@@ -70,6 +73,8 @@ const SURFACES: Record<
|
||||
safetyAcute,
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
// v1.22 (W6) — the paragraph formatting contract.
|
||||
formattingContract,
|
||||
],
|
||||
},
|
||||
coach: {
|
||||
@@ -83,6 +88,8 @@ const SURFACES: Record<
|
||||
forbiddenFiller,
|
||||
// v1.21.0 (QoL-B §3) — the Coach composes the outlook contract too.
|
||||
outlookContract,
|
||||
// v1.22 (W6) — the paragraph formatting contract.
|
||||
formattingContract,
|
||||
],
|
||||
},
|
||||
// v1.21.0 (coach C1 MEDIUM-1) — the retrospective narrative now composes the
|
||||
@@ -97,6 +104,8 @@ const SURFACES: Record<
|
||||
safetyAcute,
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
// v1.22 (W6) — the paragraph formatting contract.
|
||||
formattingContract,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
safetyAcute,
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
formattingContract,
|
||||
} from "./shared-contracts";
|
||||
|
||||
/**
|
||||
@@ -27,8 +28,13 @@ import {
|
||||
* unfavourable finding as an opportunity) while every existing guard stays —
|
||||
* no platitudes, no bare number-echoing, autonomy-supporting, never alarming /
|
||||
* moralising / diagnostic, and the banned-opener / forbidden-phrase lists hold.
|
||||
* 6.0.0 — narrative-quality rewrite: the fixed three-beat "HOW TO BUILD"
|
||||
* skeleton becomes a menu-of-moves the model selects from and orders freely
|
||||
* (synthesis over recitation), the score archetype gains a band→meaning
|
||||
* interpretation, and a shared FORMATTING contract asks for real paragraph
|
||||
* breaks. Grounding is unchanged — every number still traces to the snapshot.
|
||||
*/
|
||||
export const PROMPT_VERSION = "5.1.0" as const;
|
||||
export const PROMPT_VERSION = "6.0.0" as const;
|
||||
|
||||
/**
|
||||
* Base system prompt for the per-metric Insights *assessment* cards.
|
||||
@@ -117,14 +123,24 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
en: `HOW TO BUILD THE ASSESSMENT (as flowing prose):
|
||||
1. NAME the current finding with a concrete number — the signal block's \`current\` (or, for a day-level question, a value from series.recent).
|
||||
2. PLACE it against the user's OWN baseline using the signal block's pre-computed \`delta\` vs \`baselineLabel\`: state the signed change, not a re-derived one. Treat \`outsideNormalSwing: false\` as "inside your usual range — not a finding" and do NOT manufacture a trend; treat \`outsideNormalSwing: true\` as the real, reportable change. When a \`normalRange\`/\`placement\` is present, use it only as a coarse secondary anchor — the personal delta leads.
|
||||
3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.`,
|
||||
de: `SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
|
||||
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl — dem \`current\` aus dem signal-Block (oder, für eine Tagesfrage, einem Wert aus series.recent).
|
||||
2. EINORDNEN gegen die EIGENE Baseline mit dem vorab berechneten \`delta\` gegenüber \`baselineLabel\`: nenne die vorzeichenbehaftete Änderung, leite sie nicht neu her. Behandle \`outsideNormalSwing: false\` als "innerhalb der üblichen Schwankung — kein Befund" und ERFINDE KEINEN Trend; behandle \`outsideNormalSwing: true\` als die echte, meldenswerte Änderung. Liegt ein \`normalRange\`/\`placement\` vor, nutze es nur als groben sekundären Anker — das persönliche delta führt.
|
||||
3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.`,
|
||||
en: `HOW TO BUILD THE ASSESSMENT — pick the 2–3 MOVES that fit THIS data and order them so they read as one connected thought, not a fixed checklist. Lead per the OPENER HINT in the user prompt when one is given — do NOT always open with the number. Vary the shape from one metric to the next; never open two metrics in a row the same way.
|
||||
Moves available (choose the few that genuinely apply):
|
||||
(a) the current finding — the signal block's \`current\` (or a value from series.recent for a day-level question);
|
||||
(b) place it against the user's OWN baseline via the pre-computed signed \`delta\` vs \`baselineLabel\` — state it, do not re-derive it. Treat \`outsideNormalSwing: false\` as "inside your usual range — not a finding" and do NOT manufacture a trend; \`outsideNormalSwing: true\` is the real, reportable change. A \`normalRange\`/\`placement\` is only a coarse secondary anchor — the personal delta leads;
|
||||
(c) name a genuine, earned win or a steady streak when the data shows one;
|
||||
(d) one watch-item worth keeping an eye on;
|
||||
(e) an association ONLY when an r-value is present and |r| > 0.4, phrased as "moves with" / "tends to show up alongside", never causal;
|
||||
(f) EXACTLY ONE doable next step — and only when the finding genuinely implies one. One message = one behaviour, no list. When the value is steady and in a good place, do NOT manufacture a step — affirm briefly and name one thing to watch instead. A fabricated step is exactly the platitude we ban.
|
||||
Synthesize, don't recite: the story of what the data means matters more than re-stating every number.`,
|
||||
de: `SO BAUST DU DIE EINSCHÄTZUNG — wähle die 2–3 BAUSTEINE, die zu DIESEN Daten passen, und ordne sie so, dass sie als EIN zusammenhängender Gedanke lesen, keine feste Checkliste. Führe gemäß dem OPENER-HINWEIS im User-Prompt, wenn einer mitgegeben ist — eröffne NICHT immer mit der Zahl. Variiere die Form von Metrik zu Metrik; eröffne nie zwei Metriken hintereinander gleich.
|
||||
Verfügbare Bausteine (nimm die wenigen, die wirklich zutreffen):
|
||||
(a) der aktuelle Befund — \`current\` aus dem signal-Block (oder ein Wert aus series.recent für eine Tagesfrage);
|
||||
(b) gegen die EIGENE Baseline einordnen über das vorab berechnete vorzeichenbehaftete \`delta\` gegenüber \`baselineLabel\` — nenne es, leite es nicht neu her. Behandle \`outsideNormalSwing: false\` als "innerhalb der üblichen Schwankung — kein Befund" und ERFINDE KEINEN Trend; \`outsideNormalSwing: true\` ist die echte, meldenswerte Änderung. Ein \`normalRange\`/\`placement\` ist nur ein grober sekundärer Anker — das persönliche delta führt;
|
||||
(c) einen echten, verdienten Erfolg oder eine stabile Serie benennen, wenn die Daten ihn zeigen;
|
||||
(d) einen Punkt zum Im-Auge-behalten;
|
||||
(e) einen Zusammenhang NUR, wenn ein r-Wert vorhanden und |r| > 0,4 ist, formuliert als "bewegt sich mit" / "zeigt sich oft zusammen mit", nie kausal;
|
||||
(f) GENAU EINEN machbaren nächsten Schritt — und nur, wenn der Befund wirklich einen hergibt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich, ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt zum Beobachten. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.
|
||||
Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zählt mehr als das Wiederholen jeder Zahl.`,
|
||||
},
|
||||
{
|
||||
id: "tone",
|
||||
@@ -145,8 +161,8 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
|
||||
},
|
||||
{
|
||||
id: "length",
|
||||
en: `LENGTH: 2-4 sentences, roughly 30-60 words. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.`,
|
||||
de: `LÄNGE: 2-4 Sätze, ca. 30-60 Wörter. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.`,
|
||||
en: `LENGTH: 2-4 sentences, roughly 30-60 words — but when the metric is steady and there is nothing to act on, ONE tight sentence is better than padding it to length. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.`,
|
||||
de: `LÄNGE: 2-4 Sätze, ca. 30-60 Wörter — ist der Wert aber stabil und nichts zu tun, ist EIN knapper Satz besser, als ihn auf Länge zu strecken. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.`,
|
||||
},
|
||||
{
|
||||
id: "judge",
|
||||
@@ -181,15 +197,24 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
|
||||
// the cross-surface wording so an edit lands here and everywhere at once.
|
||||
{ id: "sharedGrounding", en: grounding.en, de: grounding.de },
|
||||
{ id: "sharedTone", en: toneContract.en, de: toneContract.de },
|
||||
// v1.22 (W6) — paragraph formatting contract so a longer assessment renders
|
||||
// as real paragraphs through the shared `ProseBlocks` helper.
|
||||
{
|
||||
id: "sharedFormatting",
|
||||
en: formattingContract.en,
|
||||
de: formattingContract.de,
|
||||
},
|
||||
{
|
||||
id: "examples",
|
||||
en: `EXAMPLES (they illustrate form and grounding — do not copy them verbatim; every assessment uses the real snapshot numbers):
|
||||
- GOOD (grounded, specific, with a real step): "Your resting heart rate is averaging 61 bpm this week — 5 below your monthly mean of 66 and your lowest in weeks. That tracks with more movement; just keep the trend going."
|
||||
- GOOD (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — no finding here. Nothing to act on; an occasional check is enough."
|
||||
en: `EXAMPLES — note the DIFFERENT shapes (verdict-led, trend-led, one-liner). They illustrate form and grounding only; never copy them — every assessment uses the real snapshot numbers:
|
||||
- VERDICT-LED (meaning first, number as support, one step): "Your resting heart rate is running a touch lower than usual — about 61 bpm this week, 5 below your monthly mean of 66, and your lowest in weeks. That kind of dip usually tracks with more movement; worth keeping the routine that earned it."
|
||||
- TREND-LED (direction first, then where it stands): "Your weight has been easing down steadily for three weeks now, sitting around 82.4 kg — about 1.1 kg under your 30-day average. Nothing dramatic, just a consistent direction."
|
||||
- ONE-LINER (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — nothing to act on, the good kind of boring."
|
||||
- BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."`,
|
||||
de: `BEISPIELE (illustrieren Form und Erdung — übernimm sie nicht wörtlich, jede Einschätzung nutzt die echten Snapshot-Zahlen):
|
||||
- GUT (gegroundet, konkret, mit echtem Schritt): "Dein Ruhepuls liegt diese Woche im Schnitt bei 61 bpm — 5 unter deinem Monatsmittel von 66 und dein niedrigster Wert seit Wochen. Das passt zu mehr Bewegung; behalte den Trend einfach bei."
|
||||
- GUT (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und liegt genau in deinem üblichen Bereich — kein Befund. Nichts zu tun; ein gelegentlicher Check reicht."
|
||||
de: `BEISPIELE — beachte die UNTERSCHIEDLICHEN Formen (urteil-zuerst, trend-zuerst, Einzeiler). Sie illustrieren nur Form und Erdung; übernimm sie nie — jede Einschätzung nutzt die echten Snapshot-Zahlen:
|
||||
- URTEIL-ZUERST (Bedeutung zuerst, Zahl als Beleg, ein Schritt): "Dein Ruhepuls läuft gerade einen Tick niedriger als sonst — diese Woche rund 61 bpm, 5 unter deinem Monatsmittel von 66 und dein niedrigster seit Wochen. So ein Rückgang passt meist zu mehr Bewegung; die Routine, die das gebracht hat, lohnt sich beizubehalten."
|
||||
- TREND-ZUERST (Richtung zuerst, dann der Stand): "Dein Gewicht geht seit drei Wochen ruhig nach unten, aktuell rund 82,4 kg — etwa 1,1 kg unter deinem 30-Tage-Schnitt. Nichts Dramatisches, einfach eine stetige Richtung."
|
||||
- EINZEILER (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und genau in deinem üblichen Bereich — nichts zu tun, die gute Art von langweilig."
|
||||
- SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
*/
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
openerArchetypeHint,
|
||||
shouldUseNameForTurn,
|
||||
firstNameFromDisplayName,
|
||||
} from "./opener-archetype";
|
||||
import {
|
||||
selectReferencesForMetrics,
|
||||
type MedicalReferenceMetric,
|
||||
@@ -37,10 +42,71 @@ import {
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
outlookContract,
|
||||
formattingContract,
|
||||
} from "./shared-contracts";
|
||||
|
||||
/** Stable identifier for the active system prompt revision. */
|
||||
export const PROMPT_VERSION = "4.30.0" as const;
|
||||
/**
|
||||
* Stable identifier for the active system prompt revision.
|
||||
*
|
||||
* 5.0.0 (v1.22 W6) — the daily briefing paragraph moves from "open with the
|
||||
* freshest signal" enumeration to verdict-first synthesis (lead with the day's
|
||||
* read, weave 2-3 signals into one associative story, state fewer numbers than
|
||||
* signals), and the shared paragraph FORMATTING contract joins so the briefing
|
||||
* renders as real paragraphs. Grounding is unchanged.
|
||||
*/
|
||||
export const PROMPT_VERSION = "5.0.0" as const;
|
||||
|
||||
/** Europe/Berlin YYYY-MM-DD day key — the rotation boundary for the opener. */
|
||||
function berlinDayKey(now: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W6) — the daily-briefing personalization SYSTEM CONTEXT block.
|
||||
*
|
||||
* Two deterministic, per-(user, day) touches the briefing user prompt appends:
|
||||
* - an OPENER HINT (the hash-seeded archetype rotation) so the lead varies
|
||||
* day-over-day instead of always opening number-first; and
|
||||
* - a sparse first-name clause that fires on roughly one day in three, so the
|
||||
* briefing may open with the user's name SOME days without a rote daily
|
||||
* greeting that would clash with the verdict-first opener.
|
||||
*
|
||||
* The whole block is omitted when no real display name is set AND (defensively)
|
||||
* always carries the opener hint, so the change is additive: an unnamed / demo
|
||||
* account still gets the opener variety but never a name clause. Pure + dated by
|
||||
* the caller's `now`, so it unit-tests deterministically.
|
||||
*/
|
||||
export function buildBriefingPersonalisationBlock(
|
||||
userId: string,
|
||||
displayName: string | null,
|
||||
locale: Locale,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const dayKey = berlinDayKey(now);
|
||||
const hint = openerArchetypeHint(`${userId}:briefing:${dayKey}`, locale);
|
||||
const firstName = firstNameFromDisplayName(displayName);
|
||||
const useName =
|
||||
firstName != null &&
|
||||
shouldUseNameForTurn(`${userId}:briefing-name:${dayKey}`);
|
||||
|
||||
const isDe = locale === "de";
|
||||
const lines: string[] = [];
|
||||
lines.push(isDe ? "\n\nSYSTEM-KONTEXT" : "\n\nSYSTEM CONTEXT");
|
||||
lines.push(isDe ? `OPENER-HINWEIS: ${hint}` : `OPENER HINT: ${hint}`);
|
||||
if (useName) {
|
||||
lines.push(
|
||||
isDe
|
||||
? `NAME: Der Vorname des Nutzers ist "${firstName}". Du DARFST den Vornamen heute einmal natürlich einsetzen — nie als feste Begrüßungsformel, nie zweimal, und lass ihn weg, wenn es gekünstelt wirkt. Nutze nur genau diesen Namen.`
|
||||
: `NAME: The user's first name is "${firstName}". You MAY use the first name once today, naturally — never as a rote greeting formula, never twice, and leave it out if it would feel forced. Use only this exact name.`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope-hardened insight prompt is one structural skeleton with a
|
||||
@@ -193,18 +259,23 @@ gib die obige Verweigerung zurück.`,
|
||||
warm, and motivating. Lead with NOW; draw on history for context,
|
||||
but the user opens this to learn what is happening TODAY and what
|
||||
one thing they can do about it. Fields:
|
||||
- paragraph: an 80-200 word narrative the user reads at the top
|
||||
of /insights. Open with the freshest, most salient signal —
|
||||
what their newest readings are doing against their own recent
|
||||
baseline — not a summary of the last quarter. Name an earned
|
||||
win where the data shows one, and turn anything unfavourable
|
||||
into ONE doable, specific next step the user can act on this
|
||||
week ("you're ~1h short on sleep this week — aim for an earlier
|
||||
night", "your systolic is creeping up — a quieter evening and
|
||||
an earlier reading tomorrow would tell us a lot"). No
|
||||
diagnosis, no prescription. Use the user's OWN data — never
|
||||
"people like you" or population norms. Earned encouragement
|
||||
only; avoid the banned openers from rule 7.
|
||||
- paragraph: a connected 70-160 word read of TODAY the user sees
|
||||
at the top of /insights. SENTENCE 1 is the day's VERDICT in plain
|
||||
words — the overall picture, NOT a number ("Today reads like a
|
||||
recovery day", "A steady day, nothing demanding your attention").
|
||||
Then weave the 2-3 most salient signals into ONE story: lead from
|
||||
the single most salient and let the others be context — do NOT
|
||||
give every metric equal billing, and do NOT walk the signals one
|
||||
by one. Where several move together, say so as an ASSOCIATION
|
||||
("your shorter nights and a slightly higher resting heart rate
|
||||
this week tend to show up together"), NEVER as cause. Close on
|
||||
what it means for today and ONE doable step. Follow the OPENER
|
||||
HINT in the system context if one is given. You may state FEWER
|
||||
numbers than signals — the story matters more than the readout;
|
||||
every number you do state must come from this snapshot, never
|
||||
"people like you" or population norms. No diagnosis, no
|
||||
prescription; earned encouragement only; avoid the banned openers
|
||||
from rule 7.
|
||||
- signalsOfDay: 0-3 present-focused signals — the lead of the
|
||||
briefing. PREFER the snapshot's "signalsOfDay" block when it is
|
||||
present: each entry already carries the comparison finished for
|
||||
@@ -381,18 +452,24 @@ gib die obige Verweigerung zurück.`,
|
||||
JETZT; nutze die Historie als Kontext, aber der Nutzer öffnet das, um
|
||||
zu erfahren, was HEUTE passiert und welche eine Sache er dagegen tun
|
||||
kann. Felder:
|
||||
- paragraph: ein 80-200 Wörter langer Fließtext, den der Nutzer
|
||||
oben auf /insights liest. Eröffne mit dem frischesten, wichtigsten
|
||||
Signal — was seine neuesten Werte gegenüber seiner eigenen
|
||||
jüngsten Baseline tun — nicht mit einer Zusammenfassung des letzten
|
||||
Quartals. Benenne einen verdienten Erfolg, wo die Daten ihn zeigen,
|
||||
und mach aus Ungünstigem EINEN machbaren, konkreten nächsten
|
||||
Schritt für diese Woche ("dir fehlt diese Woche ~1h Schlaf — geh
|
||||
früher ins Bett", "dein systolischer Wert steigt — ein ruhigerer
|
||||
Abend und eine frühe Messung morgen würden viel sagen"). Keine
|
||||
Diagnose, keine Verschreibung. Nutze die EIGENEN Daten des Nutzers
|
||||
— nie "Menschen wie Sie" oder Bevölkerungsnormen. Nur verdiente
|
||||
Ermutigung; verwende keine in Regel 7 verbotenen Eröffnungen.
|
||||
- paragraph: ein zusammenhängender 70-160 Wörter langer Tages-Read,
|
||||
den der Nutzer oben auf /insights liest. SATZ 1 ist das URTEIL des
|
||||
Tages in klaren Worten — das Gesamtbild, KEINE Zahl ("Heute liest
|
||||
sich wie ein Erholungstag", "Ein ruhiger Tag, nichts, das deine
|
||||
Aufmerksamkeit braucht"). Dann verwebe die 2-3 wichtigsten Signale
|
||||
zu EINER Geschichte: führe vom einzeln wichtigsten und lass die
|
||||
anderen Kontext sein — gib NICHT jeder Metrik gleiches Gewicht und
|
||||
geh die Signale NICHT eins nach dem anderen durch. Wo sich mehrere
|
||||
zusammen bewegen, sag es als ZUSAMMENHANG ("deine kürzeren Nächte
|
||||
und ein leicht erhöhter Ruhepuls zeigen sich diese Woche oft
|
||||
zusammen"), NIE als Ursache. Schließe damit, was es heute bedeutet,
|
||||
und EINEM machbaren Schritt. Folge dem OPENER-HINWEIS im
|
||||
System-Kontext, wenn einer mitgegeben ist. Du darfst WENIGER Zahlen
|
||||
nennen als Signale — die Geschichte zählt mehr als die Auflistung;
|
||||
jede genannte Zahl stammt aus diesem Snapshot, nie "Menschen wie
|
||||
Sie" oder Bevölkerungsnormen. Keine Diagnose, keine Verschreibung;
|
||||
nur verdiente Ermutigung; verwende keine in Regel 7 verbotenen
|
||||
Eröffnungen.
|
||||
- signalsOfDay: 0-3 gegenwartsbezogene Signale — der Aufmacher des
|
||||
Briefings. BEVORZUGE den "signalsOfDay"-Block des Snapshots, wenn
|
||||
er vorhanden ist: jeder Eintrag trägt den Vergleich bereits fertig
|
||||
@@ -780,6 +857,13 @@ neutralem Label + Detail. Höchstgrenze: 20 Einträge.`,
|
||||
en: outlookContract.en,
|
||||
de: outlookContract.de,
|
||||
},
|
||||
// v1.22 (W6) — paragraph formatting so the briefing paragraph renders as real
|
||||
// paragraphs through the shared `ProseBlocks` helper.
|
||||
{
|
||||
id: "sharedFormatting",
|
||||
en: formattingContract.en,
|
||||
de: formattingContract.de,
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
en: `LANGUAGE
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v1.22 (W6) — deterministic opener-archetype rotation + hash helpers.
|
||||
*
|
||||
* The narrative weakness the maintainer reports ("every text is structured the
|
||||
* same way") is structural: the per-metric card, the briefing, and the score
|
||||
* card each open with the same shape every time. The fix is variety WITHOUT
|
||||
* randomness — a stable hash over a per-(user, metric, day) key picks one of a
|
||||
* small set of opener archetypes, so:
|
||||
* - consecutive cards / consecutive days open differently, and
|
||||
* - the choice is reproducible (unit-testable, no RNG, no cross-day state).
|
||||
*
|
||||
* The archetype is injected into the prompt as a one-line HINT, never a hard
|
||||
* template — the model still writes freely and every grounding verifier stays
|
||||
* untouched (this changes opener SHAPE, not which numbers are allowed). The
|
||||
* same hash also drives the sparse, anti-formulaic name personalization
|
||||
* (`shouldUseNameForTurn`) and a day-rotated sampling seed
|
||||
* (`dayRotatedSeed`) that replaces the fixed reference seed on the narrative
|
||||
* surfaces so weekly / monthly / score prose varies run-to-run.
|
||||
*
|
||||
* Pure + side-effect-free — it imports only the locale type and unit-tests in
|
||||
* isolation.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/** The opener archetypes, in a fixed order so the index map is stable. */
|
||||
export const OPENER_ARCHETYPES = [
|
||||
"VERDICT",
|
||||
"TREND",
|
||||
"CONTRAST",
|
||||
"CONTINUITY",
|
||||
"MEANING",
|
||||
] as const;
|
||||
|
||||
export type OpenerArchetype = (typeof OPENER_ARCHETYPES)[number];
|
||||
|
||||
/**
|
||||
* 32-bit FNV-1a hash of a string. Deterministic, dependency-free, and stable
|
||||
* across processes (no `Math.random`, no `Date.now`). Returns an unsigned
|
||||
* 32-bit integer so the modulo math below is sign-safe.
|
||||
*/
|
||||
export function hashSeedKey(key: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i);
|
||||
// h *= 16777619, kept in 32-bit space via the shift-add decomposition.
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one opener archetype for a stable key (`<userId>:<metric>:<dayKey>` or
|
||||
* `<userId>:<turnIndex>`). Same key → same archetype; different metric or day
|
||||
* → (usually) a different one, so cards and days don't all open the same way.
|
||||
*/
|
||||
export function pickOpenerArchetype(seedKey: string): OpenerArchetype {
|
||||
return OPENER_ARCHETYPES[hashSeedKey(seedKey) % OPENER_ARCHETYPES.length];
|
||||
}
|
||||
|
||||
/** The one-line opener HINT (not a template) injected into the user prompt. */
|
||||
const ARCHETYPE_HINT: Record<OpenerArchetype, Record<"en" | "de", string>> = {
|
||||
VERDICT: {
|
||||
en: "Open with the overall read in plain words, then bring in the number as support — not number-first.",
|
||||
de: "Eröffne mit dem Gesamteindruck in klaren Worten und bring die Zahl danach als Beleg — nicht zahlen-zuerst.",
|
||||
},
|
||||
TREND: {
|
||||
en: "Open with the direction of change over time, then say where it stands now.",
|
||||
de: "Eröffne mit der Richtung der Veränderung über die Zeit und nenne dann den aktuellen Stand.",
|
||||
},
|
||||
CONTRAST: {
|
||||
en: "Open by contrasting now against the user's own baseline.",
|
||||
de: "Eröffne mit dem Kontrast zwischen jetzt und der eigenen Baseline des Nutzers.",
|
||||
},
|
||||
CONTINUITY: {
|
||||
en: "Open by acknowledging steadiness or a streak, then the current value.",
|
||||
de: "Eröffne mit der Anerkennung von Konstanz oder einer Serie und dann dem aktuellen Wert.",
|
||||
},
|
||||
MEANING: {
|
||||
en: "Open with what this means for the user today, then the number behind it.",
|
||||
de: "Eröffne damit, was das heute für den Nutzer bedeutet, und dann die Zahl dahinter.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the opener-archetype hint line for a seed key + locale. EN/DE are
|
||||
* hand-composed; every other locale rides the EN hint (the hint is internal
|
||||
* scaffolding, not user-facing prose, so it does not need native wording).
|
||||
*/
|
||||
export function openerArchetypeHint(seedKey: string, locale: Locale): string {
|
||||
const archetype = pickOpenerArchetype(seedKey);
|
||||
const loc = locale === "de" ? "de" : "en";
|
||||
return ARCHETYPE_HINT[archetype][loc];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti-formulaic name gate. Returns true on roughly 1-in-3 keys, so the Coach /
|
||||
* briefing uses the user's first name occasionally and varied rather than as a
|
||||
* rote per-turn greeting. Deterministic per key (testable); the caller mixes
|
||||
* the turn index / day into the key so the cadence is unpredictable but stable.
|
||||
*/
|
||||
export function shouldUseNameForTurn(seedKey: string): boolean {
|
||||
return hashSeedKey(seedKey) % 3 === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a person's first name from a stored display name — the first
|
||||
* whitespace-delimited token, trimmed. Returns null for an empty / whitespace /
|
||||
* null input so the caller can omit the name clause entirely (never a blank
|
||||
* name). Kept here so the route and the briefing share one definition.
|
||||
*/
|
||||
export function firstNameFromDisplayName(
|
||||
displayName: string | null | undefined,
|
||||
): string | null {
|
||||
if (!displayName) return null;
|
||||
const token = displayName.trim().split(/\s+/)[0] ?? "";
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-rotated sampling seed. Replaces the fixed reference seed on the narrative
|
||||
* surfaces so the prose varies day-to-day while staying deterministic within a
|
||||
* day (so a test fixing the key + day gets reproducible output). Bounded to a
|
||||
* positive 31-bit integer, the safe range every seed-aware provider accepts.
|
||||
*/
|
||||
export function dayRotatedSeed(seedKey: string): number {
|
||||
return hashSeedKey(seedKey) % 2_000_000_000;
|
||||
}
|
||||
@@ -137,6 +137,20 @@ When it fits, end with a small forward beat so the user feels accompanied into t
|
||||
Schließe, wenn es passt, mit einem kleinen Ausblick, damit sich die Person in den nächsten Abschnitt begleitet fühlt. Drei Formen: (A) sanfte Prognose — "wenn das Tempo so bleibt, bist du in ein, zwei Wochen wieder gut in deinem üblichen Bereich" (Zahlen NUR, wenn ein "trajectory"-Block sie trägt, und dann als Spanne direkt daraus); (B) Was-zu-erwarten — normalisiere die übliche Kurve eines neuen Schritts ("die ersten ein, zwei Wochen nach einer Dosis-Umstellung sind oft die holprigsten — ein bisschen Auf und Ab ist die übliche Kurve, kein Rückschlag"), beschreibe die typische Kurve, sag nie einen Wert voraus; (C) vorausschauendes Wenn-dann — benenne den nächsten Prüfpunkt und deute beide Zweige vorab ("die Werte nächste Woche sind die spannenden: gehen sie zurück, war's ein Ausreißer; bleiben sie oben, ist das einen Austausch mit deinem Arzt wert"). Jeder Ausblick ist konditional ("wenn das so bleibt"), mit Spanne und als Zusammenhang gerahmt. NIE eine datierte Gewissheit, ein "du wirst…", eine erfundene Prognosezahl, ein Risiko-Score, eine Krankheitswahrscheinlichkeit oder eine Prognose, die einen Sicherheitsverweis aufweicht — ein sich verschlechternder Trend führt weiterhin zur ärztlichen Abklärung.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.22 (W6) — paragraph formatting contract. The prose surfaces render through
|
||||
* the shared `ProseBlocks` helper, which turns a blank line into a real
|
||||
* paragraph break — but only if the model emits one. This single fragment asks
|
||||
* for that structure on every narrative surface so a longer assessment reads as
|
||||
* 1–3 short paragraphs instead of one run-on block. It is whitespace, never
|
||||
* markup: the grounding verifiers operate on extracted numbers / causal verbs
|
||||
* and are whitespace-agnostic, so this changes nothing they grade.
|
||||
*/
|
||||
export const formattingContract: SharedContract = {
|
||||
en: `FORMATTING — write in 1–3 short paragraphs separated by a BLANK LINE, each 1–3 sentences. Put a blank line between distinct ideas (e.g. "where things stand" vs "one thing to try"). A steady one-liner stays a single paragraph — do NOT pad to fill a second. No markdown, no headings, no asterisks, no bullet points, no emojis.`,
|
||||
de: `FORMATIERUNG — schreibe in 1–3 kurzen Absätzen, getrennt durch eine LEERZEILE, je 1–3 Sätze. Setze eine Leerzeile zwischen unterschiedliche Gedanken (z. B. "wie es steht" vs. "eine Sache zum Ausprobieren"). Ein stabiler Einzeiler bleibt EIN Absatz — strecke nicht auf einen zweiten. Kein Markdown, keine Überschriften, keine Sternchen, keine Aufzählungen, keine Emojis.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* The full set, keyed by name — consumed by surfaces and by the
|
||||
* cross-surface coverage test.
|
||||
@@ -149,6 +163,7 @@ export const SHARED_CONTRACTS = {
|
||||
metricIdentifierBan,
|
||||
forbiddenFiller,
|
||||
outlookContract,
|
||||
formattingContract,
|
||||
} as const satisfies Record<string, SharedContract>;
|
||||
|
||||
export type SharedContractName = keyof typeof SHARED_CONTRACTS;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FeaturesPayloadTooLargeError,
|
||||
BRIEFING_FEATURE_WINDOW_DAYS,
|
||||
type SignalOfDay,
|
||||
type AggregatedFeatures,
|
||||
} from "@/lib/insights/features";
|
||||
import {
|
||||
findUngroundedBriefingNumbers,
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildSystemPromptWithReferences,
|
||||
buildBriefingPersonalisationBlock,
|
||||
} from "@/lib/ai/prompts/insight-generator";
|
||||
import { buildRetryCorrectionMessage } from "@/lib/ai/generate-insight";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import {
|
||||
@@ -190,6 +194,12 @@ async function rerollBriefingParagraph(args: {
|
||||
* it does not trace to one of these figures.
|
||||
*/
|
||||
signals: readonly SignalOfDay[] | null;
|
||||
/**
|
||||
* v1.22 (W6, W8 seam) — the full feature snapshot so the grounding gate also
|
||||
* admits numbers from the W8 aggregate blocks (glucose / labs / preventive-
|
||||
* care / workouts) the reroll may now cite.
|
||||
*/
|
||||
features?: AggregatedFeatures | null;
|
||||
}): Promise<{ text: string; providerType: string } | null> {
|
||||
let cached: Record<string, unknown>;
|
||||
try {
|
||||
@@ -260,6 +270,7 @@ async function rerollBriefingParagraph(args: {
|
||||
const rerollUngrounded = findUngroundedBriefingNumbers(
|
||||
{ paragraph: freshParagraph },
|
||||
args.signals,
|
||||
args.features,
|
||||
);
|
||||
if (rerollUngrounded.length > 0) {
|
||||
return null;
|
||||
@@ -717,6 +728,8 @@ export async function generateComprehensiveInsight(
|
||||
dashboardWidgetsJson: true,
|
||||
// v1.18.11 P5 — gender feeds the cycle context block (phase contrast).
|
||||
gender: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -881,6 +894,16 @@ export async function generateComprehensiveInsight(
|
||||
if (illnessCycleCtx) {
|
||||
userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale);
|
||||
}
|
||||
// v1.22 (W6) — opener-archetype rotation + sparse first-name personalization.
|
||||
// Both are deterministic per (user, day): the opener hint varies the briefing
|
||||
// lead day-over-day, and the name appears on roughly one day in three (never a
|
||||
// rote daily "Good morning, <name>"). The whole block is omitted when no
|
||||
// display name is set, so unnamed / demo accounts are byte-identical.
|
||||
userPrompt += buildBriefingPersonalisationBlock(
|
||||
userId,
|
||||
dbUser?.displayName ?? null,
|
||||
locale,
|
||||
);
|
||||
const systemPrompt = buildSystemPromptWithReferences(
|
||||
locale,
|
||||
referenceMetrics,
|
||||
@@ -906,6 +929,7 @@ export async function generateComprehensiveInsight(
|
||||
cachedText: dbUser.insightsCachedText,
|
||||
locale,
|
||||
signals: features.signalsOfDay ?? null,
|
||||
features,
|
||||
});
|
||||
if (rerolled) {
|
||||
await prisma.user.update({
|
||||
@@ -1051,6 +1075,7 @@ export async function generateComprehensiveInsight(
|
||||
let ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(insights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (ungrounded.length > 0) {
|
||||
annotate({
|
||||
@@ -1075,6 +1100,7 @@ export async function generateComprehensiveInsight(
|
||||
const retryUngrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(retryInsights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (retryInsights !== null && retryUngrounded.length === 0) {
|
||||
insights = retryInsights;
|
||||
|
||||
@@ -206,6 +206,44 @@ describe("resolveDeterministicAssessment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.22 (W6) — the deterministic fallback now closes with a band-conditioned
|
||||
// "what it means for today" read (a closed table; no number introduced), so the
|
||||
// provider-less + demo score cards carry the forward interpretation too.
|
||||
describe("band → meaning enrichment (deterministic fallback)", () => {
|
||||
it("appends the forward read for a yellow readiness (en)", () => {
|
||||
const a = resolveDeterministicAssessment(
|
||||
"READINESS",
|
||||
okDerived(readiness),
|
||||
"en",
|
||||
NOW,
|
||||
);
|
||||
expect(a!.text).toContain("usual load");
|
||||
});
|
||||
|
||||
it("appends the forward read for a green sleep score (en)", () => {
|
||||
const a = resolveDeterministicAssessment(
|
||||
"SLEEP_SCORE",
|
||||
okDerived(sleepScore),
|
||||
"en",
|
||||
NOW,
|
||||
);
|
||||
expect(a!.text.toLowerCase()).toContain("carry you well");
|
||||
});
|
||||
|
||||
it("names BOTH a carry (green) AND the forward read (de)", () => {
|
||||
const a = resolveDeterministicAssessment(
|
||||
"SLEEP_SCORE",
|
||||
okDerived(sleepScore),
|
||||
"de",
|
||||
NOW,
|
||||
);
|
||||
// green branch names the strongest contributor as the carry …
|
||||
expect(a!.text).toContain("Getragen vor allem von");
|
||||
// … and the closed band-meaning table appends the forward read.
|
||||
expect(a!.text).toContain("durch den Tag tragen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScoreSignal", () => {
|
||||
it("carries contributors for READINESS", () => {
|
||||
const signal = buildScoreSignal("READINESS", readiness, "en");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* v1.22 (W6) — the score-card number-grounding gate. The only numbers the AI
|
||||
* score prose may state are the score and its contributor values; anything else
|
||||
* is flagged so the caller falls back to the deterministic text.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { findUngroundedScoreNumbers } from "../score-grounding";
|
||||
import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
|
||||
function signal(partial: Partial<MetricSignal>): MetricSignal {
|
||||
return {
|
||||
current: 64,
|
||||
delta: -4,
|
||||
contributors: [
|
||||
{ key: "sufficiency", value: 55, weight: 0.4 },
|
||||
{ key: "efficiency", value: 88, weight: 0.3 },
|
||||
],
|
||||
...partial,
|
||||
} as MetricSignal;
|
||||
}
|
||||
|
||||
describe("findUngroundedScoreNumbers", () => {
|
||||
it("passes a prose that only cites the score, delta and contributors", () => {
|
||||
const s = signal({});
|
||||
const prose =
|
||||
"Your sleep score landed at 64 today, down 4 from your recent nights. " +
|
||||
"Duration (55) weighed on it while efficiency (88) held up well.";
|
||||
expect(findUngroundedScoreNumbers(prose, s)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows the 0-100 denominator and small ordinals", () => {
|
||||
const s = signal({});
|
||||
const prose =
|
||||
"Your score is 64 out of 100 — the 2 things to watch are clear.";
|
||||
expect(findUngroundedScoreNumbers(prose, s)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a fabricated figure not present on the signal", () => {
|
||||
const s = signal({});
|
||||
// 72 is not the score, delta, or any contributor value.
|
||||
const found = findUngroundedScoreNumbers(
|
||||
"Your score is 64, and your HRV of 72 ms pulled it down.",
|
||||
s,
|
||||
);
|
||||
expect(found.length).toBe(1);
|
||||
expect(found[0].value).toBe(72);
|
||||
});
|
||||
|
||||
it("rounds the score for a restatement (64.4 → 64)", () => {
|
||||
const s = signal({ current: 64.4 });
|
||||
expect(findUngroundedScoreNumbers("Your score is 64 today.", s)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty for empty prose", () => {
|
||||
expect(findUngroundedScoreNumbers("", signal({}))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,18 @@ import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
statusCacheAction,
|
||||
computeStatusInputFingerprint,
|
||||
gateUnchangedStatusInput,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
openerArchetypeHint,
|
||||
dayRotatedSeed,
|
||||
} from "@/lib/ai/prompts/opener-archetype";
|
||||
import { findUngroundedScoreNumbers } from "./score-grounding";
|
||||
import {
|
||||
buildScoreSignal,
|
||||
isAssessableDerivedScore,
|
||||
@@ -63,22 +72,37 @@ export function parseDerivedScoreScope(scope: string): DerivedMetricId | null {
|
||||
return scope.slice("derived-score:".length) as DerivedMetricId;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W6) — the contributor source metrics every assessable score derives
|
||||
* from. A conservative superset across READINESS / SLEEP_SCORE / RECOVERY /
|
||||
* STRESS / STRAIN so the input-hash gate regenerates whenever any driver moved
|
||||
* and only skips on a genuinely quiet day. Mood joins via `includeMood`.
|
||||
*/
|
||||
const SCORE_INPUT_TYPES: readonly MeasurementType[] = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"SLEEP_DURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
] as MeasurementType[];
|
||||
|
||||
// ── prompt ────────────────────────────────────────────────────────────────
|
||||
|
||||
function scoreSystemPrompt(locale: SupportedLocale): string {
|
||||
const base = getBaseSystemPrompt(locale);
|
||||
const section =
|
||||
locale === "en"
|
||||
? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well):
|
||||
- This is a 0–100 composite score, not a raw measurement. Explain WHY it sits where it does by naming the 1–2 contributors in \`signal.contributors[]\` that moved it most — a lower contributor value drags the score down. Each contributor is itself 0–100.
|
||||
- Lead with the score and its standing, then attribute it to the contributor(s). Do not invent a contributor that is not listed. When there are no contributors, use the trend (signal.delta) instead.
|
||||
- Warm and motivating: on a strong score, name the win and what it sets them up to do today; on a softer score, frame it as a recovery cue and one small thing within reach — supportive, never deflating. The encouragement is earned by the contributors / trend, never a reflexive compliment, and never bare number-echoing.
|
||||
- This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic, never alarming.`
|
||||
: `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE (schreibe wie ein Premium-Recovery-Coach — WHOOP / Oura —, der dieser Person echten Erfolg wünscht):
|
||||
- Das ist ein 0–100-Kompositscore, keine Rohmessung. Erkläre, WARUM er dort liegt, indem du die 1–2 Beiträge aus \`signal.contributors[]\` nennst, die ihn am stärksten bewegt haben — ein niedriger Beitragswert zieht den Score nach unten. Jeder Beitrag ist selbst 0–100.
|
||||
- Führe mit dem Score und seiner Einordnung, dann ordne ihn den Beiträgen zu. Erfinde keinen Beitrag, der nicht gelistet ist. Gibt es keine Beiträge, nutze stattdessen den Trend (signal.delta).
|
||||
- Warm und motivierend: bei einem starken Score den Erfolg benennen und was er heute ermöglicht; bei einem schwächeren Score als Erholungshinweis einordnen und eine kleine, erreichbare Sache nennen — unterstützend, nie entmutigend. Die Ermutigung ist durch die Beiträge / den Trend verdient, nie ein reflexhaftes Kompliment und nie bloße Zahlenwiederholung.
|
||||
- Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch, nie alarmierend.`;
|
||||
? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well). This is a 0–100 composite, not a raw measurement. Weave these FOUR beats into varied, connected prose (NOT a fixed template, NOT a labelled list) — lead per the OPENER HINT if one is given:
|
||||
- STANDING — the score and its band, in one short clause.
|
||||
- WHAT DROVE IT — name the contributor(s) in \`signal.contributors[]\` that HELPED (the strongest, highest values) AND the one(s) that HURT (the weakest, lowest values), so a good score earns its win and a soft score is explained honestly. Each contributor is itself 0–100; a low value drags the score down, a high one carries it. Never invent a contributor that is not listed. When none are listed, use the trend (signal.delta) instead.
|
||||
- WHAT IT MEANS FOR TODAY — translate the band into a forward read: a strong readiness/recovery score → a good day to take on more / push a little; a soft score → a lighter day would serve you, treat it as a recovery cue. This is a band-conditioned interpretation, NOT a new number — never invent a figure for it.
|
||||
- ONE NUDGE — close with the single grounded next step keyed to the weakest behaviourally addressable contributor. If nothing is behaviourally addressable (the soft driver is physiology only), affirm and name one thing to watch instead of manufacturing a step.
|
||||
This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic, never alarming; the encouragement is earned by the contributors / trend, never a reflexive compliment, never bare number-echoing.`
|
||||
: `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE (schreibe wie ein Premium-Recovery-Coach — WHOOP / Oura —, der dieser Person echten Erfolg wünscht). Das ist ein 0–100-Komposit, keine Rohmessung. Verwebe diese VIER Beats zu abwechslungsreicher, zusammenhängender Prosa (KEINE feste Vorlage, KEINE beschriftete Liste) — führe gemäß dem OPENER-HINWEIS, wenn einer mitgegeben ist:
|
||||
- EINORDNUNG — der Score und sein Band, in einer kurzen Wendung.
|
||||
- WAS IHN TRIEB — benenne die Beiträge aus \`signal.contributors[]\`, die GEHOLFEN haben (die stärksten, höchsten Werte) UND die, die GEBREMST haben (die schwächsten, niedrigsten), damit ein guter Score seinen Erfolg verdient und ein schwacher ehrlich erklärt wird. Jeder Beitrag ist selbst 0–100; ein niedriger Wert zieht den Score nach unten, ein hoher trägt ihn. Erfinde keinen Beitrag, der nicht gelistet ist. Gibt es keine, nutze den Trend (signal.delta).
|
||||
- WAS ES HEUTE BEDEUTET — übersetze das Band in einen Ausblick: ein starker Tagesform-/Erholungs-Score → ein guter Tag, um mehr zu wagen / etwas zu pushen; ein schwacher Score → ein ruhigerer Tag tut gut, nimm ihn als Erholungshinweis. Das ist eine band-bedingte Deutung, KEINE neue Zahl — erfinde dafür keine Zahl.
|
||||
- EIN ANSTOSS — schließe mit dem einen gegroundeten nächsten Schritt am schwächsten verhaltens-adressierbaren Beitrag. Ist nichts verhaltens-adressierbar (der schwache Treiber ist reine Physiologie), bestätige und nenne einen Punkt zum Beobachten, statt einen Schritt zu erfinden.
|
||||
Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch, nie alarmierend; die Ermutigung ist durch die Beiträge / den Trend verdient, nie ein reflexhaftes Kompliment und nie bloße Zahlenwiederholung.`;
|
||||
return `${base}\n\n${section}`;
|
||||
}
|
||||
|
||||
@@ -87,6 +111,7 @@ function scoreUserPrompt(
|
||||
band: string,
|
||||
todayKey: string,
|
||||
locale: SupportedLocale,
|
||||
openerHint: string,
|
||||
): string {
|
||||
const snapshot = JSON.stringify(
|
||||
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
|
||||
@@ -95,12 +120,14 @@ function scoreUserPrompt(
|
||||
);
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Write one short assessment of why ${signal.metric} is the score it is today: name the score and its standing, then attribute it to the 1–2 contributors that moved it most. Keep it to 2–3 sentences, descriptive only.
|
||||
OPENER HINT: ${openerHint}
|
||||
Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist.
|
||||
|
||||
${snapshot}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Schreibe eine kurze Einschätzung, warum ${signal.metric} heute diesen Score hat: nenne den Score und seine Einordnung und ordne ihn dann den 1–2 stärksten Beiträgen zu. Halte dich an 2–3 Sätze, rein beschreibend.
|
||||
OPENER-HINWEIS: ${openerHint}
|
||||
Schreibe eine Einschätzung zu ${signal.metric} heute über die vier Beats — die Einordnung, was geholfen UND was gebremst hat, was das Band für den Tag bedeutet und ein gegroundeter Anstoß. Ziel sind 3–5 Sätze, rund 45–75 Wörter (das übersteuert die kürzere Basis-Längenvorgabe). Zusammenhängende Prosa, keine Checkliste.
|
||||
|
||||
${snapshot}`;
|
||||
}
|
||||
@@ -190,13 +217,48 @@ export async function generateDerivedScoreAssessment(args: {
|
||||
const cacheAction = statusCacheAction(scope, locale);
|
||||
const todayKey = toBerlinDayKey(now);
|
||||
|
||||
// v1.22 (W6) — opener-archetype rotation + a day-rotated seed so a score
|
||||
// assessment varies across scores and across days instead of riding the
|
||||
// fixed reference seed. The key is per (user, score, day).
|
||||
const seedKey = `${args.userId}:${scope}:${todayKey}`;
|
||||
const openerHint = openerArchetypeHint(seedKey, locale as Locale);
|
||||
|
||||
// v1.22 (W6) — score-warm input-hash gate. A score is re-warmed daily even on
|
||||
// a day with no new wearable data; this skips the LLM and re-stamps the cached
|
||||
// text when none of the contributor sources (sleep / HRV / RHR / respiratory /
|
||||
// mood) changed. Conservative superset → never staler than the data; a genuine
|
||||
// input change still flips the hash and regenerates. Reuses the same audit-log
|
||||
// `inputHash` column the per-metric status cards use (no new persistence).
|
||||
const inputHash = await computeStatusInputFingerprint({
|
||||
userId: args.userId,
|
||||
types: SCORE_INPUT_TYPES,
|
||||
includeMood: true,
|
||||
});
|
||||
const unchangedInput = await gateUnchangedStatusInput({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
inputHash,
|
||||
force: false,
|
||||
});
|
||||
if (unchangedInput) {
|
||||
annotate({
|
||||
action: { name: "insights.derived-assessment.input_unchanged" },
|
||||
meta: { metric: args.metric },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await runStatusCompletion({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: scoreSystemPrompt(locale),
|
||||
userPrompt: scoreUserPrompt(signal, band, todayKey, locale),
|
||||
temperature: 0.45,
|
||||
userPrompt: scoreUserPrompt(signal, band, todayKey, locale, openerHint),
|
||||
// v1.22 (W6) — nudged up from 0.45 for variety; the grounding gate below
|
||||
// catches any number the warmer sampling might drift onto.
|
||||
temperature: 0.55,
|
||||
seed: dayRotatedSeed(seedKey),
|
||||
maxTokens: 600,
|
||||
});
|
||||
|
||||
@@ -211,6 +273,24 @@ export async function generateDerivedScoreAssessment(args: {
|
||||
const text = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!text) return;
|
||||
|
||||
// v1.22 (W6) — score number-grounding gate. The only numbers the prose may
|
||||
// state are the score and its contributor values. On a miss we do NOT persist
|
||||
// the AI text — the route keeps serving the always-grounded deterministic
|
||||
// fallback, so the field stays non-empty and never carries a fabricated
|
||||
// figure. Non-blocking by construction (write nothing = fall back).
|
||||
const ungrounded = findUngroundedScoreNumbers(text, signal);
|
||||
if (ungrounded.length > 0) {
|
||||
annotate({
|
||||
action: { name: "insights.derived-assessment.ungrounded" },
|
||||
meta: {
|
||||
metric: args.metric,
|
||||
count: ungrounded.length,
|
||||
sample: ungrounded[0]?.source,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await persistStatusInsight({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
@@ -220,6 +300,9 @@ export async function generateDerivedScoreAssessment(args: {
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
// v1.22 (W6) — store the input fingerprint so the next day's gate can skip
|
||||
// the warm when no contributor source changed.
|
||||
inputHash,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "insights.derived-assessment.warmed" },
|
||||
|
||||
@@ -282,6 +282,102 @@ function contributorPointer(key: string, locale: Locale): string | null {
|
||||
return CONTRIBUTOR_POINTERS[key]?.[locale] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W6) — band → "what it means for today" interpretation. A CLOSED
|
||||
* deterministic table (so the read can never hallucinate a verdict) keyed by
|
||||
* metric + band, mirroring the WHOOP/Oura forward read: a strong readiness /
|
||||
* recovery / sleep score is a day to take on a little more; a soft one is a
|
||||
* recovery cue. For strain/stress the bands already encode favourable→green, so
|
||||
* green reads as "in a sustainable place" and red as "ease off". This is a
|
||||
* band-conditioned interpretation, never a new number, so it carries no figure
|
||||
* the score-grounding gate could flag.
|
||||
*/
|
||||
const SCORE_BAND_MEANING: Partial<
|
||||
Record<
|
||||
DerivedMetricId,
|
||||
Record<"green" | "yellow" | "red", { de: string; en: string }>
|
||||
>
|
||||
> = {
|
||||
READINESS: {
|
||||
green: {
|
||||
de: "Heute ist ein guter Tag, um etwas mehr zu wagen.",
|
||||
en: "Today reads like a good day to take on a little more.",
|
||||
},
|
||||
yellow: {
|
||||
de: "Ein mittlerer Tag — gut für Gewohntes, ohne zu überziehen.",
|
||||
en: "A middling day — fine for your usual load without overreaching.",
|
||||
},
|
||||
red: {
|
||||
de: "Ein ruhigerer Tag würde dir guttun — nimm es als Erholungshinweis.",
|
||||
en: "A lighter day would serve you — take it as a recovery cue.",
|
||||
},
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
green: {
|
||||
de: "Dein Körper wirkt erholt — Raum, heute etwas mehr zu geben.",
|
||||
en: "Your body reads recovered — room to give a little more today.",
|
||||
},
|
||||
yellow: {
|
||||
de: "Teilweise erholt — ein gewohnter Tag passt, kein harter Push.",
|
||||
en: "Partly recovered — a normal day fits, nothing too hard.",
|
||||
},
|
||||
red: {
|
||||
de: "Noch nicht erholt — heute eher schonen als pushen.",
|
||||
en: "Not yet recovered — favour easing off over pushing today.",
|
||||
},
|
||||
},
|
||||
SLEEP_SCORE: {
|
||||
green: {
|
||||
de: "Eine Nacht, die dich gut durch den Tag tragen sollte.",
|
||||
en: "A night that should carry you well through the day.",
|
||||
},
|
||||
yellow: {
|
||||
de: "Eine durchwachsene Nacht — heute etwas bewusster mit deiner Energie.",
|
||||
en: "A mixed night — be a little deliberate with your energy today.",
|
||||
},
|
||||
red: {
|
||||
de: "Eine kurze Nacht — plane heute bewusst Erholung ein.",
|
||||
en: "A short night — build a little recovery into today on purpose.",
|
||||
},
|
||||
},
|
||||
STRAIN_SCORE: {
|
||||
green: {
|
||||
de: "Deine Belastung liegt in einem gut tragbaren Bereich.",
|
||||
en: "Your load is sitting in a comfortably sustainable place.",
|
||||
},
|
||||
yellow: {
|
||||
de: "Eine spürbare Belastung — achte heute auf genug Erholung.",
|
||||
en: "A noticeable load — keep an eye on recovery today.",
|
||||
},
|
||||
red: {
|
||||
de: "Eine hohe Belastung — heute zählt Erholung mehr als noch mehr.",
|
||||
en: "A high load — recovery matters more than more today.",
|
||||
},
|
||||
},
|
||||
STRESS_SCORE: {
|
||||
green: {
|
||||
de: "Dein Stress liegt niedrig — eine gute Basis für den Tag.",
|
||||
en: "Your stress is sitting low — a good base for the day.",
|
||||
},
|
||||
yellow: {
|
||||
de: "Etwas erhöhter Stress — ein ruhiger Moment kann heute helfen.",
|
||||
en: "Slightly raised stress — a calm moment could help today.",
|
||||
},
|
||||
red: {
|
||||
de: "Erhöhter Stress — ein bewusst ruhigerer Tag würde dir guttun.",
|
||||
en: "Raised stress — a deliberately calmer day would serve you.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function scoreBandMeaning(
|
||||
metric: DerivedMetricId,
|
||||
band: "green" | "yellow" | "red",
|
||||
locale: Locale,
|
||||
): string | null {
|
||||
return SCORE_BAND_MEANING[metric]?.[band]?.[locale] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the deterministic per-score assessment from the score's signal +
|
||||
* its contributors. Always returns a non-empty, factual, non-causal text.
|
||||
@@ -358,6 +454,13 @@ export function buildDeterministicScoreAssessment(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.22 (W6) — close with a band-conditioned "what it means for today" read
|
||||
// from the closed deterministic table, so even the provider-less + demo score
|
||||
// cards carry the forward interpretation the WHOOP/Oura voice does, not just
|
||||
// the attribution. No number is introduced, so the grounding posture holds.
|
||||
const meaning = scoreBandMeaning(metric, band, locale);
|
||||
if (meaning) sentences.push(meaning);
|
||||
|
||||
return sentences.join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* v1.22 (W6) — number-grounding gate for the AI-warmed SCORE assessment.
|
||||
*
|
||||
* The per-score AI text (`derived-assessment-ai.ts`) rode the base-system
|
||||
* prompt but had NO post-generation number verifier — unlike the briefing
|
||||
* (`briefing-grounding.ts`) and the Coach (`coach-prose-grounding.ts`). The
|
||||
* v1.22 score rewrite enriches the prose (a band→meaning interpretation, both
|
||||
* the helped + hurt contributors), which is exactly when a fabricated figure
|
||||
* could slip in. This gate closes that: the only numbers the score prose may
|
||||
* state are the score itself and its contributor values (each a 0–100), all
|
||||
* already present on the `MetricSignal`. A miss does NOT block — the caller
|
||||
* falls back to the always-grounded deterministic text, keeping the locked iOS
|
||||
* `assessment: {text, source, updatedAt}` contract non-empty.
|
||||
*
|
||||
* It grades NUMBERS only (reusing the briefing verifier's `extractNumbers` +
|
||||
* tolerance basis); tone / framing stay the prompt's job. The band→meaning
|
||||
* mapping carries no number, so it cannot be flagged here — and because that
|
||||
* mapping is a closed deterministic table in the deterministic fallback, the
|
||||
* interpretation can never hallucinate a verdict either.
|
||||
*/
|
||||
import { extractNumbers } from "@/lib/ai/briefing-grounding";
|
||||
import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
|
||||
/** Same tolerance basis as the briefing + Coach verifiers. */
|
||||
const ABS_TOLERANCE = 0.15;
|
||||
const REL_TOLERANCE = 0.02;
|
||||
|
||||
/** Window lengths + small ordinals the prose uses structurally. */
|
||||
const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 100, 365]);
|
||||
|
||||
function isStructural(value: number, raw: string): boolean {
|
||||
if (raw.includes(".") || raw.includes(",")) return false;
|
||||
if (!Number.isInteger(value)) return false;
|
||||
const abs = Math.abs(value);
|
||||
if (abs <= 3) return true;
|
||||
// 100 is the score denominator ("64 out of 100"); always allowed.
|
||||
return STRUCTURAL_INTEGERS.has(abs);
|
||||
}
|
||||
|
||||
/** One score-prose number that traces to no authoritative score figure. */
|
||||
export interface UngroundedScoreNumber {
|
||||
value: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authoritative magnitudes a score assessment may state: the score itself,
|
||||
* its signed/absolute delta, and every contributor value (each 0–100). Both
|
||||
* the signed and absolute forms are kept so a "+4" delta restatement matches.
|
||||
*/
|
||||
function authoritativeScoreValues(signal: MetricSignal): number[] {
|
||||
const values: number[] = [];
|
||||
const push = (n: number | null | undefined) => {
|
||||
if (typeof n === "number" && Number.isFinite(n)) {
|
||||
values.push(n);
|
||||
values.push(Math.round(n));
|
||||
values.push(Math.abs(n));
|
||||
}
|
||||
};
|
||||
push(signal.current);
|
||||
push(signal.delta);
|
||||
for (const c of signal.contributors ?? []) push(c.value);
|
||||
return values;
|
||||
}
|
||||
|
||||
function isGrounded(value: number, authoritative: readonly number[]): boolean {
|
||||
for (const a of authoritative) {
|
||||
const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE);
|
||||
if (Math.abs(value - a) <= tol) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every number the score prose asserts that does not trace to the score or
|
||||
* one of its contributor values. Empty array when the prose is empty or every
|
||||
* number is grounded. The caller treats a non-empty result as "fall back to the
|
||||
* deterministic text" — never a hard block.
|
||||
*/
|
||||
export function findUngroundedScoreNumbers(
|
||||
summary: string,
|
||||
signal: MetricSignal,
|
||||
): UngroundedScoreNumber[] {
|
||||
if (!summary) return [];
|
||||
const authoritative = authoritativeScoreValues(signal);
|
||||
const findings: UngroundedScoreNumber[] = [];
|
||||
for (const { value, raw } of extractNumbers(summary)) {
|
||||
if (isStructural(value, raw)) continue;
|
||||
if (isGrounded(value, authoritative)) continue;
|
||||
findings.push({ value, source: raw.slice(0, 32) });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
@@ -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.11.0");
|
||||
expect(NARRATIVE_PROMPT_VERSION).toBe("1.12.0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
|
||||
import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
buildPeriodNarrativeContext,
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
* prompt below changes so the cross-feature attribution aggregator can slice
|
||||
* quality per (provider × prompt) and a prompt change is observable.
|
||||
*/
|
||||
export const NARRATIVE_PROMPT_VERSION = "1.11.0" as const;
|
||||
export const NARRATIVE_PROMPT_VERSION = "1.12.0" as const;
|
||||
|
||||
/** A narrative cached this recently is served without regenerating. */
|
||||
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
|
||||
@@ -78,6 +79,13 @@ interface GenerateOptions {
|
||||
runCompletion?: typeof runStatusCompletion;
|
||||
/** Injected for tests — the shared client by default. */
|
||||
prisma?: PrismaClient;
|
||||
/**
|
||||
* v1.22 (W6) — pin the deterministic `REFERENCE_AI_SEED` instead of the
|
||||
* day-rotated production seed. Used ONLY by the cross-feature attribution /
|
||||
* diff baseline (and tests) that need byte-stable output; real users generate
|
||||
* with a day-rotated seed so weekly / monthly narratives vary run-to-run.
|
||||
*/
|
||||
referenceSeed?: boolean;
|
||||
}
|
||||
|
||||
/** The labels-only provenance the surface renders as ⓘ chips. */
|
||||
@@ -105,10 +113,11 @@ Hard rules:
|
||||
- Be DESCRIPTIVE, never CAUSAL. Say "X moved with Y" or "X was associated with Y", never "X caused Y" or "because of X".
|
||||
- The listed drivers already survived statistical multiple-comparison control; restate them only as associations and keep their conservative meaning.
|
||||
- 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.
|
||||
|
||||
${composeSharedContracts("en", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
${composeSharedContracts("en", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
|
||||
|
||||
const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen.
|
||||
Prompt-Version: ${NARRATIVE_PROMPT_VERSION}.
|
||||
@@ -118,10 +127,11 @@ Feste Regeln:
|
||||
- Sei BESCHREIBEND, nie URSÄCHLICH. Sage "X bewegte sich mit Y" oder "X war mit Y assoziiert", nie "X verursachte Y" oder "wegen X".
|
||||
- Die genannten Zusammenhänge haben bereits die statistische Mehrfachvergleichskorrektur überstanden; gib sie nur als Assoziationen wieder und bewahre ihre vorsichtige Bedeutung.
|
||||
- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein"). Wenn der Zeitraum es hergibt, benenne einen echten Erfolg klar; bleib warm und in der zweiten Person, nie ein reflexhaftes Kompliment.
|
||||
- FORM: Führe mit dem Ein-Satz-Urteil des Zeitraums (das Gesamtbild in klaren Worten), verbinde dann die stärksten Bewegungen und die belegten Zusammenhänge zu einem kurzen Bogen und schließe mit Konstanz oder einem Punkt zum Beobachten — Synthese, nicht jede Metrik nacheinander auflisten.
|
||||
- 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis.
|
||||
- Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden.
|
||||
|
||||
${composeSharedContracts("de", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
${composeSharedContracts("de", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
|
||||
|
||||
/**
|
||||
* Test-only view of the composed system prompts (incl. the appended shared
|
||||
@@ -283,9 +293,13 @@ export async function generatePeriodNarrative(
|
||||
userPrompt: buildNarrativeUserPrompt(context, locale),
|
||||
temperature: AI_BUDGETS.narrative.temperature,
|
||||
maxTokens: AI_BUDGETS.narrative.maxTokens,
|
||||
// v1.18.7 — reference surface: pin a deterministic seed so a prompt
|
||||
// change is diff-able against a stable baseline (MEDIUM-4).
|
||||
seed: REFERENCE_AI_SEED,
|
||||
// v1.18.7 / v1.22 (W6) — the reference/diff path pins the deterministic
|
||||
// seed (byte-stable for attribution); real users get a day-rotated seed so
|
||||
// the weekly / monthly narrative varies run-to-run instead of reading
|
||||
// near-identical every time (Root cause D — the fixed seed in prod).
|
||||
seed: options.referenceSeed
|
||||
? REFERENCE_AI_SEED
|
||||
: dayRotatedSeed(`${userId}:${period}:${dateKeyFor(now, tz)}`),
|
||||
// The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress
|
||||
// the JSON-mode opt-in the status cards default into.
|
||||
responseFormat: "text",
|
||||
|
||||
Reference in new issue
Block a user