diff --git a/prisma/migrations/0068_v1436_insights_exclude_metrics/migration.sql b/prisma/migrations/0068_v1436_insights_exclude_metrics/migration.sql new file mode 100644 index 000000000..54e6e4efb --- /dev/null +++ b/prisma/migrations/0068_v1436_insights_exclude_metrics/migration.sql @@ -0,0 +1,15 @@ +-- v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list. +-- +-- Mirrors `coachPrefsJson.excludeMetrics` so the user has a single +-- contract for "which data blocks reach the LLM" across both Coach +-- and Insights. Stored as a plain Postgres TEXT[] (not Json) because +-- the values are a closed enum and we want straightforward array +-- containment queries in case ops ever needs to audit them. +-- +-- Additive only: NOT NULL with DEFAULT '{}' keeps every existing row +-- on the "exclude nothing" path, byte-identical to v1.4.35 behaviour +-- for accounts that haven't opened the new toggle. The `IF NOT EXISTS` +-- guard makes the migration idempotent — same posture as 0058 / +-- 0061. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "insights_exclude_metrics" TEXT[] NOT NULL DEFAULT '{}'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 73d2f71d6..824bb8bf9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,6 +36,12 @@ model User { insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") insightsCachedText String? @map("insights_cached_text") // cached JSON output + // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors + // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route + // filters matching keys off `features` before JSON.stringify so the + // model never sees data the user opted out of. Default `[]` = legacy + // behaviour for every existing row. + insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") // Telegram notifications (per-user) telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 37bfa63a5..99d9f00e9 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -57,9 +57,18 @@ export const PUT = apiHandler(async (req: Request) => { // Persist the canonical defaulted form so the column shape stays // stable regardless of which subset of keys the caller supplied. + // + // v1.4.36 W3 T3 — mirror the excludeMetrics list onto the dedicated + // `insightsExcludeMetrics` column so the Insights generator and + // Coach snapshot share a single source of truth. Keeps the user's + // privacy contract symmetric across the two surfaces without + // requiring a separate Insights settings sheet. await prisma.user.update({ where: { id: user.id }, - data: { coachPrefsJson: parsed.data }, + data: { + coachPrefsJson: parsed.data, + insightsExcludeMetrics: parsed.data.excludeMetrics, + }, }); annotate({ diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 228fe4849..297947196 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -5,6 +5,7 @@ import { extractFeatures, FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; +import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; import { detectGlp1Plateau, buildGlp1PlateauPrompt, @@ -191,6 +192,10 @@ export const POST = apiHandler(async (request: NextRequest) => { insightsPrivacyMode: true, insightsCachedAt: true, insightsCachedText: true, + // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach + // settings. Filtered off `features` before serialisation so the + // LLM never sees the excluded blocks. + insightsExcludeMetrics: true, locale: true, }, }); @@ -291,6 +296,10 @@ export const POST = apiHandler(async (request: NextRequest) => { throw err; } } + // v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list + // BEFORE serialisation so the model never sees the dropped blocks. + const excludeList = dbUser?.insightsExcludeMetrics ?? []; + features = applyInsightsExcludeFilter(features, excludeList); const featuresJson = JSON.stringify(features, null, 2); if (payloadDowngraded) { annotate({ diff --git a/src/lib/insights/__tests__/exclude-filter.test.ts b/src/lib/insights/__tests__/exclude-filter.test.ts new file mode 100644 index 000000000..e5f534d66 --- /dev/null +++ b/src/lib/insights/__tests__/exclude-filter.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { applyInsightsExcludeFilter } from "../exclude-filter"; +import type { AggregatedFeatures } from "../features"; + +function baseFeatures(): AggregatedFeatures { + return { + weight: { + latest: 82, + avg7: 82, + avg30: 82, + avg90: 82, + allTimeAvg: 82, + allTimeMin: 80, + allTimeMax: 84, + slope30: 0, + outlierCount: 0, + bmi: 25, + coverage: { + count: 10, + spanDays: 30, + avgDaysBetween: 3, + oldestDaysAgo: 30, + newestDaysAgo: 1, + }, + }, + bloodPressure: { + avgSys30: 128, + avgDia30: 82, + avgSys90: 128, + avgDia90: 82, + allTimeAvgSys: 128, + allTimeAvgDia: 82, + allTimeMinSys: 110, + allTimeMaxSys: 145, + allTimeMinDia: 70, + allTimeMaxDia: 90, + slopeSys30: 0, + slopeDia30: 0, + sdSys30: 5, + sdDia30: 3, + pulsePressure30: 46, + pctInTarget: 70, + coverage: { + count: 10, + spanDays: 30, + avgDaysBetween: 3, + oldestDaysAgo: 30, + newestDaysAgo: 1, + }, + }, + medications: [ + { + name: "Mounjaro", + dose: "7.5mg", + category: "GLP1", + compliance7: 100, + compliance30: 95, + compliance90: 92, + streak: 30, + missedLast7: 0, + }, + ], + context: { + heightCm: 180, + hasBpTargets: true, + totalMeasurements: 100, + dataSpanDays: 365, + oldestMeasurementDaysAgo: 365, + newestMeasurementDaysAgo: 1, + ageYears: 45, + gender: "MALE", + }, + }; +} + +describe("applyInsightsExcludeFilter", () => { + it("returns the original payload unchanged when excludeList is empty", () => { + const f = baseFeatures(); + const out = applyInsightsExcludeFilter(f, []); + expect(out).toBe(f); + }); + + it("drops weight when 'weight' is excluded", () => { + const f = baseFeatures(); + const out = applyInsightsExcludeFilter(f, ["weight"]); + expect(out.weight).toBeUndefined(); + expect(out.bloodPressure).toBeDefined(); + }); + + it("drops blood pressure when 'bp' is excluded", () => { + const f = baseFeatures(); + const out = applyInsightsExcludeFilter(f, ["bp"]); + expect(out.bloodPressure).toBeUndefined(); + expect(out.weight).toBeDefined(); + }); + + it("drops medications when 'medications' or 'compliance' is excluded", () => { + expect( + applyInsightsExcludeFilter(baseFeatures(), ["medications"]).medications, + ).toBeUndefined(); + expect( + applyInsightsExcludeFilter(baseFeatures(), ["compliance"]).medications, + ).toBeUndefined(); + }); + + it("strips anthropometrics from context without nuking the whole block", () => { + const out = applyInsightsExcludeFilter(baseFeatures(), ["anthropometrics"]); + expect(out.context.heightCm).toBeNull(); + expect(out.context.ageYears).toBeNull(); + expect(out.context.gender).toBeNull(); + // The aggregate totals stay — those aren't PII. + expect(out.context.dataSpanDays).toBe(365); + expect(out.context.totalMeasurements).toBe(100); + }); + + it("applies multiple exclusions in one pass", () => { + const out = applyInsightsExcludeFilter(baseFeatures(), [ + "weight", + "bp", + "anthropometrics", + ]); + expect(out.weight).toBeUndefined(); + expect(out.bloodPressure).toBeUndefined(); + expect(out.context.heightCm).toBeNull(); + }); + + it("does not mutate the input", () => { + const f = baseFeatures(); + applyInsightsExcludeFilter(f, ["weight", "bp", "anthropometrics"]); + expect(f.weight).toBeDefined(); + expect(f.bloodPressure).toBeDefined(); + expect(f.context.heightCm).toBe(180); + }); + + it("is a no-op for tokens with no mapped feature keys (hrv / resting_hr)", () => { + const out = applyInsightsExcludeFilter(baseFeatures(), [ + "hrv", + "resting_hr", + ]); + expect(out.weight).toBeDefined(); + expect(out.bloodPressure).toBeDefined(); + }); +}); diff --git a/src/lib/insights/exclude-filter.ts b/src/lib/insights/exclude-filter.ts new file mode 100644 index 000000000..0fc78a15b --- /dev/null +++ b/src/lib/insights/exclude-filter.ts @@ -0,0 +1,76 @@ +/** + * v1.4.36 W3 T3 — exclude-metrics filter for the AI Insights payload. + * + * Mirrors the Coach snapshot's `excludeMetrics` contract so the user + * has a single privacy contract across both surfaces: what's hidden + * from the Coach turn stays hidden from the Insights generation. The + * filter drops matching keys off the features object BEFORE + * `JSON.stringify` so the LLM never sees the excluded data. + * + * The token vocabulary is the same `coachExcludeMetricEnum` from + * `src/lib/validations/coach-prefs.ts` — see the comment there for + * the canonical list. The mapping below pairs each token with the + * features.ts field keys it gates; tokens with no mapped fields + * (e.g. Apple Health metrics that don't have a dedicated features + * branch yet) are no-ops on this surface but keep the contract + * symmetric so adding a future field is one-line. + * + * Anthropometrics is special-cased: rather than dropping the whole + * `context` field (which carries non-PII totals like `dataSpanDays`), + * we strip just `heightCm` / `ageYears` / `gender` from it. + */ +import type { AggregatedFeatures, RawFeatures } from "./features"; + +/** + * Map exclude-token → features.ts top-level keys to remove. `null` + * means the token gates a substructure inside `context` and is + * handled inline below. + */ +const EXCLUDE_TO_FEATURE_KEYS: Record> = { + bp: ["bloodPressure"], + weight: ["weight"], + pulse: ["pulse"], + mood: ["mood"], + compliance: ["medications"], + medications: ["medications"], + sleep: ["sleep"], + steps: ["activity"], + // hrv / resting_hr — no dedicated features branch yet (carried only + // by the Coach Apple-Health blocks). Keep the entry for symmetry + + // future-proofing. + hrv: [], + resting_hr: [], +}; + +/** + * Return a shallow copy of `features` with every key gated by an + * active exclude-token removed. Idempotent. Original payload is not + * mutated — same posture as the snapshot filter so call sites can + * keep both the filtered + unfiltered shape if they need to. + */ +export function applyInsightsExcludeFilter< + T extends AggregatedFeatures | RawFeatures, +>(features: T, excludeMetrics: ReadonlyArray): T { + if (excludeMetrics.length === 0) return features; + const next = { ...features } as Record; + for (const token of excludeMetrics) { + const keys = EXCLUDE_TO_FEATURE_KEYS[token]; + if (keys) { + for (const key of keys) { + delete next[key as string]; + } + } + if (token === "anthropometrics" && next.context && typeof next.context === "object") { + // Anthropometrics gates the profile sub-fields inside `context`. + // The rest of context (totals, span days) stays — those are + // aggregate counts the model needs for narrative coverage even + // when the profile is hidden. + const ctx = { ...(next.context as Record) }; + ctx.heightCm = null; + ctx.ageYears = null; + ctx.gender = null; + next.context = ctx; + } + } + return next as T; +}