refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/

Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.

Move both files into `src/lib/ai/prompts/`:
  - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
  - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
  - co-located tests follow

Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 17:14:01 +02:00
1 parent d0bdc4b8d1
commit 8a56f48259
16 files changed
+473 -613

No files matched your search

@@ -91,7 +91,7 @@ vi.mock("@/lib/insights/features", async () => {
};
});
vi.mock("@/lib/insights/prompt", () => ({
vi.mock("@/lib/ai/prompts/insight-system-prompt", () => ({
buildUserPrompt: vi.fn(() => "user"),
}));
+2 -2
View File
@@ -6,7 +6,7 @@ import {
FeaturesPayloadTooLargeError,
} from "@/lib/insights/features";
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
import { compactSections } from "@/lib/insights/prompt-compact";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
@@ -14,7 +14,7 @@ import {
import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
+1 -1
View File
@@ -22,7 +22,7 @@ import {
type CoachExcludeMetric,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { compactSections } from "@/lib/insights/prompt-compact";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import type {
CoachProvenance,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { compactSections } from "../prompt-compact";
import { compactSections } from "../compact-sections";
describe("compactSections", () => {
it("drops empty arrays", () => {
@@ -3,7 +3,7 @@ import {
buildUserPrompt,
buildComparisonBlock,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
} from "@/lib/ai/prompts/insight-system-prompt";
/**
* v1.4.16 phase B8 — comparison context block in the user prompt.
@@ -27,10 +27,7 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import {
computeLongWindowSummary,
computeSummariesSlice,
} from "../summaries-slice";
import { computeSummariesSlice } from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
const MEASUREMENT_FIND_FIRST =
@@ -453,136 +450,3 @@ describe("computeSummariesSlice", () => {
});
});
/**
* v1.4.39 W-WMY — long-window summary helper. Pins:
* - the helper routes a 365-day window through MONTH buckets,
* - the linearly-composable aggregate (count / min / max / mean /
* sum) lands at the same numbers `aggregateWmyBuckets` produces
* directly, so the rollup math survives round-tripping through
* the summaries-slice entry point,
* - a coverage miss surfaces as a `null` return instead of a
* partial-shape leak.
*/
describe("computeLongWindowSummary", () => {
it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => {
// The first findMany call is the per-tier probe inside
// `readBestGranularityRollups`. With windowDays=365 the router
// walks YEAR (skipped, floor=731) → MONTH (floor=181, matched).
// The MONTH call returns two buckets; the helper composes the
// window aggregate from them.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
count: 10,
mean: 82,
sd: 1,
slope: -0.01,
r2: 0.3,
sumValue: null,
minValue: 79,
maxValue: 84,
},
{
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
count: 20,
mean: 80,
sd: 1.5,
slope: 0.02,
r2: 0.4,
sumValue: null,
minValue: 77,
maxValue: 83,
},
]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 365);
expect(result).not.toBeNull();
expect(result?.granularity).toBe("MONTH");
expect(result?.bucketCount).toBe(2);
expect(result?.count).toBe(30);
expect(result?.min).toBe(77);
expect(result?.max).toBe(84);
// Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666…
expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5);
expect(result?.sum).toBeNull(); // no sumValue on these buckets
// The router asked MONTH first (365 d clears the 181-day MONTH
// floor and falls short of the 731-day YEAR floor).
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => {
// YEAR coverage miss → router falls back to MONTH.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss
.mockResolvedValueOnce([
{
bucketStart: new Date("2024-06-01T00:00:00.000Z"),
count: 100,
mean: 81,
sd: 1.2,
slope: 0.005,
r2: 0.15,
sumValue: null,
minValue: 76,
maxValue: 86,
},
]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
expect(result?.granularity).toBe("MONTH");
expect(result?.bucketCount).toBe(1);
expect(result?.count).toBe(100);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
});
it("composes the cumulative sum when every bucket carries sumValue", async () => {
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
count: 28,
mean: 8_200,
sd: 1_000,
slope: 0,
r2: 0,
sumValue: 229_600,
minValue: 5_000,
maxValue: 12_000,
},
{
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
count: 30,
mean: 9_100,
sd: 1_100,
slope: 0,
r2: 0,
sumValue: 273_000,
minValue: 6_000,
maxValue: 12_500,
},
]);
const result = await computeLongWindowSummary(
"user-1",
"ACTIVITY_STEPS",
365,
);
expect(result?.sum).toBe(229_600 + 273_000);
});
it("returns null when every granularity misses", async () => {
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
expect(result).toBeNull();
});
it("returns null on a zero / negative / NaN window without hitting the DB", async () => {
expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull();
expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull();
expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull();
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
-48
View File
@@ -656,54 +656,6 @@ async function computeFromLiveAggregate(
* "rollup tier empty" cold-mount case, and v1.5's multi-year card
* accepts a `null` "no data yet" state.
*/
export async function computeLongWindowSummary(
userId: string,
type: MeasurementType,
windowDays: number,
): Promise<{
granularity: RollupGranularity;
bucketStart: Date | null;
bucketEnd: Date | null;
bucketCount: number;
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
} | null> {
const resolved = await readBestGranularityRollups(userId, type, windowDays);
if (!resolved) return null;
const rows: RollupBucketRow[] = resolved.rows;
const aggregate = aggregateWmyBuckets(rows);
// Annotate which granularity served the window so the operator can
// verify the routing in production wide-events without redeploying
// instrumentation. Mirrors the `slim_summaries.path` annotate
// shape inside `computeFromRollups` / `computeFromLiveAggregate`.
annotate({
meta: {
analytics: {
long_window_summary: {
type,
window_days: windowDays,
granularity: resolved.granularity,
bucket_count: rows.length,
row_count: aggregate.count,
},
},
},
});
return {
granularity: resolved.granularity,
bucketStart: rows[0]?.bucketStart ?? null,
bucketEnd: rows[rows.length - 1]?.bucketStart ?? null,
bucketCount: rows.length,
count: aggregate.count,
min: aggregate.min,
max: aggregate.max,
mean: aggregate.mean,
sum: aggregate.sum,
};
}
/**
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
@@ -1,136 +0,0 @@
/**
* v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers.
*
* `prisma.measurementRollup.findMany` is mocked so the tests pin the
* exact `where` shape the consumer sends (cumulative type set,
* granularity, since-bound) and the legacy-NULL fallback semantics of
* `resolveBucketSum`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurementRollup: {
findMany: mocks.findMany,
},
},
}));
import { prisma } from "@/lib/db";
import {
isCumulativeType,
readCumulativeDaySums,
resolveBucketSum,
} from "../measurement-read-cumulative";
beforeEach(() => {
mocks.findMany.mockReset();
});
describe("isCumulativeType", () => {
it("recognises the five cumulative HK types", () => {
expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true);
expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true);
expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true);
expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true);
expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true);
});
it("rejects spot metrics", () => {
expect(isCumulativeType("WEIGHT")).toBe(false);
expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false);
expect(isCumulativeType("PULSE")).toBe(false);
});
});
describe("readCumulativeDaySums", () => {
it("queries the rollup table with the cumulative type + DAY granularity", async () => {
const since = new Date("2026-04-15T00:00:00.000Z");
const rows = [
{
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
},
{
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
sumValue: 12480,
count: 5,
mean: 2496,
},
];
mocks.findMany.mockResolvedValueOnce(rows);
const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
});
// Single round-trip — eliminates the legacy per-type chunked
// findMany loop in analytics A2.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
const args = mocks.findMany.mock.calls[0][0];
expect(args.where).toMatchObject({
userId: "user-1",
type: "ACTIVITY_STEPS",
granularity: "DAY",
bucketStart: { gte: since },
});
expect(args.orderBy).toEqual({ bucketStart: "asc" });
});
it("propagates legacy NULL sum_value rows so the caller can fall back", async () => {
mocks.findMany.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: null,
count: 3,
mean: 1500,
},
]);
const [row] = await readCumulativeDaySums(
"user-1",
"ACTIVITY_STEPS",
new Date("2026-04-15T00:00:00.000Z"),
);
expect(row.sumValue).toBeNull();
// Algebraic fallback parity: pre-v1.4.39 buckets still surface
// a usable daily total during the boot-backfill convergence
// window.
expect(resolveBucketSum(row)).toBe(4500);
});
});
describe("resolveBucketSum", () => {
it("returns sumValue when populated", () => {
expect(
resolveBucketSum({
bucketStart: new Date(),
sumValue: 7500,
count: 3,
mean: 2500,
}),
).toBe(7500);
});
it("falls back to mean * count when sumValue is null (legacy row)", () => {
expect(
resolveBucketSum({
bucketStart: new Date(),
sumValue: null,
count: 3,
mean: 2500,
}),
).toBe(7500);
});
});
@@ -37,9 +37,6 @@ vi.mock("@/lib/db", () => ({
import {
aggregateWmyBuckets,
readBestGranularityRollups,
readMonthRollups,
readWeekRollups,
readYearRollups,
type RollupBucketRow,
} from "../measurement-read-wmy";
@@ -71,86 +68,6 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("readWeekRollups", () => {
it("returns the WEEK buckets for (userId, type, since)", async () => {
const rows = [
bucket("2026-04-27T00:00:00.000Z"),
bucket("2026-05-04T00:00:00.000Z", { mean: 81 }),
];
findMany.mockResolvedValueOnce(rows);
const since = new Date("2026-04-20T00:00:00.000Z");
const result = await readWeekRollups("user-1", "WEIGHT", since);
expect(result).not.toBeNull();
expect(result).toHaveLength(2);
expect(result?.[0].mean).toBe(82);
expect(result?.[1].mean).toBe(81);
const args = findMany.mock.calls[0][0];
expect(args.where.granularity).toBe("WEEK");
expect(args.where.userId).toBe("user-1");
expect(args.where.type).toBe("WEIGHT");
expect(args.where.bucketStart.gte).toBe(since);
expect(args.orderBy).toEqual({ bucketStart: "asc" });
});
it("returns null when the WEEK window has no coverage", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readWeekRollups(
"user-1",
"WEIGHT",
new Date("2026-04-20T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readMonthRollups", () => {
it("returns the MONTH buckets and pins the granularity filter", async () => {
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
const result = await readMonthRollups(
"user-2",
"PULSE",
new Date("2026-01-01T00:00:00.000Z"),
);
expect(result).toHaveLength(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("returns null on coverage miss", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readMonthRollups(
"user-2",
"PULSE",
new Date("2026-01-01T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readYearRollups", () => {
it("returns the YEAR buckets and pins the granularity filter", async () => {
findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]);
const result = await readYearRollups(
"user-3",
"WEIGHT",
new Date("2023-01-01T00:00:00.000Z"),
);
expect(result).toHaveLength(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
});
it("returns null on coverage miss", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readYearRollups(
"user-3",
"WEIGHT",
new Date("2023-01-01T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readBestGranularityRollups", () => {
it("returns null on a non-positive window", async () => {
expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull();
@@ -1,120 +0,0 @@
/**
* v1.4.39 W-SUM — read helpers for cumulative-metric daily sums.
*
* The v1.4.36 read path consumes the rollup table for spot-metric means
* via `rollup-read.ts`. Cumulative HK types (steps, flights, distance,
* daylight, active-energy) need the per-day SUM rather than the MEAN —
* every row is a partial-day slice from HealthKit, so MEAN understates
* the daily total by the per-bucket sample count.
*
* The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to
* `measurement_rollups`. The writer in `rollups.ts` populates it on
* every fold (including for non-cumulative types — the cost is one
* extra `SUM` in the existing aggregator). The legacy-NULL backfill
* runs through `enqueueBootTimeRollupBackfill` so existing rows
* converge on the next worker boot.
*
* Callers fall back to `mean * count` when `sumValue` is null. The
* shortcut is mathematically exact for a single source per day:
*
* sum = mean × count = (Σvalue / count) × count = Σvalue
*
* Multiple sources for the same `(user, type, day)` mix into the same
* bucket today — the source-priority resolution that
* `pickCanonicalSourceRows` runs in JS happens AFTER the rollup
* aggregate is built. The W8c per-source rollup deferred to v1.5 fixes
* that; until then, the cumulative read paths trust the bucket sum as
* the canonical daily total and the iOS drain (which collapses per
* source via `dailyStatsExternalId`) keeps Marc's tenant on the
* single-source-per-day path.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
/**
* Cumulative measurement types the rollup `sum_value` column serves.
* Re-exported from the canonical Apple Health mapping so the
* consumer-side gate stays in one place.
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §3
* "Cumulative daily sums" + §5 P3.
*/
export type CumulativeType =
| "ACTIVITY_STEPS"
| "ACTIVE_ENERGY_BURNED"
| "FLIGHTS_CLIMBED"
| "WALKING_RUNNING_DISTANCE"
| "TIME_IN_DAYLIGHT";
/**
* `true` when `type` is one of the cumulative HK types whose daily
* total the rollup `sum_value` column carries.
*/
export function isCumulativeType(type: MeasurementType): type is CumulativeType {
return CUMULATIVE_HK_TYPES.has(type);
}
/** One DAY-bucket row read from `measurement_rollups`. */
export interface CumulativeDaySumRow {
bucketStart: Date;
/**
* Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39
* writer change AND the boot-time backfill has not yet converged on
* this user. Callers fall back to `mean * count` for the null case.
*/
sumValue: number | null;
count: number;
mean: number;
}
/**
* Read every DAY bucket for `(userId, type)` since `since`. Returns
* rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with
* `sumValue = null`; the caller decides whether to fall back to
* `mean * count` or skip them.
*
* Bounded to the cumulative-type set so a misuse (passing a spot
* metric) is a type error rather than a silent SUM on the wrong
* column. The query is a plain `findMany` against the
* `(userId, type, granularity, bucketStart)` composite primary key —
* single round-trip, fully indexed.
*/
export async function readCumulativeDaySums(
userId: string,
type: CumulativeType,
since: Date,
): Promise<CumulativeDaySumRow[]> {
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type: type as MeasurementType,
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: { bucketStart: "asc" },
select: {
bucketStart: true,
sumValue: true,
count: true,
mean: true,
},
});
return rows.map((r) => ({
bucketStart: r.bucketStart,
sumValue: r.sumValue,
count: r.count,
mean: r.mean,
}));
}
/**
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
* v1.4.39 writer has populated it; falls back to `mean * count` for
* legacy NULL rows so the chart never paints a hole during the boot-
* backfill convergence window.
*/
export function resolveBucketSum(row: CumulativeDaySumRow): number {
if (row.sumValue !== null) return row.sumValue;
return row.mean * row.count;
}
+3 -41
View File
@@ -117,43 +117,6 @@ const GRANULARITY_FLOORS: Array<{
{ granularity: "DAY", minWindowDays: 0 },
];
/**
* Read the trailing WEEK buckets for `(userId, type)` since `since`.
* Returns `null` when no buckets exist in the window — the caller
* decides whether to fall back to a finer granularity or to live SQL.
*/
export async function readWeekRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "WEEK", since);
}
/**
* Read the trailing MONTH buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readMonthRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "MONTH", since);
}
/**
* Read the trailing YEAR buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readYearRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "YEAR", since);
}
/**
* Pick the largest granularity that can still resolve the requested
* `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for
@@ -163,10 +126,9 @@ export async function readYearRollups(
* series.
*
* Returns the granularity the helper resolved against plus the row
* shape `readWeekRollups` / `readMonthRollups` / `readYearRollups`
* return. `null` when no granularity yields any buckets in the
* window — the caller is expected to short-circuit to "no data" or
* to live SQL.
* shape the internal `readGranularity` reader produces. `null` when
* no granularity yields any buckets in the window — the caller is
* expected to short-circuit to "no data" or to live SQL.
*/
export async function readBestGranularityRollups(
userId: string,
@@ -459,40 +459,6 @@ export async function recomputeUserMedicationCompliance(
return { rowsUpserted, durationMs: Date.now() - startedAt };
}
/**
* Fire-and-forget warm-up — kept around for symmetry with
* `ensureUserRollupsFresh` even though the on-write hook fully owns
* coverage in steady state. The route currently doesn't call this
* directly; the coverage-probe + boot-backfill cascade handles the
* cold-mount case.
*/
export async function ensureUserMedicationComplianceFresh(
userId: string,
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
tz: string | null | undefined = null,
): Promise<{ recomputed: boolean }> {
try {
if (await hasMedicationComplianceCoverage(userId, days, tz)) {
return { recomputed: false };
}
await recomputeUserMedicationCompliance(userId, days, tz);
return { recomputed: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_refresh_failed: true,
medication_compliance_rollup_refresh_error: message,
},
});
console.error(
"[medication-compliance-rollups] ensure-fresh failed:",
message,
);
return { recomputed: false };
}
}
/**
* v1.4.39 hotfix (QA F-SEC-M-01) — user-scoped enqueue helper for the
* request-path coverage-miss fallback.