feat(insights): per-user exclude-metrics opt-out (mirrors Coach)

Add insightsExcludeMetrics to the User schema and apply the list as
a feature-payload filter inside /api/insights/generate before
JSON.stringify. The Coach settings PUT mirrors its excludeMetrics
onto the new column so both surfaces share a single privacy
contract — no separate Insights settings panel needed.

Anthropometrics is special-cased to strip only height / age / gender
inside features.context, leaving the aggregate totals intact.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:29:09 +02:00
1 parent 13f8905ea1
commit 7352806184
6 files changed
+260 -1

No files matched your search

@@ -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 '{}';
+6
View File
@@ -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
+10 -1
View File
@@ -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({
+9
View File
@@ -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({
@@ -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();
});
});
+76
View File
@@ -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<string, ReadonlyArray<keyof AggregatedFeatures>> = {
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<string>): T {
if (excludeMetrics.length === 0) return features;
const next = { ...features } as Record<string, unknown>;
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<string, unknown>) };
ctx.heightCm = null;
ctx.ageYears = null;
ctx.gender = null;
next.context = ctx;
}
}
return next as T;
}