mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 '{}';
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user