diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 52a89ea4c..9d7e61548 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 } : {}), }), }, }); diff --git a/src/lib/ai/reasoning/__tests__/controls.test.ts b/src/lib/ai/reasoning/__tests__/controls.test.ts index 11f6a196f..3e574a3c7 100644 --- a/src/lib/ai/reasoning/__tests__/controls.test.ts +++ b/src/lib/ai/reasoning/__tests__/controls.test.ts @@ -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(); + }); +}); diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts index 9751573ec..7b1420c36 100644 --- a/src/lib/ai/reasoning/controls.ts +++ b/src/lib/ai/reasoning/controls.ts @@ -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 { // 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({ diff --git a/src/lib/documents/__tests__/document-settings.test.ts b/src/lib/documents/__tests__/document-settings.test.ts new file mode 100644 index 000000000..52ab86b53 --- /dev/null +++ b/src/lib/documents/__tests__/document-settings.test.ts @@ -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 | 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); + }); +}); diff --git a/src/lib/documents/document-settings.ts b/src/lib/documents/document-settings.ts index e07aeafd5..44731faaf 100644 --- a/src/lib/documents/document-settings.ts +++ b/src/lib/documents/document-settings.ts @@ -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; + } } diff --git a/src/lib/insights/__tests__/briefing-short-form.test.ts b/src/lib/insights/__tests__/briefing-short-form.test.ts new file mode 100644 index 000000000..394757fa6 --- /dev/null +++ b/src/lib/insights/__tests__/briefing-short-form.test.ts @@ -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(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index e9accd527..55a5865fd 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -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 }).data.insightsCachedText !== + undefined, + ); + const data = (write![0] as { data: Record }).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); + }, + ); +}); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 08a6a8a51..ca0325658 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/usage-level-simulation.test.ts b/src/lib/insights/__tests__/usage-level-simulation.test.ts new file mode 100644 index 000000000..f8891903e --- /dev/null +++ b/src/lib/insights/__tests__/usage-level-simulation.test.ts @@ -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(), + 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(); + +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, + ); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 59e2c9c70..e90d9f602 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 7257869dd..06f213942 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index 422ec7fbc..b8646a92c 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -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" diff --git a/src/lib/insights/briefing-short-form.ts b/src/lib/insights/briefing-short-form.ts new file mode 100644 index 000000000..a877735d0 --- /dev/null +++ b/src/lib/insights/briefing-short-form.ts @@ -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 { + 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(payload: T): T { + if (!payload || typeof payload !== "object") return payload; + const out = { ...(payload as Record) }; + 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) }; + 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; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 824681e30..fd714e06e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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. diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 837e0339a..d8d0acbba 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index c322381bf..57caf5be3 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -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, - 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; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 7b6846d3f..977cbffce 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 26b5748ab..1da61d7e5 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(args.userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 53b4d06dd..355284e78 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 1d1901f91..c0cff4dda 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot); diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index aafcc7d47..033b289fa 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -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 { - 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 { - 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, }, }); } diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 6646c9771..954688f65 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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" diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 59d2d58e4..09dc5328d 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -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, + { maxChars: await statusSnapshotCharsForRecord(userId) }, ); const snapshotJson = JSON.stringify(snapshot);