diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 4c2a53dd8..2b2a4a01e 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -15,7 +15,7 @@ * values with weekday labels and aggregates the older window into * weekly buckets so the prompt budget stays tight. */ -import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; +import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances"; import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; import type { CoachDataCluster } from "@/lib/validations/coach-prefs"; @@ -807,7 +807,7 @@ async function buildCoachSnapshotImpl( sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null, userTz, ageYears: derivedProfile.ageYears, - freeWeekdaySpans: await loadFreeWeekdaySpans(userId), + freeWeekdaySpans: await loadFreeWeekdaySpansForPrompt(userId), snapshot, metrics, registerBlock, diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index 7eea2ff8a..bab7ee582 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -47,6 +47,7 @@ import { computePackYears, DEFAULT_FREE_WEEKDAYS, isReviewDue, + LIFE_KIND_AI_SECTION, screeningHints, type FreeWeekdaysSpan, type PackYears, @@ -214,6 +215,42 @@ export async function loadFreeWeekdaySpans( } } +/** + * The free-weekday answers for a prompt: only when the section they belong + * to (`WORK`) is released to the AI. Otherwise the answers are never read or + * decrypted for a prompt, and the night-by-night day type falls back to the + * weekend default, exactly as for a record that never answered. The data + * surfaces (the sleep page, the dashboard) read `loadFreeWeekdaySpans` + * directly: they show the person their own answer, no model sees it. + */ +export async function loadFreeWeekdaySpansForPrompt( + userId: string, +): Promise { + // Fail-soft and fail-closed, like the read below: a failed switch read + // gives the weekend default and never the answers. + try { + const profile = await prisma.userHealthProfile.findUnique({ + where: { userId }, + select: { aiIncludedSections: true }, + }); + if ( + !storedSections(profile?.aiIncludedSections).has( + LIFE_KIND_AI_SECTION.FREE_WEEKDAYS, + ) + ) { + return []; + } + } catch (error) { + getEvent()?.addWarning( + `free weekdays switch read failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return []; + } + return loadFreeWeekdaySpans(userId); +} + /** Assemble the whole read model for one record. */ export async function buildLifeCircumstances( userId: string, diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts index 46fe9c8de..9c1cd8d12 100644 --- a/tests/integration/life-circumstances.test.ts +++ b/tests/integration/life-circumstances.test.ts @@ -297,6 +297,34 @@ describe("life-circumstance facts", () => { }); }); +describe("free weekdays in a prompt", () => { + it("are read for a prompt only while the work section is released", async () => { + const user = await makeUser(); + await signIn(user.id); + await data( + await postFact({ kind: "FREE_WEEKDAYS", value: VALUES.FREE_WEEKDAYS }), + 201, + ); + const { loadFreeWeekdaySpans, loadFreeWeekdaySpansForPrompt } = + await import("@/lib/profile/life-circumstances"); + + await getPrismaClient().userHealthProfile.upsert({ + where: { userId: user.id }, + create: { userId: user.id, aiIncludedSections: ["WORK"] }, + update: { aiIncludedSections: ["WORK"] }, + }); + expect(await loadFreeWeekdaySpansForPrompt(user.id)).toHaveLength(1); + + await getPrismaClient().userHealthProfile.update({ + where: { userId: user.id }, + data: { aiIncludedSections: ["ABOUT_ME"] }, + }); + expect(await loadFreeWeekdaySpansForPrompt(user.id)).toEqual([]); + // The data surfaces still show the person their own answer. + expect(await loadFreeWeekdaySpans(user.id)).toHaveLength(1); + }); +}); + describe("AUDIT-C", () => { async function postAuditC(body: unknown): Promise { const { POST } = await import("@/app/api/anamnesis/audit-c/route");