diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 23d2b8062..eca41144d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -251,6 +251,63 @@ components: - data - error type: object + GetCoachPrefsResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + excludeMetrics: + default: [] + items: + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + type: string + maxItems: 9 + type: array + showEvidenceByDefault: + default: false + type: boolean + tone: + default: warm + enum: + - warm + - neutral + - concise + type: string + verbosity: + default: default + enum: + - brief + - default + - detailed + type: string + required: + - tone + - verbosity + - excludeMetrics + - showEvidenceByDefault + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object InsightsComprehensiveResponse: additionalProperties: false description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface @@ -495,6 +552,63 @@ components: - data - error type: object + PutCoachPrefsResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + excludeMetrics: + default: [] + items: + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + type: string + maxItems: 9 + type: array + showEvidenceByDefault: + default: false + type: boolean + tone: + default: warm + enum: + - warm + - neutral + - concise + type: string + verbosity: + default: default + enum: + - brief + - default + - detailed + type: string + required: + - tone + - verbosity + - excludeMetrics + - showEvidenceByDefault + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object RefreshResponse: additionalProperties: false properties: @@ -596,6 +710,79 @@ paths: summary: Email-or-username login (password) tags: - Auth + /api/auth/me/coach-prefs: + get: + description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy + v1.4.22 defaults. + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GetCoachPrefsResponse" + description: Resolved preferences. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Read per-user Coach prompt-tuning preferences + tags: + - Auth + put: + description: Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the + documented defaults. + requestBody: + content: + application/json: + schema: + properties: + excludeMetrics: + default: [] + items: + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + type: string + maxItems: 9 + type: array + showEvidenceByDefault: + default: false + type: boolean + tone: + default: warm + enum: + - warm + - neutral + - concise + type: string + verbosity: + default: default + enum: + - brief + - default + - detailed + type: string + type: object + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PutCoachPrefsResponse" + description: Saved preferences echoed back. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Replace per-user Coach prompt-tuning preferences + tags: + - Auth /api/auth/passkey/login-verify: post: requestBody: diff --git a/messages/de.json b/messages/de.json index 12e9257b7..f15cef525 100644 --- a/messages/de.json +++ b/messages/de.json @@ -901,7 +901,25 @@ "last90days": "letzte 90 Tage", "allTime": "gesamter Zeitraum" }, - "windowLabel": "Zeitraum" + "windowLabel": "Zeitraum", + "settingsAriaLabel": "Coach-Einstellungen", + "settingsTitle": "Coach-Einstellungen", + "settingsDescription": "Stell ein, wie der Coach mit dir spricht.", + "settingsToneLabel": "Tonfall", + "settingsToneWarm": "Warm", + "settingsToneNeutral": "Neutral", + "settingsToneConcise": "Knapp", + "settingsVerbosityLabel": "Antwortlänge", + "settingsVerbosityBrief": "Kurz", + "settingsVerbosityDefault": "Standard", + "settingsVerbosityDetailed": "Ausführlich", + "settingsExcludeLabel": "Metriken ausschließen", + "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", + "settingsEvidenceLabel": "Belege standardmäßig zeigen", + "settingsEvidenceHint": "Die Belege-Box unter jeder Antwort automatisch öffnen.", + "settingsSave": "Speichern", + "settingsSaved": "Gespeichert.", + "settingsCancel": "Abbrechen" }, "viewAll": "Alle ansehen", "previewEmpty": "Noch keine Empfehlungen.", diff --git a/messages/en.json b/messages/en.json index 2300bd8af..c6df0438c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -901,7 +901,25 @@ "last90days": "last 90 days", "allTime": "all time" }, - "windowLabel": "Window" + "windowLabel": "Window", + "settingsAriaLabel": "Coach settings", + "settingsTitle": "Coach settings", + "settingsDescription": "Tune how the Coach talks with you.", + "settingsToneLabel": "Tone", + "settingsToneWarm": "Warm", + "settingsToneNeutral": "Neutral", + "settingsToneConcise": "Concise", + "settingsVerbosityLabel": "Reply length", + "settingsVerbosityBrief": "Brief", + "settingsVerbosityDefault": "Default", + "settingsVerbosityDetailed": "Detailed", + "settingsExcludeLabel": "Exclude metrics", + "settingsExcludeHint": "The Coach will not see data for selected metrics.", + "settingsEvidenceLabel": "Show evidence by default", + "settingsEvidenceHint": "Open the disclosure under every reply automatically.", + "settingsSave": "Save", + "settingsSaved": "Saved.", + "settingsCancel": "Cancel" }, "viewAll": "View all", "previewEmpty": "No recommendations yet.", diff --git a/prisma/migrations/0038_coach_prefs/migration.sql b/prisma/migrations/0038_coach_prefs/migration.sql new file mode 100644 index 000000000..d21a51e20 --- /dev/null +++ b/prisma/migrations/0038_coach_prefs/migration.sql @@ -0,0 +1,15 @@ +-- v1.4.23 H4 — per-user Coach prompt-tuning preferences. +-- +-- The Coach drawer's settings cog returned in v1.4.23 (the v1.4.22 B5 +-- audit had pulled the dead placeholder cog out so it stayed empty +-- until the per-user surface was real). Persist the four controls the +-- cog exposes — tone, verbosity, excludeMetrics, showEvidenceByDefault +-- — as a single nullable Json column so adding new knobs in v1.5 +-- (e.g., per-source language preference) doesn't churn the schema. +-- +-- Default behaviour stays exactly v1.4.22 when the column is NULL — +-- tone="warm", verbosity="default", no metrics excluded, evidence +-- disclosure closed by default. The Coach prompt builder reads the +-- column and folds non-default knobs into the system prompt prefix. +ALTER TABLE "users" +ADD COLUMN IF NOT EXISTS "coach_prefs_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9823265b0..91c4669f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,6 +112,21 @@ model User { // `sanitisePracticeName()`. Null = user has never set one. lastReportPracticeName String? @map("last_report_practice_name") + // v1.4.23 H4 — Coach drawer per-user prompt-tuning preferences. + // Shape: + // { "tone": "warm" | "neutral" | "concise", + // "verbosity": "brief" | "default" | "detailed", + // "excludeMetrics": ("bp" | "weight" | "pulse" | "mood" | + // "compliance" | "hrv" | "sleep" | + // "resting_hr" | "steps")[], + // "showEvidenceByDefault": boolean } + // Null = legacy v1.4.22 defaults (tone=warm, verbosity=default, no + // metrics excluded, evidence closed). The Coach prompt builder reads + // this column and the snapshot builder filters on `excludeMetrics` + // BEFORE the snapshot lands in the system prompt — otherwise the + // model would still see the data the user excluded. + coachPrefsJson Json? @map("coach_prefs_json") + // Relations passkeys Passkey[] sessions Session[] diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts new file mode 100644 index 000000000..37bfa63a5 --- /dev/null +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -0,0 +1,75 @@ +/** + * v1.4.23 H4 — per-user Coach prompt-tuning preferences. + * + * GET /api/auth/me/coach-prefs — returns the parsed prefs (defaults + * when the row is null). + * PUT /api/auth/me/coach-prefs — replaces the persisted prefs with + * the supplied shape; the body is + * validated against + * `coachPrefsSchema` and persisted as + * the canonical defaulted form so a + * future schema migration doesn't + * need to back-fill missing keys. + * + * Bearer-auth + cookie-auth both work via the shared `requireAuth()` + * helper. The Coach prompt builder + snapshot builder both read this + * row on every turn — there's no caching layer to invalidate. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { + coachPrefsSchema, + parseCoachPrefs, +} from "@/lib/validations/coach-prefs"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.coach-prefs.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { coachPrefsJson: true }, + }); + return apiSuccess(parseCoachPrefs(row?.coachPrefsJson)); +}); + +export const PUT = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "coach-prefs.body.invalid_json"); + } + + const parsed = coachPrefsSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.coach-prefs.put.invalid" }, + meta: { issues: parsed.error.issues.length }, + }); + throw new HttpError(422, "coach-prefs.body.invalid_shape"); + } + + // Persist the canonical defaulted form so the column shape stays + // stable regardless of which subset of keys the caller supplied. + await prisma.user.update({ + where: { id: user.id }, + data: { coachPrefsJson: parsed.data }, + }); + + annotate({ + action: { name: "auth.me.coach-prefs.put" }, + meta: { + tone: parsed.data.tone, + verbosity: parsed.data.verbosity, + excludeCount: parsed.data.excludeMetrics.length, + showEvidence: parsed.data.showEvidenceByDefault, + }, + }); + return apiSuccess(parsed.data); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index fde99d741..b1b998ee6 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -61,6 +61,7 @@ import { detectRefusal } from "@/lib/ai/coach/refusal"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; +import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; import { createSseStream } from "@/lib/sse/create-stream"; /** @@ -202,9 +203,16 @@ async function handleChatRequest(request: NextRequest): Promise { }); // Build the prompt: system + (optional) snapshot + recent history + - // the new user message. + // the new user message. v1.4.23 H4 — fold per-user prefs into the + // system-prompt prefix; the snapshot builder reads the same prefs + // separately so excluded metrics never even leave the DB. + const prefsRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { coachPrefsJson: true }, + }); + const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson); const snapshot = await buildCoachSnapshot(userId, scope); - const systemPrompt = getCoachSystemPrompt(locale); + const systemPrompt = getCoachSystemPrompt(locale, coachPrefs); const window = buildHistoryWindow([ ...priorTurns, { role: "user", content: message }, diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index d3d062fde..055dee029 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -34,6 +34,15 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => useAuthMock(), })); +// v1.4.23 H4 — message-thread reads `useQuery(["coach-prefs"])` so the +// evidence disclosure honours `showEvidenceByDefault`. SSR has no +// QueryClientProvider; stub the hook to return the legacy defaults so +// the existing assertions on the closed-by-default disclosure stay +// representative. +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), +})); + import { MessageThread } from "../message-thread"; import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 9be1c0039..fbc007655 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; -import { Plus, Sparkles } from "lucide-react"; +import { Plus, Settings, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +17,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; +import { CoachSettingsSheet } from "./coach-settings-sheet"; import { HistoryRail } from "./history-rail"; import { MessageThread } from "./message-thread"; import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail"; @@ -130,6 +131,10 @@ export function CoachDrawer({ // data the Coach is using without losing the message thread context. const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); + // v1.4.23 H4 — Coach prompt-tuning sheet. The v1.4.22 B5 audit had + // removed the placeholder cog from the drawer header; v1.4.23 H4 + // returns it with a real surface backed by `/api/auth/me/coach-prefs`. + const [settingsOpen, setSettingsOpen] = useState(false); // v1.4.23 H3 — `prefill` is now a fully-controlled prop. The // v1.4.20 implementation used `key={prefill}` on the parent mount // to force a fresh `useState()` initialiser run on every prefill @@ -226,13 +231,11 @@ export function CoachDrawer({ "flex h-[100dvh] flex-col gap-0", )} > - {/* Header (full width). Avatar + title + new-chat button. - v1.4.22 B5 — the settings cog was removed: it had no real - wiring and the matching v1.4.21 placeholder tooltip - ("Coach settings arrive in v1.4.21") read as a dead button. - A real settings surface for per-user prompt-tuning lands - with v1.4.23. pr-12 keeps the new-chat button clear of the - Sheet's close-X on narrower viewports. */} + {/* Header (full width). Avatar + title + new-chat button + + settings cog (v1.4.23 H4 — real per-user prompt-tuning + surface, replacing the v1.4.22 B5 dead-button cleanup). + pr-12 keeps the buttons clear of the Sheet's close-X on + narrower viewports. */} + {/* Body — three columns on lg+, single column on smaller. @@ -334,6 +348,14 @@ export function CoachDrawer({ + {/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so + it doesn't conflict with the existing left/right rail + trays (those are ` so it + * sits on top of the drawer without conflicting with the existing + * mobile rail trays (history = left, sources = right tray on ` void; +} + +const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [ + "bp", + "weight", + "pulse", + "mood", + "compliance", + "hrv", + "sleep", + "resting_hr", + "steps", +]; + +export function CoachSettingsSheet({ + open, + onOpenChange, +}: CoachSettingsSheetProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const { data: persisted } = useQuery({ + queryKey: ["coach-prefs"], + queryFn: async () => { + const res = await fetch("/api/auth/me/coach-prefs"); + if (!res.ok) throw new Error("coach-prefs.fetch_failed"); + const env = (await res.json()) as { data: CoachPrefs }; + return env.data; + }, + enabled: open, + }); + + // Local working copy edited by the form. Re-syncs to the persisted + // shape on every fetch via the same in-render setState pattern as + // the drawer's `useResettableValue` (no useEffect — the + // `react-hooks/set-state-in-effect` rule rejects setState inside an + // effect when the source is a query result). + const [draft, setDraft] = useState(DEFAULT_COACH_PREFS); + const [lastSeenPersisted, setLastSeenPersisted] = useState( + null, + ); + if (persisted && persisted !== lastSeenPersisted) { + setLastSeenPersisted(persisted); + setDraft(persisted); + } + + const save = useMutation({ + mutationFn: async (next: CoachPrefs) => { + const res = await fetch("/api/auth/me/coach-prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(next), + }); + if (!res.ok) throw new Error("coach-prefs.save_failed"); + return (await res.json()) as { data: CoachPrefs }; + }, + onSuccess: (envelope) => { + queryClient.setQueryData(["coach-prefs"], envelope.data); + onOpenChange(false); + }, + }); + + function toggleExclude(metric: CoachExcludeMetric, next: boolean) { + setDraft((prev) => ({ + ...prev, + excludeMetrics: next + ? Array.from(new Set([...prev.excludeMetrics, metric])) + : prev.excludeMetrics.filter((m) => m !== metric), + })); + } + + return ( + + + + + {t("insights.coach.settingsTitle")} + + + {t("insights.coach.settingsDescription")} + + + +
+ {/* Tone */} +
+ + +
+ + {/* Verbosity */} +
+ + +
+ + {/* Exclude metrics */} +
+ +

+ {t("insights.coach.settingsExcludeHint")} +

+
    + {EXCLUDE_OPTIONS.map((metric) => { + const id = `coach-prefs-exclude-${metric}`; + const checked = draft.excludeMetrics.includes(metric); + return ( +
  • + + toggleExclude(metric, next)} + /> +
  • + ); + })} +
+
+ + {/* Show evidence by default */} +
+
+ +

+ {t("insights.coach.settingsEvidenceHint")} +

+
+ + setDraft((prev) => ({ + ...prev, + showEvidenceByDefault: next, + })) + } + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 23ec4c227..beccb1b7e 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,11 +1,16 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Bot, ChevronRight, Sparkles, User } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; +import { + DEFAULT_COACH_PREFS, + type CoachPrefs, +} from "@/lib/validations/coach-prefs"; import { SourceChips } from "./source-chips"; import type { @@ -52,6 +57,22 @@ export function MessageThread({ const scrollerRef = useRef(null); const wasPinnedRef = useRef(true); + // v1.4.23 H4 — read the user's Coach prefs so the evidence + // disclosure honours `showEvidenceByDefault`. Cached at the + // queryClient level; the settings sheet writes the same key after a + // successful save so the new default takes effect on the next reply + // without a page reload. + const { data: coachPrefs } = useQuery({ + queryKey: ["coach-prefs"], + queryFn: async () => { + const res = await fetch("/api/auth/me/coach-prefs"); + if (!res.ok) return DEFAULT_COACH_PREFS; + const env = (await res.json()) as { data: CoachPrefs }; + return env.data; + }, + }); + const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false; + const messages: CoachMessageDTO[] = conversation?.messages ?? []; // v1.4.20.1 — once the SSE stream emits `done`, the route's // invalidate-then-refetch pulls the persisted assistant message into @@ -158,6 +179,7 @@ export function MessageThread({ content={m.content} metricSource={m.metricSource} providerType={m.providerType} + evidenceDefaultOpen={evidenceDefaultOpen} /> ); })} @@ -174,6 +196,7 @@ export function MessageThread({ providerType={streaming.inProgress ? "streaming" : null} inProgress={streaming.inProgress} errorCode={streaming.errorCode} + evidenceDefaultOpen={evidenceDefaultOpen} /> )} @@ -203,6 +226,8 @@ interface ChatBubbleProps { providerType?: string | null; inProgress?: boolean; errorCode?: string | null; + /** v1.4.23 H4 — when true the evidence `
` mounts open. */ + evidenceDefaultOpen?: boolean; } function ChatBubble({ @@ -212,6 +237,7 @@ function ChatBubble({ providerType, inProgress, errorCode, + evidenceDefaultOpen, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -306,6 +332,11 @@ function ChatBubble({ {keyValues.length > 0 && (
({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + user: { findUnique: vi.fn() }, }, })); @@ -21,6 +22,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + user: { findUnique: ReturnType }; }; const featuresMock = extractFeatures as unknown as ReturnType; @@ -50,6 +52,10 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => vi.clearAllMocks(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default + // null = legacy defaults so the existing test fixtures stay + // representative of the v1.4.22 behaviour. + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); featuresMock.mockResolvedValue({ bloodPressure: undefined, weight: undefined, diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 28cb35b2a..f940c7721 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + user: { findUnique: vi.fn() }, }, })); @@ -21,6 +22,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + user: { findUnique: ReturnType }; }; const featuresMock = extractFeatures as unknown as ReturnType; @@ -48,6 +50,9 @@ describe("buildCoachSnapshot", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply + // per-user excludeMetrics. Default null = use legacy defaults. + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); featuresMock.mockResolvedValue({ bloodPressure: undefined, weight: undefined, diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 772f28b00..c2fae487c 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; import { getCoachSystemPrompt } from "../system-prompt"; /** @@ -139,3 +140,56 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); }); }); + +/** + * v1.4.23 H4 — per-user prompt-tuning prefix. Default prefs leave the + * prompt unchanged so the v1.4.22 train stays in place; non-default + * tone / verbosity selections prepend a short OVERRIDE paragraph. + */ +describe("getCoachSystemPrompt — H4 prefs prefix", () => { + it("returns the unchanged base prompt when prefs are at defaults", () => { + const base = getCoachSystemPrompt("en"); + const withDefaults = getCoachSystemPrompt("en", DEFAULT_COACH_PREFS); + expect(withDefaults).toBe(base); + }); + + it("prepends a TONE OVERRIDE for tone=neutral", () => { + const out = getCoachSystemPrompt("en", { + ...DEFAULT_COACH_PREFS, + tone: "neutral", + }); + expect(out.startsWith("TONE OVERRIDE")).toBe(true); + expect(out).toMatch(/keep the warmth language minimal/i); + }); + + it("prepends a TONE OVERRIDE for tone=concise (and forces brief verbosity)", () => { + const out = getCoachSystemPrompt("en", { + ...DEFAULT_COACH_PREFS, + tone: "concise", + verbosity: "detailed", + }); + // Concise tone overrides verbosity — even if the user picked + // "detailed", the prefix asks for the brief cap. + expect(out).toMatch(/TONE OVERRIDE: be concise/); + expect(out).toMatch(/VERBOSITY OVERRIDE: cap replies at ~90 words/); + expect(out).not.toMatch(/180-250 words is acceptable/); + }); + + it("prepends a VERBOSITY OVERRIDE for verbosity=detailed at warm tone", () => { + const out = getCoachSystemPrompt("en", { + ...DEFAULT_COACH_PREFS, + verbosity: "detailed", + }); + expect(out).toMatch(/VERBOSITY OVERRIDE: 180-250 words is acceptable/); + }); + + it("prepends a German prefix in the de locale", () => { + const out = getCoachSystemPrompt("de", { + ...DEFAULT_COACH_PREFS, + tone: "neutral", + verbosity: "brief", + }); + expect(out).toMatch(/TONFALL-OVERRIDE/); + expect(out).toMatch(/AUSFÜHRLICHKEITS-OVERRIDE/); + }); +}); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 34af8061d..3999f9ff7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -17,6 +17,10 @@ */ import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; +import { + parseCoachPrefs, + type CoachExcludeMetric, +} from "@/lib/validations/coach-prefs"; import type { CoachProvenance, CoachScope, @@ -249,7 +253,29 @@ export async function buildCoachSnapshot( userId: string, scope?: CoachScope, ): Promise { - const { sources, window } = resolveScope(scope); + const { sources: scopedSources, window } = resolveScope(scope); + + // v1.4.23 H4 — apply per-user `excludeMetrics` BEFORE we read any + // measurement rows so the model never sees data the user opted out + // of. The filter intersects with the resolved scope (the explicit + // `scope` argument from the request body still wins for the + // _maximum_ set; prefs only narrow further). + const prefsRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { coachPrefsJson: true }, + }); + const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); + const excluded = new Set(prefs.excludeMetrics); + const sources = new Set(); + for (const src of scopedSources) { + // The `excludeMetrics` enum is a strict subset of `CoachScopeSource` + // (every excludable key is also a valid scope source); this cast is + // safe and the runtime check is just defence-in-depth. + if (!excluded.has(src as unknown as CoachExcludeMetric)) { + sources.add(src); + } + } + const windowDays = windowToDays(window); const features = await extractFeatures(userId, false, { sinceDays: windowDays, diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 99f594b20..60aa63f40 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -18,6 +18,10 @@ */ import type { Locale } from "@/lib/i18n/config"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; +import { + DEFAULT_COACH_PREFS, + type CoachPrefs, +} from "@/lib/validations/coach-prefs"; const COACH_PROMPT_EN = `You are the HealthLog Coach. You sit alongside the user as they look at their own health data — blood pressure, weight, pulse, mood, medications — @@ -352,6 +356,59 @@ SPRACHE Antworte auf Deutsch, sofern der Nutzer auf Deutsch schreibt; bei englischen Nachrichten antworte auf Englisch.`; -export function getCoachSystemPrompt(locale: Locale): string { - return locale === "en" ? COACH_PROMPT_EN : COACH_PROMPT_DE; +export function getCoachSystemPrompt( + locale: Locale, + prefs: CoachPrefs = DEFAULT_COACH_PREFS, +): string { + const base = locale === "en" ? COACH_PROMPT_EN : COACH_PROMPT_DE; + const prefix = buildPrefsPrefix(locale, prefs); + return prefix ? `${prefix}\n\n${base}` : base; +} + +/** + * v1.4.23 H4 — convert non-default Coach prefs into a one-paragraph + * prefix the system prompt prepends. Defaults produce an empty prefix + * so legacy users see the unchanged v1.4.22 prompt verbatim. + * + * Tone "concise" caps verbosity at "brief" regardless of the explicit + * verbosity selection — same intuition as concise == short, the picker + * just keeps the controls orthogonal in the UI for clarity. + */ +function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string { + const parts: string[] = []; + const isEn = locale === "en"; + + // Tone + if (prefs.tone === "neutral") { + parts.push( + isEn + ? "TONE OVERRIDE: keep the warmth language minimal — match the user's literal register without warmth flourishes." + : "TONFALL-OVERRIDE: Halte die Warmth-Sprache zurück — spiegele die wörtliche Tonalität des Nutzers ohne zusätzliche warme Floskeln.", + ); + } else if (prefs.tone === "concise") { + parts.push( + isEn + ? "TONE OVERRIDE: be concise. Drop the motivational-interviewing micro-moves; one short observation + one short follow-up is enough." + : "TONFALL-OVERRIDE: Sei knapp. Lass die Motivational-Interviewing-Mikro-Moves weg; eine kurze Beobachtung plus eine kurze Anschlussfrage genügt.", + ); + } + + // Verbosity (concise tone implicitly forces brief). + const effectiveVerbosity = + prefs.tone === "concise" ? "brief" : prefs.verbosity; + if (effectiveVerbosity === "brief") { + parts.push( + isEn + ? "VERBOSITY OVERRIDE: cap replies at ~90 words. One paragraph; no opening recap of the user's question." + : "AUSFÜHRLICHKEITS-OVERRIDE: Antworten auf ~90 Wörter begrenzen. Ein Absatz; keine einleitende Wiederholung der Userfrage.", + ); + } else if (effectiveVerbosity === "detailed") { + parts.push( + isEn + ? "VERBOSITY OVERRIDE: 180-250 words is acceptable. The user wants the long-form context; carry every cited number through to a thoughtful close." + : "AUSFÜHRLICHKEITS-OVERRIDE: 180-250 Wörter sind in Ordnung. Der Nutzer möchte den ausführlichen Kontext; trage jede genannte Zahl bis zu einem sorgfältigen Schluss.", + ); + } + + return parts.length > 0 ? parts.join("\n\n") : ""; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 07e36dbd9..7eea9af99 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -30,6 +30,7 @@ import { measurementSourceEnum, } from "@/lib/validations/measurement"; import { loginPasswordSchema } from "@/lib/validations/auth"; +import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -96,6 +97,12 @@ listMeasurementsSchema.meta({ "Query params for the measurements list endpoint. `limit` capped at 500.", }); +coachPrefsSchema.meta({ + id: "CoachPrefs", + description: + "Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.", +}); + // ── Sub-schemas owned here (route-specific shapes) ─────────────────── const passkeyLoginVerifyRequest = z @@ -479,6 +486,46 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/auth/me/coach-prefs": { + get: { + tags: ["Auth"], + summary: "Read per-user Coach prompt-tuning preferences", + description: + "Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.", + responses: { + "200": { + description: "Resolved preferences.", + content: { + "application/json": { + schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Auth"], + summary: "Replace per-user Coach prompt-tuning preferences", + description: + "Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.", + requestBody: { + required: true, + content: { "application/json": { schema: coachPrefsSchema } }, + }, + responses: { + "200": { + description: "Saved preferences echoed back.", + content: { + "application/json": { + schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/comprehensive": { get: { tags: ["Insights"], diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts new file mode 100644 index 000000000..34f4df610 --- /dev/null +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_COACH_PREFS, + coachPrefsSchema, + parseCoachPrefs, +} from "../coach-prefs"; + +/** + * v1.4.23 H4 — per-user Coach prompt-tuning preferences. + */ +describe("coachPrefsSchema", () => { + it("accepts an empty object and fills in defaults", () => { + const out = coachPrefsSchema.parse({}); + expect(out).toEqual(DEFAULT_COACH_PREFS); + }); + + it("accepts the full shape and round-trips it", () => { + const input = { + tone: "concise" as const, + verbosity: "brief" as const, + excludeMetrics: ["bp", "weight"] as const, + showEvidenceByDefault: true, + }; + const out = coachPrefsSchema.parse(input); + expect(out).toEqual(input); + }); + + it("rejects unknown tone values", () => { + const result = coachPrefsSchema.safeParse({ tone: "stoic" }); + expect(result.success).toBe(false); + }); + + it("rejects unknown verbosity values", () => { + const result = coachPrefsSchema.safeParse({ verbosity: "rambling" }); + expect(result.success).toBe(false); + }); + + it("rejects unknown excludeMetrics entries", () => { + const result = coachPrefsSchema.safeParse({ + excludeMetrics: ["bp", "horoscope"], + }); + expect(result.success).toBe(false); + }); + + it("caps excludeMetrics at 9 entries", () => { + const result = coachPrefsSchema.safeParse({ + excludeMetrics: [ + "bp", + "weight", + "pulse", + "mood", + "compliance", + "hrv", + "sleep", + "resting_hr", + "steps", + "bp", + ], + }); + expect(result.success).toBe(false); + }); +}); + +describe("parseCoachPrefs", () => { + it("returns defaults for null input", () => { + expect(parseCoachPrefs(null)).toEqual(DEFAULT_COACH_PREFS); + }); + + it("returns defaults for undefined input", () => { + expect(parseCoachPrefs(undefined)).toEqual(DEFAULT_COACH_PREFS); + }); + + it("returns defaults for malformed input (forward-compat fallback)", () => { + expect(parseCoachPrefs({ tone: "stoic" })).toEqual(DEFAULT_COACH_PREFS); + expect(parseCoachPrefs("not an object")).toEqual(DEFAULT_COACH_PREFS); + }); + + it("preserves a valid shape", () => { + const input = { + tone: "neutral" as const, + verbosity: "default" as const, + excludeMetrics: ["mood" as const], + showEvidenceByDefault: true, + }; + expect(parseCoachPrefs(input)).toEqual(input); + }); +}); diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts new file mode 100644 index 000000000..a215a2d99 --- /dev/null +++ b/src/lib/validations/coach-prefs.ts @@ -0,0 +1,93 @@ +/** + * v1.4.23 H4 — per-user Coach prompt-tuning preferences. + * + * Persisted as a Json blob on `User.coachPrefsJson`. Null = legacy + * defaults (tone="warm", verbosity="default", no metrics excluded, + * evidence disclosure closed by default). The Coach prompt builder + + * snapshot builder both read this shape — the snapshot must filter on + * `excludeMetrics` BEFORE landing in the system prompt so the model + * never sees data the user opted out of. + */ +import { z } from "zod/v4"; + +/** + * Tone presets the Coach system-prompt prefix toggles between. v1.4.22 + * landed `warm` as the default; `neutral` strips the warmth language + * for users who prefer a clinical-adjacent style; `concise` caps + * verbosity and skips the optional motivational-interviewing micro- + * moves entirely. + */ +export const coachToneEnum = z.enum(["warm", "neutral", "concise"]); +export type CoachTone = z.infer; + +/** + * Verbosity presets. Maps onto the prompt's "60-180 words" guidance: + * `brief` ≈ 30-90 words, `default` keeps the v1.4.22 range, `detailed` + * lifts the cap to 250 words. The `concise` tone overrides verbosity + * down to `brief` regardless of the verbosity selection — same + * intuition as concise == short, the picker just keeps the controls + * orthogonal in the UI for clarity. + */ +export const coachVerbosityEnum = z.enum(["brief", "default", "detailed"]); +export type CoachVerbosity = z.infer; + +/** + * Metric scopes the user can exclude from every Coach turn. Matches + * `CoachScopeSource` (`src/lib/ai/coach/types.ts`) so the snapshot + * builder can filter without a translation step. Apple Health-only + * metrics (hrv / sleep / resting_hr / steps) are listed alongside the + * core five so iOS users have a single surface to manage their privacy + * preferences. + */ +export const coachExcludeMetricEnum = z.enum([ + "bp", + "weight", + "pulse", + "mood", + "compliance", + "hrv", + "sleep", + "resting_hr", + "steps", +]); +export type CoachExcludeMetric = z.infer; + +/** + * Full preferences shape. Defaults are inlined into the schema so a + * `safeParse({})` call returns the legacy v1.4.22 defaults — saves a + * sprinkle of `?? defaultX` calls at the call sites. + */ +export const coachPrefsSchema = z.object({ + tone: coachToneEnum.default("warm"), + verbosity: coachVerbosityEnum.default("default"), + excludeMetrics: z.array(coachExcludeMetricEnum).max(9).default([]), + showEvidenceByDefault: z.boolean().default(false), +}); + +export type CoachPrefs = z.infer; + +/** + * Default preferences applied when the user has never opened the + * settings cog. Equivalent to `coachPrefsSchema.parse({})` but + * returned as a plain object so call sites can compare references + * (e.g., short-circuit a snapshot rebuild when prefs match defaults). + */ +export const DEFAULT_COACH_PREFS: CoachPrefs = { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, +}; + +/** + * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, + * falling back to defaults when the row is null OR the persisted shape + * has drifted (a forward-compat field rename, an admin-side hand-edit, + * etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and + * `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing. + */ +export function parseCoachPrefs(raw: unknown): CoachPrefs { + if (raw == null) return DEFAULT_COACH_PREFS; + const parsed = coachPrefsSchema.safeParse(raw); + return parsed.success ? parsed.data : DEFAULT_COACH_PREFS; +} diff --git a/tests/integration/coach-prefs.test.ts b/tests/integration/coach-prefs.test.ts new file mode 100644 index 000000000..fc671a495 --- /dev/null +++ b/tests/integration/coach-prefs.test.ts @@ -0,0 +1,130 @@ +/** + * v1.4.23 H4 — per-user Coach prompt-tuning preferences route. + * + * Round-trips through GET → PUT → GET and asserts that: + * - the GET returns the documented defaults when the row is null + * - the PUT persists the supplied shape and returns the canonical + * defaulted form + * - subsequent GETs reflect the persisted values + * - malformed input is rejected with 422 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, email: `${username}@example.test` }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +describe("GET /api/auth/me/coach-prefs", () => { + it("returns documented defaults when the user has never saved prefs", async () => { + await seedSession("coach-prefs-defaults"); + + const { GET } = await import("@/app/api/auth/me/coach-prefs/route"); + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: typeof DEFAULT_COACH_PREFS }; + expect(env.data).toEqual(DEFAULT_COACH_PREFS); + }); +}); + +describe("PUT /api/auth/me/coach-prefs", () => { + it("persists the supplied shape and round-trips through GET", async () => { + const user = await seedSession("coach-prefs-roundtrip"); + const prisma = getPrismaClient(); + + const { PUT, GET } = await import("@/app/api/auth/me/coach-prefs/route"); + const body = { + tone: "concise" as const, + verbosity: "brief" as const, + excludeMetrics: ["mood" as const, "compliance" as const], + showEvidenceByDefault: true, + }; + const putRes = await (PUT as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/coach-prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + expect(putRes.status).toBe(200); + const putEnv = (await putRes.json()) as { data: typeof body }; + expect(putEnv.data).toEqual(body); + + // DB row reflects the canonical defaulted form. + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { coachPrefsJson: true }, + }); + expect(row?.coachPrefsJson).toEqual(body); + + // GET reads the saved values back. + const getRes = await (GET as () => Promise)(); + expect(getRes.status).toBe(200); + const getEnv = (await getRes.json()) as { data: typeof body }; + expect(getEnv.data).toEqual(body); + }); + + it("rejects unknown tone values with 422", async () => { + await seedSession("coach-prefs-invalid"); + + const { PUT } = await import("@/app/api/auth/me/coach-prefs/route"); + const res = await (PUT as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/coach-prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tone: "stoic" }), + }), + ); + expect(res.status).toBe(422); + }); +});