feat(insights): let the usage level decide the background AI work

The level now sets, per record: the status-card regeneration gap after a
sync (frugal 24 h, standard the operator's gap, full 1 h) and after a hand
entry (frugal 6 h, otherwise 1 h); the status snapshot cap (10 000 compact
characters on frugal, 14 000 above); background reasoning for the briefing
and the period narratives (off on frugal); the daily briefing's form (a
short form on frugal that keeps every field and at most one
recommendation, the full contract on standard and full); and whether the
automatic document read may act (frugal reads on request only, the stored
switch is kept).

A deterministic simulation of a watch-synced day through the invalidator
counts 122 ingest-driven status generations on full (the v1.42 behaviour),
25 on standard and 2 on frugal, plus the nightly warm on each.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:55:40 +02:00
1 parent d3770b9010
commit 74dcc67ef7
23 files changed
+840 -83

No files matched your search

+13
View File
@@ -94,6 +94,11 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
applyBriefingShortForm,
briefingFormForRecord,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
import { featuresInReaderUnits } from "@/lib/insights/features-units";
import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
@@ -753,6 +758,10 @@ export const POST = apiHandler((request: NextRequest) =>
locale,
await resolveUserTimezone(userId),
);
// v1.43 (#1217) — the short form on the `frugal` usage level; the full
// contract on every other level.
const briefingForm = await briefingFormForRecord(userId);
userPrompt += buildBriefingShortFormBlock(locale, briefingForm);
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this briefing actually carries, so a normative claim can cite a real
@@ -1083,6 +1092,9 @@ export const POST = apiHandler((request: NextRequest) =>
errorCode: "insights.generate.profileScopeChanged",
});
}
if (briefingForm === "short") {
insights = applyBriefingShortForm(insights);
}
const stored = withBriefingGeneratedAt(insights, new Date());
const committed = await prisma.user.updateMany({
where: {
@@ -1104,6 +1116,7 @@ export const POST = apiHandler((request: NextRequest) =>
aboutMe: aboutMe ?? null,
comparisonBaseline: comparisonSnapshot?.baseline ?? "none",
generationLocale: locale,
...(briefingForm === "short" ? { briefingForm } : {}),
}),
},
});
@@ -5,8 +5,9 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { row } = vi.hoisted(() => ({
const { row, user } = vi.hoisted(() => ({
row: { current: null as unknown, fail: false },
user: { level: null as string | null },
}));
vi.mock("@/lib/db", () => ({
@@ -17,6 +18,9 @@ vi.mock("@/lib/db", () => ({
return row.current;
}),
},
user: {
findUnique: vi.fn(async () => ({ aiUsageLevel: user.level })),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
@@ -27,11 +31,13 @@ vi.mock("@/lib/logging/context", () => ({
import {
loadReasoningControls,
resolveCoachTurnReasoningLevel,
resolveJobReasoning,
} from "../controls";
beforeEach(() => {
row.current = null;
row.fail = false;
user.level = null;
});
describe("loadReasoningControls", () => {
@@ -88,3 +94,41 @@ describe("resolveCoachTurnReasoningLevel", () => {
);
});
});
// #1217 — background reasoning per AI usage level.
describe("resolveJobReasoning by usage level", () => {
const chain = [{ providerType: "openai" as const }];
it("asks for nothing on frugal", async () => {
user.level = "FRUGAL";
row.current = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" };
expect(
await resolveJobReasoning("daily_briefing", chain, "u1"),
).toBeUndefined();
});
it.each(["STANDARD", "FULL"])(
"keeps the job's level on %s",
async (level) => {
user.level = level;
row.current = {
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
};
expect(await resolveJobReasoning("daily_briefing", chain, "u1")).toEqual({
effort: "medium",
summaries: false,
});
},
);
it("follows a frugal instance default for an account without a choice", async () => {
row.current = {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
};
expect(
await resolveJobReasoning("daily_briefing", chain, "u1"),
).toBeUndefined();
});
});
+29
View File
@@ -13,6 +13,11 @@ import { isOperatorFundedProvider } from "@/lib/ai/coach/budget";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
import type { CompletionParams } from "@/lib/ai/types";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import {
AI_USAGE_LEVEL_EFFECTS,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import {
coachReasoningLevel,
type CoachPrefs,
@@ -167,9 +172,33 @@ export async function loadCoachReasoningState(args: {
export async function resolveJobReasoning(
job: BackgroundReasoningJob,
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
/**
* v1.43 (#1217) — the record the job runs for. On the `frugal` AI usage
* level background jobs do not reason at all; the call goes out as it did
* before reasoning existed. Absent: no level applies.
*/
recordId?: string,
): Promise<CompletionParams["reasoning"]> {
// Reasoning is an improvement to a job, never a precondition for it: any
// failure here sends the call exactly as it went before reasoning existed.
if (recordId !== undefined) {
let level: AiUsageLevel;
try {
level = await loadRecordUsageLevel(recordId);
} catch {
return undefined;
}
if (!AI_USAGE_LEVEL_EFFECTS[level].backgroundReasoning) {
annotate({
meta: {
ai_reasoning_job: job,
ai_reasoning_effort: "off",
ai_reasoning_usage_level: level,
},
});
return undefined;
}
}
let resolved;
try {
resolved = resolveReasoning({
@@ -0,0 +1,64 @@
/**
* #1217 — the automatic document read acts only while both the person's
* switch is on and their AI usage level allows it. On `frugal` documents are
* read on request only; the stored switch is left alone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
user: { documentsAutoAiRead: true, aiUsageLevel: null as string | null },
settings: {
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
} as Record<string, unknown> | null,
failSettings: false,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => db.user) },
appSettings: {
findUnique: vi.fn(async () => {
if (db.failSettings) throw new Error("db down");
return db.settings;
}),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
import { documentAutoReadEnabled } from "../document-settings";
beforeEach(() => {
db.user = { documentsAutoAiRead: true, aiUsageLevel: null };
db.settings = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" };
db.failSettings = false;
});
describe("documentAutoReadEnabled", () => {
it.each([
["FRUGAL", false],
["STANDARD", true],
["FULL", true],
[null, true],
])("level %s with the switch on reads %s", async (level, expected) => {
db.user = { documentsAutoAiRead: true, aiUsageLevel: level };
expect(await documentAutoReadEnabled("u1")).toBe(expected);
});
it("stays off with the switch off, whatever the level", async () => {
db.user = { documentsAutoAiRead: false, aiUsageLevel: "FULL" };
expect(await documentAutoReadEnabled("u1")).toBe(false);
});
it("rests on a frugal instance default", async () => {
db.settings = {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
};
expect(await documentAutoReadEnabled("u1")).toBe(false);
});
});
+18 -4
View File
@@ -12,13 +12,21 @@
* - the summary job and its catch-up pass run only while it is ON;
* - the auto-index-on-upload job stays strictly local when it is OFF and uses
* the document-order external pick when it is ON.
*
* v1.43 (#1217) — on the `frugal` AI usage level the setting rests: documents
* are read on request only, whatever the switch says, and the switch acts
* again as soon as the level allows it. The stored choice is never changed.
*/
import { prisma } from "@/lib/db";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import { AI_USAGE_LEVEL_EFFECTS } from "@/lib/ai/usage-level";
/**
* True when the user has opted into automatic AI reading of uploaded documents.
* A missing row (deleted account raced against a job) resolves to `false` —
* fail-closed to the local-first posture, never to external egress.
* True when the user has opted into automatic AI reading of uploaded documents
* and their AI usage level lets the opt-in act. A missing row (deleted account
* raced against a job) resolves to `false` — fail-closed to the local-first
* posture, never to external egress; so does a usage level that cannot be
* read.
*/
export async function documentAutoReadEnabled(
userId: string,
@@ -27,5 +35,11 @@ export async function documentAutoReadEnabled(
where: { id: userId },
select: { documentsAutoAiRead: true },
});
return row?.documentsAutoAiRead ?? false;
if (!(row?.documentsAutoAiRead ?? false)) return false;
try {
const level = await loadRecordUsageLevel(userId);
return AI_USAGE_LEVEL_EFFECTS[level].documentsAutoRead;
} catch {
return false;
}
}
@@ -0,0 +1,123 @@
/**
* #1217 — the briefing's short form on the `frugal` usage level. The contract
* keeps its shape on every level: `recommendations` stays present (the
* native client decodes it as a required array), only shorter on `frugal`,
* and untouched on `standard` and `full`.
*/
import { describe, expect, it } from "vitest";
import { aiInsightResponseSchema } from "@/lib/ai/schema";
import { AI_USAGE_LEVEL_EFFECTS, AI_USAGE_LEVELS } from "@/lib/ai/usage-level";
import {
SHORT_FORM_LIMITS,
applyBriefingShortForm,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
function rec(id: string) {
return {
id,
text: `Recommendation ${id}`,
severity: "suggestion",
metricSource: {
type: "bloodPressure",
timeRange: "last30days",
summary: "avg 128/82",
},
rationale: {
dataWindow: "last30days",
comparedTo: "your 90-day median",
deviation: "+4 mmHg",
},
};
}
function finding(n: number) {
return {
tone: "info",
headline: `Finding ${n}`,
detail: "One sentence.",
delta: null,
sourceWindow: "30d",
sourceMetric: "bp",
};
}
const PAYLOAD = {
summary: "A steady day.",
recommendations: [rec("rec-1"), rec("rec-2"), rec("rec-3")],
citations: [
{ type: "bloodPressure", timeRange: "last30days", summary: "avg 128/82" },
],
warnings: [],
dailyBriefing: {
paragraph: "Today reads like a steady day.",
keyFindings: [finding(1), finding(2), finding(3), finding(4)],
},
trendAnnotations: { bp: "Steady over the month." },
storyboardAnnotations: [],
};
describe("briefing form per usage level", () => {
it("keeps the full contract on standard and full, the short form on frugal", () => {
expect(
AI_USAGE_LEVELS.map((level) => AI_USAGE_LEVEL_EFFECTS[level].briefing),
).toEqual(["short", "full", "full"]);
});
it("adds nothing to the prompt for the full form", () => {
expect(buildBriefingShortFormBlock("en", "full")).toBe("");
expect(buildBriefingShortFormBlock("de", "full")).toBe("");
});
it("asks for at most one recommendation on the short form, field kept", () => {
const en = buildBriefingShortFormBlock("en", "short");
expect(en).toContain('"recommendations": at most 1');
expect(en).toContain("every required field");
expect(buildBriefingShortFormBlock("de", "short")).toContain(
'"recommendations": höchstens 1',
);
});
});
describe("applyBriefingShortForm", () => {
it("validates against the briefing schema before and after", () => {
expect(aiInsightResponseSchema.safeParse(PAYLOAD).success).toBe(true);
const short = applyBriefingShortForm(PAYLOAD);
const parsed = aiInsightResponseSchema.safeParse(short);
expect(parsed.success).toBe(true);
});
it("keeps recommendations present, at most one", () => {
const short = applyBriefingShortForm(PAYLOAD);
expect(Array.isArray(short.recommendations)).toBe(true);
expect(short.recommendations).toHaveLength(
SHORT_FORM_LIMITS.recommendations,
);
expect(short.recommendations[0]!.id).toBe("rec-1");
expect(short.dailyBriefing.keyFindings).toHaveLength(
SHORT_FORM_LIMITS.keyFindings,
);
expect(short).not.toHaveProperty("trendAnnotations");
expect(short).not.toHaveProperty("storyboardAnnotations");
// Every required field of the contract is still there.
for (const key of ["summary", "recommendations", "citations", "warnings"]) {
expect(short).toHaveProperty(key);
}
});
it("keeps an empty recommendation list an empty list", () => {
const short = applyBriefingShortForm({ ...PAYLOAD, recommendations: [] });
expect(short.recommendations).toEqual([]);
});
it("leaves the input untouched", () => {
const before = structuredClone(PAYLOAD);
applyBriefingShortForm(PAYLOAD);
expect(PAYLOAD).toEqual(before);
});
it("passes a non-object through", () => {
expect(applyBriefingShortForm(null)).toBeNull();
});
});
@@ -495,3 +495,83 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
});
});
// #1217 — the frugal usage level asks for the short briefing and holds its
// counts; the fingerprint carries the form so a level change regenerates.
describe("generateComprehensiveInsight — usage level", () => {
const reply = {
summary: "s",
recommendations: ["a", "b", "c"].map((id) => ({
id,
text: `Keep walking ${id}`,
severity: "info",
})),
citations: [],
warnings: [],
dailyBriefing: { paragraph: "p" },
};
function arrange(aiUsageLevel: string | null) {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH,
aiUsageLevel,
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify(reply),
tokensUsed: 10,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
}
function storedPayload() {
const write = userUpdate.mock.calls.find(
(c) =>
(c[0] as { data: Record<string, unknown> }).data.insightsCachedText !==
undefined,
);
const data = (write![0] as { data: Record<string, unknown> }).data;
return {
payload: JSON.parse(data.insightsCachedText as string),
hash: data.insightsSnapshotHash,
};
}
it("asks a frugal account for the short form and keeps one recommendation", async () => {
arrange("FRUGAL");
const outcome = await generateComprehensiveInsight("u1", {
locale: "en",
force: true,
});
expect(outcome.status).toBe("generated");
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
expect(prompt).toContain("SHORT FORM");
const { payload, hash } = storedPayload();
expect(payload.recommendations.map((r: { id: string }) => r.id)).toEqual([
"a",
]);
expect(hash).not.toBe(FEATURES_HASH);
});
it.each(["STANDARD", "FULL", null])(
"keeps the full contract on %s",
async (level) => {
arrange(level);
await generateComprehensiveInsight("u1", { locale: "en", force: true });
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
expect(prompt).not.toContain("SHORT FORM");
expect(storedPayload().payload.recommendations).toHaveLength(3);
},
);
});
@@ -452,7 +452,6 @@ describe("regeneration gap by ingest source", () => {
source: "manual",
});
expect(probedGapHours(before)).toBe(1);
expect(appSettingsFindUnique).not.toHaveBeenCalled();
});
it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => {
@@ -493,3 +492,67 @@ describe("regeneration gap by ingest source", () => {
expect(probedGapHours(before)).toBe(24);
});
});
// #1217 — the gap per AI usage level: frugal holds a sync to a day and a hand
// entry to six hours, standard uses the operator's sync gap and the hour,
// full keeps the hour for both (the behaviour before v1.43).
describe("regeneration gap by usage level", () => {
const cases: Array<
[level: string | null, source: "sync" | "manual", hours: number]
> = [
["FRUGAL", "sync", 24],
["FRUGAL", "manual", 6],
["STANDARD", "sync", 6],
["STANDARD", "manual", 1],
["FULL", "sync", 1],
["FULL", "manual", 1],
// No choice: the instance default (standard).
[null, "sync", 6],
[null, "manual", 1],
];
it.each(cases)(
"%s after a %s ingest waits %i h",
async (level, source, hours) => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: level });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source,
});
expect(probedGapHours(before)).toBe(hours);
},
);
it("caps a stored full choice at the operator's ceiling", async () => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: "FULL" });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: 8,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source: "sync",
});
expect(probedGapHours(before)).toBe(8);
});
it("follows a frugal instance default for an account without a choice", async () => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: null });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
aiSyncRegenGapHours: null,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source: "sync",
});
expect(probedGapHours(before)).toBe(24);
});
});
@@ -0,0 +1,210 @@
/**
* #1217 — what the usage levels do to a watch-synced account, simulated
* deterministically through the real invalidator.
*
* One day of a phone that delivers a watch's samples every 15 minutes from
* 07:00 to 23:00, the morning sleep and recovery figures, two hand-entered
* blood-pressure readings and a hand-entered weight. Every scope the
* invalidator enqueues counts as one generation, the worst case the issue
* describes: today's partial bucket moves on every sync, so the content-hash
* gate never short-circuits a sync-driven run. The 04:30 nightly warm
* regenerates every scope once on every level and is counted apart.
*
* The clock, the cache rows and the queue are in memory; the scope mapping,
* the per-source gap and the level resolution are the production code.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const sim = vi.hoisted(() => ({
notes: new Map<string, number>(),
generations: 0,
level: null as string | null,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
locale: "en",
aiUsageLevel: sim.level,
})),
},
appSettings: {
findUnique: vi.fn(async () => ({
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
})),
},
insightStatusCache: {
findMany: vi.fn(
async (args: {
where: {
OR: Array<{ metric: string; locale: string }>;
generatedAt: { gte: Date };
};
}) =>
args.where.OR.filter(
(key) =>
(sim.notes.get(key.metric) ?? -Infinity) >=
args.where.generatedAt.gte.getTime(),
).map((key) => ({ metric: key.metric, locale: key.locale })),
),
},
},
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => {
sim.generations += 1;
sim.notes.set(job.metric, Date.now());
}),
}));
vi.mock("@/lib/measurements/live-types", () => ({
listLiveMeasurementTypes: vi.fn(async () => []),
}));
import { invalidateStatusInsightsForTypes } from "../status-invalidation";
import type { MeasurementType } from "@/generated/prisma/client";
type Event = {
at: string;
source: "sync" | "manual";
types: MeasurementType[];
};
/** One day of a watch account, minute-exact and the same on every run. */
function day(): Event[] {
const events: Event[] = [];
for (let minutes = 7 * 60; minutes <= 23 * 60; minutes += 15) {
const hh = String(Math.floor(minutes / 60)).padStart(2, "0");
const mm = String(minutes % 60).padStart(2, "0");
const types: MeasurementType[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"PULSE",
"FLIGHTS_CLIMBED",
];
if (minutes % 60 === 0)
types.push("TIME_IN_DAYLIGHT", "AUDIO_EXPOSURE_ENV");
if (minutes % 120 === 0) {
types.push("HEART_RATE_VARIABILITY", "WALKING_HEART_RATE_AVERAGE");
}
if (minutes === 7 * 60) {
types.push(
"SLEEP_DURATION",
"RESTING_HEART_RATE",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"WRIST_TEMPERATURE",
"VO2_MAX",
);
}
events.push({ at: `${hh}:${mm}`, source: "sync", types });
}
events.push(
{ at: "07:30", source: "manual", types: ["WEIGHT"] },
{
at: "08:00",
source: "manual",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
{
at: "20:00",
source: "manual",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
);
return events.sort((a, b) => a.at.localeCompare(b.at));
}
const ALL_SCOPES_AT_WARM = new Set<string>();
async function simulate(level: string | null): Promise<{
warm: number;
ingest: number;
bySource: Record<"sync" | "manual", number>;
}> {
sim.notes.clear();
sim.generations = 0;
sim.level = level;
// The 04:30 warm regenerates every scope the day will touch.
const warmAt = Date.parse("2026-10-15T04:30:00Z");
for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt);
const warm = ALL_SCOPES_AT_WARM.size;
const bySource = { sync: 0, manual: 0 };
for (const event of day()) {
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
const before = sim.generations;
await invalidateStatusInsightsForTypes("u1", event.types, {
source: event.source,
});
bySource[event.source] += sim.generations - before;
}
return { warm, ingest: sim.generations, bySource };
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
describe("generations per day on a watch-synced account", () => {
it("counts the scopes once to seed the warm", async () => {
// Discover every scope the day dirties: run it once on `full` with no
// warm, which enqueues each scope at least once.
ALL_SCOPES_AT_WARM.clear();
sim.notes.clear();
sim.level = "FULL";
for (const event of day()) {
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
await invalidateStatusInsightsForTypes("u1", event.types, {
source: event.source,
});
}
for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope);
expect(ALL_SCOPES_AT_WARM.size).toBe(19);
});
it("before v1.43 (every ingest at one hour) versus each level", async () => {
const full = await simulate("FULL"); // the behaviour before v1.43
const standard = await simulate("STANDARD");
const frugal = await simulate("FRUGAL");
const result = { full, standard, frugal };
process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`);
// Ingest-driven generations a day (the warm adds 19 on every level).
expect(full).toEqual({
warm: 19,
ingest: 122,
bySource: { sync: 118, manual: 4 },
});
expect(standard).toEqual({
warm: 19,
ingest: 25,
bySource: { sync: 20, manual: 5 },
});
expect(frugal).toEqual({
warm: 19,
ingest: 2,
bySource: { sync: 0, manual: 2 },
});
// The contract's aim: standard well below a quarter of the old ingest
// volume, and below a third with the warm counted.
expect(standard.ingest / full.ingest).toBeLessThan(0.25);
expect(
(standard.warm + standard.ingest) / (full.warm + full.ingest),
).toBeLessThan(0.32);
// Hand entries are narrated within the hour on standard, as before; on
// full the sync drip had often refreshed the same scope just before.
expect(standard.bySource.manual).toBeGreaterThanOrEqual(
full.bySource.manual,
);
});
});
@@ -42,6 +42,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -641,6 +642,7 @@ export async function prepareBloodPressureStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+2
View File
@@ -19,6 +19,7 @@ import {
scaleGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -334,6 +335,7 @@ export async function prepareBmiStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+1 -1
View File
@@ -127,7 +127,7 @@ export async function runBriefingCompletion(
// v1.41 — one resolution per call: the operator's switch and cap, the payer
// from the chain's primary. `undefined` is today's wire.
const reasoning = args.reasoningJob
? await resolveJobReasoning(args.reasoningJob, args.chain)
? await resolveJobReasoning(args.reasoningJob, args.chain, args.userId)
: undefined;
const thinkingTokens =
reasoning && reasoning.effort !== "off"
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.43 (#1217) — the daily briefing's short form, for the `frugal` AI usage
* level.
*
* The person chose the level knowingly, so a shorter briefing is fine there:
* the lead and today, at most one recommendation, no trend or storyboard
* annotations. The contract keeps its shape on every level: every required
* field stays present (`recommendations` is decoded as a required array by
* the native client), only shorter. `standard` and `full` keep the full
* contract, recommendations included.
*
* The prompt asks for the short form; {@link applyBriefingShortForm} holds the
* counts afterwards, so a model that writes more than asked cannot widen it.
*/
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import {
AI_USAGE_LEVEL_EFFECTS,
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
} from "@/lib/ai/usage-level";
import type { Locale } from "@/lib/i18n/config";
export type BriefingForm = "full" | "short";
/** At most this many entries of each list on the short form. */
export const SHORT_FORM_LIMITS = Object.freeze({
recommendations: 1,
signalsOfDay: 2,
keyFindings: 2,
});
/**
* The briefing form for one record's level. An unreadable level reads as the
* instance default's form.
*/
export async function briefingFormForRecord(
recordId: string,
): Promise<BriefingForm> {
try {
return AI_USAGE_LEVEL_EFFECTS[await loadRecordUsageLevel(recordId)]
.briefing;
} catch {
return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL].briefing;
}
}
/**
* The block appended to the briefing's user prompt on the short form. Empty
* for the full form, so a `standard` or `full` prompt is unchanged.
*/
export function buildBriefingShortFormBlock(
locale: Locale,
form: BriefingForm,
): string {
if (form === "full") return "";
if (instructionLocale(locale) === "de") {
return `
KURZFORM (vom Nutzer gewählte sparsame KI-Stufe)
Schreibe dieselbe JSON-Struktur mit allen Pflichtfeldern, nur kürzer:
- "summary": ein bis zwei Sätze.
- "dailyBriefing.paragraph": 40 bis 80 Wörter, ein Absatz: das Urteil des Tages und was es für heute heißt.
- "dailyBriefing.signalsOfDay": höchstens ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": höchstens ${SHORT_FORM_LIMITS.keyFindings}.
- "recommendations": höchstens ${SHORT_FORM_LIMITS.recommendations}, die wichtigste; ein leeres Array, wenn nichts ansteht.
- "trendAnnotations" und "storyboardAnnotations" weglassen.
Alle übrigen Regeln gelten unverändert.`;
}
return `
SHORT FORM (the person chose the frugal AI usage level)
Write the same JSON structure with every required field, only shorter:
- "summary": one or two sentences.
- "dailyBriefing.paragraph": 40 to 80 words, one paragraph: the day's verdict and what it means for today.
- "dailyBriefing.signalsOfDay": at most ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": at most ${SHORT_FORM_LIMITS.keyFindings}.
- "recommendations": at most ${SHORT_FORM_LIMITS.recommendations}, the most important one; an empty array when nothing is due.
- Leave out "trendAnnotations" and "storyboardAnnotations".
Every other rule still applies.`;
}
/**
* Hold a parsed briefing payload to the short form's counts. Pure: returns a
* copy, every field still present. A payload that is not an object comes
* back as it was.
*/
export function applyBriefingShortForm<T>(payload: T): T {
if (!payload || typeof payload !== "object") return payload;
const out = { ...(payload as Record<string, unknown>) };
if (Array.isArray(out.recommendations)) {
out.recommendations = out.recommendations.slice(
0,
SHORT_FORM_LIMITS.recommendations,
);
}
delete out.trendAnnotations;
delete out.storyboardAnnotations;
const briefing = out.dailyBriefing;
if (briefing && typeof briefing === "object") {
const b = { ...(briefing as Record<string, unknown>) };
if (Array.isArray(b.signalsOfDay)) {
b.signalsOfDay = b.signalsOfDay.slice(0, SHORT_FORM_LIMITS.signalsOfDay);
}
if (Array.isArray(b.keyFindings)) {
b.keyFindings = b.keyFindings.slice(0, SHORT_FORM_LIMITS.keyFindings);
}
out.dailyBriefing = b;
}
return out as T;
}
@@ -104,6 +104,11 @@ import {
stripJsonFences,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
applyBriefingShortForm,
briefingFormForRecord,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
@@ -1008,11 +1013,16 @@ export async function generateComprehensiveInsight(
const planProgressLines = await buildPlanProgressLines(userId).catch(
() => [] as string[],
);
// v1.43 (#1217) — the short form on the `frugal` usage level. It joins the
// fingerprint only when it applies, so every other account keeps its stored
// hash, and a level change regenerates the briefing once.
const briefingForm = await briefingFormForRecord(userId);
const snapshotHash = hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
comparisonBaseline,
generationLocale: locale,
...(briefingForm === "short" ? { briefingForm } : {}),
// Absent without an active plan, so an account without one keeps its
// stored fingerprint; with one, a plan that moves regenerates the
// briefing the way moved data does.
@@ -1076,6 +1086,7 @@ export async function generateComprehensiveInsight(
locale,
await resolveUserTimezone(userId),
);
userPrompt += buildBriefingShortFormBlock(locale, briefingForm);
const systemPrompt = buildSystemPromptWithReferences(
locale,
referenceMetrics,
@@ -1589,6 +1600,11 @@ export async function generateComprehensiveInsight(
return failBeforeProvider(userId, locale, "aborted");
}
// v1.43 (#1217) — the short form holds its counts whatever the model wrote.
if (briefingForm === "short") {
insights = applyBriefingShortForm(insights);
}
// The payload was built under the privacy and profile scope captured before
// the provider call. Recheck both before the write so an inclusion change
// cannot be overwritten by stale in-flight content.
+2
View File
@@ -22,6 +22,7 @@ import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
normalizeLocale,
finalizeStatusSummary,
@@ -418,6 +419,7 @@ export async function prepareGeneralStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+18 -8
View File
@@ -698,9 +698,9 @@ export function convertGradedSeries(
export const MAX_SNAPSHOT_CHARS = 14_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
* `MAX_SNAPSHOT_CHARS` (measured against the compact form the prompt
* ships), shed the lowest-signal slices in order:
* Walk an arbitrary snapshot object and, while it exceeds the cap
* (`options.maxChars`, else `MAX_SNAPSHOT_CHARS`; measured against the
* compact form the prompt ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
@@ -714,11 +714,21 @@ export const MAX_SNAPSHOT_CHARS = 14_000;
*/
export function degradeStatusSnapshotToBudget(
snapshot: Record<string, unknown>,
arrayTailCap = 30,
options: {
/**
* v1.43 (#1217) — the cap for this record's AI usage level
* (`statusSnapshotChars`, `@/lib/ai/usage-level`). Defaults to
* `MAX_SNAPSHOT_CHARS`.
*/
maxChars?: number;
arrayTailCap?: number;
} = {},
): string[] {
const maxChars = options.maxChars ?? MAX_SNAPSHOT_CHARS;
const arrayTailCap = options.arrayTailCap ?? 30;
const shed: string[] = [];
const size = () => JSON.stringify(snapshot).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
if (size() <= maxChars) return shed;
const walk = (
node: unknown,
@@ -746,14 +756,14 @@ export function degradeStatusSnapshotToBudget(
};
dropField("yearly");
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("yearly");
return shed;
}
shed.push("yearly");
dropField("weekly");
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("weekly");
return shed;
}
@@ -769,7 +779,7 @@ export function degradeStatusSnapshotToBudget(
parent[key] = value.slice(-arrayTailCap);
}
});
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("array-tails");
return shed;
}
@@ -28,6 +28,7 @@ import {
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
@@ -405,6 +406,7 @@ export async function prepareMedicationComplianceStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+2
View File
@@ -62,6 +62,7 @@ import {
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
type SupportedLocale,
normalizeLocale,
@@ -453,6 +454,7 @@ export async function generateMetricStatus(args: {
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(args.userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+2
View File
@@ -38,6 +38,7 @@ import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -664,6 +665,7 @@ export async function prepareMoodStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+2
View File
@@ -27,6 +27,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -391,6 +392,7 @@ export async function preparePulseStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);
+34 -67
View File
@@ -31,6 +31,8 @@ import {
import { annotate } from "@/lib/logging/context";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import { loadRecordAiUsage } from "@/lib/ai/capabilities/usage";
import { statusRegenGapHours, type IngestSource } from "@/lib/ai/usage-level";
import type { MeasurementType } from "@/generated/prisma/client";
/**
@@ -39,82 +41,41 @@ import type { MeasurementType } from "@/generated/prisma/client";
*
* - `manual`: a reading the person entered or changed by hand (the web
* form, the phone's entry sheet, a Telegram reply, an MCP write, a
* delete or restore). It should be narrated the same day, so its gap
* stays short.
* delete or restore, a file they chose to import). It should be narrated
* the same day, so its gap stays short.
* - `sync`: rows a device or an integration delivered on its own schedule
* (Apple Health, Health Connect, Withings, Oura, Fitbit, Polar, WHOOP,
* Nightscout, Google Health, a file import, a computed score). A watch
* drips these all day, and every drip moves today's partial bucket, so
* the content-hash gate cannot catch them; only a longer gap does.
* (the phone's Apple Health sync, Withings, Oura, Fitbit, Polar, WHOOP,
* Nightscout, Google Health, a computed score). A watch drips these all
* day, and every drip moves today's partial bucket, so the content-hash
* gate cannot catch them; only a longer gap does.
*/
export type StatusIngestSource = "manual" | "sync";
export type StatusIngestSource = IngestSource;
/**
* Minimum gap between a scope's last cached (re)generation and the next
* ingest-driven re-enqueue, for a hand entry. A scope whose cached
* assessment was (re)generated within the gap is left intact and not
* re-enqueued.
* The minimum gap between a scope's last cached (re)generation and the next
* ingest-driven re-enqueue. A scope whose cached assessment was
* (re)generated within the gap is left intact and not re-enqueued.
*
* History: v1.9.0 introduced the window (30 min) because a constantly
* syncing client re-enqueued every dirtied scope on every batch. v1.16.8
* widened it to 6 h for every ingest, which was rolled back to 1 h because a
* lone 08:00 reading after the 04:30 nightly warm was not narrated the same
* day. The gap is now chosen by source: a hand entry keeps the hour, a sync
* gets {@link DEFAULT_SYNC_REGEN_GAP_HOURS} (or the operator's value).
* day. Since v1.43 (#1217) the gap is chosen by source and by the record's AI
* usage level (`statusRegenGapHours` in `@/lib/ai/usage-level`): a hand entry
* keeps the hour (six on `frugal`), a sync waits the operator's sync gap on
* `standard` (default six hours), a day on `frugal`, the hour on `full`.
*/
const MANUAL_INGEST_MIN_GAP_MS = 60 * 60 * 1000;
/**
* The sync gap when neither the operator's setting
* (`AppSettings.aiSyncRegenGapHours`) nor `AI_SYNC_REGEN_GAP_HOURS` names one.
* Six hours turns a watch's ~16 regenerations per scope and day into ~3.
*/
export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
/** The operator's sync gap bounds, shared with the admin setting. */
export const SYNC_REGEN_GAP_HOURS_MIN = 1;
export const SYNC_REGEN_GAP_HOURS_MAX = 48;
/** A gap in hours, or null when the value is not a usable whole hour count. */
function parseGapHours(value: unknown): number | null {
const n =
typeof value === "number"
? value
: typeof value === "string" && value.trim() !== ""
? Number(value)
: NaN;
if (!Number.isInteger(n)) return null;
if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null;
return n;
}
/**
* The instance's sync gap in hours: the operator's admin setting, else
* `AI_SYNC_REGEN_GAP_HOURS`, else six. An unreadable settings row falls back
* to the environment, never to a shorter gap.
*/
export async function loadSyncRegenGapHours(): Promise<number> {
let stored: number | null = null;
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { aiSyncRegenGapHours: true },
});
stored = parseGapHours(row?.aiSyncRegenGapHours);
} catch {
stored = null;
}
return (
stored ??
parseGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ??
DEFAULT_SYNC_REGEN_GAP_HOURS
async function ingestRegenGapMs(
userId: string,
source: StatusIngestSource,
): Promise<{ gapMs: number; level: string }> {
const usage = await loadRecordAiUsage(userId);
const hours = statusRegenGapHours(
usage.level,
source,
usage.settings.syncRegenGapHours,
);
}
/** The regeneration gap for one ingest, in milliseconds. */
async function ingestRegenGapMs(source: StatusIngestSource): Promise<number> {
if (source === "manual") return MANUAL_INGEST_MIN_GAP_MS;
return (await loadSyncRegenGapHours()) * 60 * 60 * 1000;
return { gapMs: hours * 60 * 60 * 1000, level: usage.level };
}
/**
@@ -234,7 +195,7 @@ export async function invalidateStatusInsightsForTypes(
// only the redundant churn is removed. Past the gap the enqueue always
// goes through — the worker's content-hash gate decides whether the
// batch actually changed anything (see the constant's doc).
const gapMs = await ingestRegenGapMs(options.source);
const { gapMs, level } = await ingestRegenGapMs(userId, options.source);
const freshScopes = await findRecentlyWarmedScopes(
userId,
locale,
@@ -247,7 +208,12 @@ export async function invalidateStatusInsightsForTypes(
if (staleScopes.length === 0) {
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: scopes.size, refreshed: 0, source: options.source },
meta: {
skipped: scopes.size,
refreshed: 0,
source: options.source,
usage_level: level,
},
});
return;
}
@@ -270,6 +236,7 @@ export async function invalidateStatusInsightsForTypes(
skipped: freshScopes.size,
refreshed: staleScopes.length,
source: options.source,
usage_level: level,
},
});
}
+1 -1
View File
@@ -216,7 +216,7 @@ export async function runStatusCompletion(
// so a refused call never reads the controls. `undefined` (the operator
// switched reasoning off, or the job resolved off) is today's wire.
const reasoning = args.reasoningJob
? await resolveJobReasoning(args.reasoningJob, chain)
? await resolveJobReasoning(args.reasoningJob, chain, userId)
: undefined;
const thinkingTokens =
reasoning && reasoning.effort !== "off"
+2
View File
@@ -33,6 +33,7 @@ import {
convertGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
@@ -607,6 +608,7 @@ export async function prepareWeightStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot);