mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d3770b9010
commit
74dcc67ef7
23 files changed
+840
-83
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user