mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'v143/ai' into release/v1.43.0
# Conflicts: # docs/api/openapi.yaml # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json # src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
commit
fd44a8381b
143 files changed
+4806
-628
No files matched your search
@@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types";
|
||||
import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types";
|
||||
import type { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
|
||||
@@ -283,3 +284,29 @@ describe("the status chokepoint's capability argument", () => {
|
||||
expect(withCapability.capability).toBe("statusText");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — capabilities whose model call has not landed yet. The
|
||||
* capability is published (clients can render its state) before the route
|
||||
* that reads a label photo exists; until then that route must not reach a
|
||||
* provider at all. When it does, it becomes an egress site above, re-checking
|
||||
* its capability at the wire like every document read, and leaves this list.
|
||||
*/
|
||||
const CAPABILITY_EGRESS_PENDING: Record<string, string> = {
|
||||
supplementLabel: "app/api/medications/[id]/ingredients/extract/route.ts",
|
||||
};
|
||||
|
||||
describe("capabilities whose model call is still to come", () => {
|
||||
it.each(Object.entries(CAPABILITY_EGRESS_PENDING))(
|
||||
"%s is a published capability whose route does not call a model yet",
|
||||
(key, rel) => {
|
||||
expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key);
|
||||
const text = code(rel);
|
||||
expect(
|
||||
EGRESS_CALL.test(text),
|
||||
`${rel} calls a provider now: list it in EGRESS_SITES with its re-check and drop it here`,
|
||||
).toBe(false);
|
||||
expect(/\bnotImplemented\s*\(/.test(text)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -38,7 +38,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
|
||||
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
@@ -62,7 +61,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 23;
|
||||
const REGISTER_SIZE = 21;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -92,6 +92,8 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
|
||||
// v1.43 — the AI usage bounds: a settings save, not a write with counts.
|
||||
"src/components/admin/ai-usage-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
|
||||
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
|
||||
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
|
||||
import { CentralCodexSection } from "@/components/admin/central-codex-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { AiUsageSection } from "@/components/admin/ai-usage-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
@@ -105,6 +106,7 @@ export function AdminSectionRenderer({
|
||||
return (
|
||||
<SectionFrame>
|
||||
<AssistantSection />
|
||||
<AiUsageSection />
|
||||
<AiServerKeySection />
|
||||
<CentralCodexSection />
|
||||
<ProviderHealthSection />
|
||||
|
||||
@@ -165,6 +165,53 @@ describe("AI capability route inventory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Capabilities no route asks, each with its reason. Every other capability is
|
||||
* asked by at least one route on `AI_ROUTES`, so a key cannot be added to the
|
||||
* table and forgotten at the edge.
|
||||
*
|
||||
* - `reactionLines`: written by a job after a new reading arrives, never on
|
||||
* request; the job asks `aiCapabilityForJob`.
|
||||
* - `supplementLabel` (v1.43, #1217): its route
|
||||
* (`medications/[id]/ingredients/extract`) is a contract stub until the
|
||||
* supplement package fills it. The check below fails the moment the stub
|
||||
* answers anything but 501, so the entry has to move to `AI_ROUTES` with
|
||||
* the gate.
|
||||
*/
|
||||
const CAPABILITIES_WITHOUT_ROUTE: Readonly<
|
||||
Partial<Record<(typeof AI_CAPABILITY_KEYS)[number], string>>
|
||||
> = {
|
||||
reactionLines: "job only",
|
||||
supplementLabel: "src/app/api/medications/[id]/ingredients/extract/route.ts",
|
||||
};
|
||||
|
||||
describe("every capability reaches a route", () => {
|
||||
it("is asked by an AI route, or named with its reason", () => {
|
||||
const asked = new Set(
|
||||
Object.values(AI_ROUTES).flatMap((entry) => [...entry.capabilities]),
|
||||
);
|
||||
const orphaned = AI_CAPABILITY_KEYS.filter(
|
||||
(key) => !asked.has(key) && !(key in CAPABILITIES_WITHOUT_ROUTE),
|
||||
);
|
||||
expect(orphaned).toEqual([]);
|
||||
const stale = Object.keys(CAPABILITIES_WITHOUT_ROUTE).filter((key) =>
|
||||
asked.has(key as (typeof AI_CAPABILITY_KEYS)[number]),
|
||||
);
|
||||
expect(stale, "asked by a route now: drop it from the list").toEqual([]);
|
||||
});
|
||||
|
||||
it("knows supplementLabel, and its route is still the stub", () => {
|
||||
expect(AI_CAPABILITY_KEYS).toContain("supplementLabel");
|
||||
const path = CAPABILITIES_WITHOUT_ROUTE.supplementLabel!;
|
||||
expect(ROUTES).toContain(path);
|
||||
const text = code(path);
|
||||
expect(
|
||||
/\bnotImplemented\s*\(/.test(text) && !ANY_GATE.test(text),
|
||||
`${path} is no longer a stub: gate it with requireAiCapability("supplementLabel") and list it on AI_ROUTES`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The route that queues each kind of background run. */
|
||||
const RUN_QUEUED_BY: Record<DocumentAiRunKindValue, string> = {
|
||||
DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts",
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
// v1.42 — the operator key's model check reads the admin settings.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
// v1.43 — accounts per effective AI usage level.
|
||||
user: { groupBy: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -378,3 +380,43 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
expect(probeModelListing).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #1217 — the operator sees how many accounts run at each level, resolved
|
||||
// against the current bounds, so lowering the ceiling moves the counts.
|
||||
describe("usage levels", () => {
|
||||
const userGroups = vi.mocked(prisma.user.groupBy);
|
||||
const settingsRow = vi.mocked(prisma.appSettings.findUnique);
|
||||
|
||||
beforeEach(() => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("counts each account at its effective level", async () => {
|
||||
settingsRow.mockResolvedValue({
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as never);
|
||||
userGroups.mockResolvedValue([
|
||||
{ aiUsageLevel: null, _count: { _all: 5 } },
|
||||
{ aiUsageLevel: "FRUGAL", _count: { _all: 2 } },
|
||||
// Above the ceiling: capped to standard.
|
||||
{ aiUsageLevel: "FULL", _count: { _all: 3 } },
|
||||
] as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.usageLevels).toEqual({ frugal: 2, standard: 8, full: 0 });
|
||||
});
|
||||
|
||||
it("reports zero everywhere on an empty instance", async () => {
|
||||
settingsRow.mockResolvedValue(null);
|
||||
userGroups.mockResolvedValue([] as never);
|
||||
const res = await GET();
|
||||
expect((await res.json()).data.usageLevels).toEqual({
|
||||
frugal: 0,
|
||||
standard: 0,
|
||||
full: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isAnthropicBaseUrl } from "@/lib/ai/provider";
|
||||
import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities";
|
||||
import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage";
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
resolveAiUsageLevel,
|
||||
usageLevelFromDb,
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -186,9 +193,37 @@ export const GET = apiHandler(async () => {
|
||||
operatorTokens: spend.operator,
|
||||
accounts: spend.accounts,
|
||||
},
|
||||
usageLevels: await countAccountsByUsageLevel(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — accounts per EFFECTIVE AI usage level: each stored choice
|
||||
* (or none) resolved against the operator's current bounds, so the readout
|
||||
* moves the moment the operator lowers the ceiling or the default. One
|
||||
* grouped read over the users' stored choices.
|
||||
*/
|
||||
async function countAccountsByUsageLevel(): Promise<
|
||||
Record<AiUsageLevel, number>
|
||||
> {
|
||||
const [settings, groups] = await Promise.all([
|
||||
loadAiUsageSettings(),
|
||||
prisma.user.groupBy({ by: ["aiUsageLevel"], _count: { _all: true } }),
|
||||
]);
|
||||
const counts = Object.fromEntries(
|
||||
AI_USAGE_LEVELS.map((level) => [level, 0]),
|
||||
) as Record<AiUsageLevel, number>;
|
||||
for (const group of groups) {
|
||||
const { level } = resolveAiUsageLevel({
|
||||
preference: usageLevelFromDb(group.aiUsageLevel),
|
||||
defaultLevel: settings.defaultLevel,
|
||||
maxLevel: settings.maxLevel,
|
||||
});
|
||||
counts[level] += group._count._all;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model check for the operator's key. Never fails the readout: a missing
|
||||
* settings row, a key that does not decrypt, or a probe error is simply no
|
||||
|
||||
@@ -48,6 +48,9 @@ const ALL_ON = {
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: "high",
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -211,6 +214,75 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// #1217 — the operator's usage bounds.
|
||||
it("stores the usage bounds and echoes them with the gap in effect", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiUsageMaxLevel: "FULL",
|
||||
aiUsageDefaultLevel: "FRUGAL",
|
||||
aiSyncRegenGapHours: 12,
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "full",
|
||||
defaultLevel: "frugal",
|
||||
syncRegenGapHours: 12,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
|
||||
expect(upsert.update).toEqual({
|
||||
aiUsageMaxLevel: "FULL",
|
||||
aiUsageDefaultLevel: "FRUGAL",
|
||||
aiSyncRegenGapHours: 12,
|
||||
});
|
||||
const body = (await res.json()) as { data: { aiUsage: unknown } };
|
||||
expect(body.data.aiUsage).toEqual({
|
||||
maxLevel: "full",
|
||||
defaultLevel: "frugal",
|
||||
syncRegenGapHours: 12,
|
||||
effectiveSyncRegenGapHours: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a default above the ceiling", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "standard",
|
||||
defaultLevel: "full",
|
||||
syncRegenGapHours: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta: { errorCode: string } };
|
||||
expect(body.meta.errorCode).toBe("ai.usage.defaultAboveCeiling");
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, 49, 2.5])(
|
||||
"refuses %s as the sync gap",
|
||||
async (syncRegenGapHours) => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "standard",
|
||||
defaultLevel: "standard",
|
||||
syncRegenGapHours,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an empty body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(putReq({}));
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
* switching reasoning off stops no AI capability, it only stops the model
|
||||
* thinking longer before it answers.
|
||||
*
|
||||
* v1.43 (#1217) — and the AI usage bounds, optional `aiUsage`:
|
||||
* `{ maxLevel, defaultLevel, syncRegenGapHours }`. `maxLevel` is the highest
|
||||
* level an account may choose, `defaultLevel` the level an account without a
|
||||
* choice runs at (never above `maxLevel`: 422 `ai.usage.defaultAboveCeiling`),
|
||||
* `syncRegenGapHours` the minimum gap between status-card regenerations after
|
||||
* a sync at `standard` (1..48, `null` follows `AI_SYNC_REGEN_GAP_HOURS`, else
|
||||
* six). Lowering `maxLevel` caps every account above it at once; their stored
|
||||
* choice is kept. Echoed as `aiUsage` with the resolved gap beside the stored
|
||||
* one.
|
||||
*
|
||||
* `requireAdmin()` gates the route — non-admins get 403.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -55,6 +65,13 @@ import { resolveAssistantFlags } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
|
||||
import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve";
|
||||
import { aiUsageSettingsFromRow } from "@/lib/ai/capabilities/usage";
|
||||
import {
|
||||
usageLevelRank,
|
||||
usageLevelToDb,
|
||||
type AiUsageLevelDb,
|
||||
} from "@/lib/ai/usage-level";
|
||||
import { adminAiUsageSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
const assistantFlagsSchema = z
|
||||
.object({
|
||||
@@ -65,6 +82,7 @@ const assistantFlagsSchema = z
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
aiReasoningEnabled: z.boolean().optional(),
|
||||
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
|
||||
aiUsage: adminAiUsageSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -76,6 +94,9 @@ type AssistantFlagsRow = {
|
||||
assistantDocumentAiEnabled: boolean;
|
||||
aiReasoningEnabled: boolean;
|
||||
aiReasoningMaxEffort: string;
|
||||
aiUsageMaxLevel: AiUsageLevelDb;
|
||||
aiUsageDefaultLevel: AiUsageLevelDb;
|
||||
aiSyncRegenGapHours: number | null;
|
||||
};
|
||||
|
||||
const ROW_SELECT = {
|
||||
@@ -86,6 +107,9 @@ const ROW_SELECT = {
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: true,
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
} as const;
|
||||
|
||||
function buildResponseShape(row: AssistantFlagsRow) {
|
||||
@@ -109,6 +133,18 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
enabled: row.aiReasoningEnabled,
|
||||
maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
|
||||
},
|
||||
aiUsage: aiUsageShape(row),
|
||||
};
|
||||
}
|
||||
|
||||
/** The usage bounds as the admin panel edits them, the gap in effect beside. */
|
||||
function aiUsageShape(row: AssistantFlagsRow) {
|
||||
const settings = aiUsageSettingsFromRow(row);
|
||||
return {
|
||||
maxLevel: settings.maxLevel,
|
||||
defaultLevel: settings.defaultLevel,
|
||||
syncRegenGapHours: settings.storedSyncRegenGapHours,
|
||||
effectiveSyncRegenGapHours: settings.syncRegenGapHours,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +168,9 @@ export const GET = apiHandler(async () => {
|
||||
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
|
||||
aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
|
||||
aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
|
||||
aiUsageMaxLevel: settings?.aiUsageMaxLevel ?? "STANDARD",
|
||||
aiUsageDefaultLevel: settings?.aiUsageDefaultLevel ?? "STANDARD",
|
||||
aiSyncRegenGapHours: settings?.aiSyncRegenGapHours ?? null,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
@@ -154,13 +193,28 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Field by field from the parsed body; the schema is strict, so every key
|
||||
// here is one the route owns.
|
||||
const { aiUsage, ...flags } = parsed.data;
|
||||
if (
|
||||
aiUsage &&
|
||||
usageLevelRank(aiUsage.defaultLevel) > usageLevelRank(aiUsage.maxLevel)
|
||||
) {
|
||||
return apiError("The default level is above the highest level", 422, {
|
||||
errorCode: "ai.usage.defaultAboveCeiling",
|
||||
});
|
||||
}
|
||||
const updates: Partial<AssistantFlagsRow> = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, boolean | string>)[key] = value;
|
||||
}
|
||||
}
|
||||
const auditDetails: Record<string, unknown> = { ...updates };
|
||||
if (aiUsage) {
|
||||
updates.aiUsageMaxLevel = usageLevelToDb(aiUsage.maxLevel);
|
||||
updates.aiUsageDefaultLevel = usageLevelToDb(aiUsage.defaultLevel);
|
||||
updates.aiSyncRegenGapHours = aiUsage.syncRegenGapHours;
|
||||
auditDetails.aiUsage = aiUsage;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
@@ -187,6 +241,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
|
||||
aiReasoningEnabled: settings.aiReasoningEnabled,
|
||||
aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
|
||||
aiUsageMaxLevel: settings.aiUsageMaxLevel,
|
||||
aiUsageDefaultLevel: settings.aiUsageDefaultLevel,
|
||||
aiSyncRegenGapHours: settings.aiSyncRegenGapHours,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the
|
||||
* operator's ceiling. A level above it is refused with 422
|
||||
* `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null`
|
||||
* returns to the instance default; the answer is the resolved `ai.usage`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
stored: null as string | null,
|
||||
settings: {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as Record<string, unknown>,
|
||||
usage: [] as Array<{ totalTokens: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
aiUsageLevel: db.stored,
|
||||
timezone: "UTC",
|
||||
})),
|
||||
update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => {
|
||||
db.stored = args.data.aiUsageLevel;
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn(async () => db.settings) },
|
||||
coachUsage: { findMany: vi.fn(async () => db.usage) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const put = PUT as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/ai-usage", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.stored = null;
|
||||
db.settings = {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
db.usage = [];
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/ai-usage", () => {
|
||||
it("stores a level within the ceiling and answers the resolved state", async () => {
|
||||
db.usage = [
|
||||
{ totalTokens: 90_000 },
|
||||
{ totalTokens: 110_000 },
|
||||
{ totalTokens: 100_000 },
|
||||
];
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({
|
||||
where: { id: "user-1" },
|
||||
data: { aiUsageLevel: "FRUGAL" },
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "frugal",
|
||||
preference: "frugal",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "user",
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 3,
|
||||
});
|
||||
expect(data.options).toEqual([
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
|
||||
]);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.aiUsage.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { previous: null, next: "frugal" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a level above the ceiling with the ceiling named", async () => {
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "ai.usage.aboveCeiling",
|
||||
maxLevel: "standard",
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts full once the operator allows it", async () => {
|
||||
db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" };
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.level).toBe("full");
|
||||
});
|
||||
|
||||
it("returns to the instance default on null", async () => {
|
||||
db.stored = "FRUGAL";
|
||||
const res = await put(req({ level: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({
|
||||
aiUsageLevel: null,
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "standard",
|
||||
preference: null,
|
||||
source: "default",
|
||||
});
|
||||
// Fewer than three days of history: no estimate.
|
||||
expect(
|
||||
data.options.every(
|
||||
(o: { estimatedTokensPerDay: number | null }) =>
|
||||
o.estimatedTokensPerDay === null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ level: "lavish" }],
|
||||
[{}],
|
||||
[{ level: "frugal", extra: true }],
|
||||
[{ level: 1 }],
|
||||
])("refuses %j as invalid", async (body) => {
|
||||
const res = await put(req(body));
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the 31st change in an hour", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited");
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a caller without a session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,22 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadAiUsageSettings,
|
||||
loadAiUsageState,
|
||||
} from "@/lib/ai/capabilities/usage";
|
||||
import { usageLevelRank, usageLevelToDb } from "@/lib/ai/usage-level";
|
||||
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -38,5 +51,43 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("auth.me.ai_usage.put");
|
||||
const { level } = parsed.data;
|
||||
const settings = await loadAiUsageSettings();
|
||||
if (
|
||||
level !== null &&
|
||||
usageLevelRank(level) > usageLevelRank(settings.maxLevel)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put.above_ceiling" },
|
||||
meta: { level, maxLevel: settings.maxLevel },
|
||||
});
|
||||
return apiError(
|
||||
"This level is above the highest one the server allows",
|
||||
422,
|
||||
{ errorCode: "ai.usage.aboveCeiling", maxLevel: settings.maxLevel },
|
||||
);
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiUsageLevel: true },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { aiUsageLevel: level === null ? null : usageLevelToDb(level) },
|
||||
});
|
||||
await auditLog("user.aiUsage.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: previous?.aiUsageLevel?.toLowerCase() ?? null,
|
||||
next: level,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put" },
|
||||
meta: { level: level ?? "default" },
|
||||
});
|
||||
|
||||
return apiSuccess(await loadAiUsageState(user.id, { withMeasurement: true }));
|
||||
});
|
||||
@@ -443,7 +443,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks
|
||||
// — and the status re-warm for the touched types. Best-effort: a
|
||||
// populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, {
|
||||
source: "manual",
|
||||
label: "import.csv",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("import.csv.upload", {
|
||||
|
||||
@@ -483,7 +483,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
|
||||
// at most ~N recomputes) and re-warm the touched types' status
|
||||
// assessments. Best-effort: a populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import");
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, {
|
||||
// A file the person chose to import: a deliberate act, narrated like a
|
||||
// hand entry rather than held to the sync gap.
|
||||
source: "manual",
|
||||
label: "import",
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -68,6 +68,18 @@ export interface ProviderCall {
|
||||
}
|
||||
export const providerCalls: ProviderCall[] = [];
|
||||
|
||||
/**
|
||||
* Everything one provider call carried on the user side. Since #1217 a turn's
|
||||
* own lines (a resolved follow-up, an answered clarification, a continuation)
|
||||
* travel here, never in the system prompt, so the cached prefix holds.
|
||||
*/
|
||||
export function userSide(call: ProviderCall): string {
|
||||
return call.messages
|
||||
.filter((msg) => msg.role === "user")
|
||||
.map((msg) => msg.content)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/** The last assistant message of the stored conversation. */
|
||||
export const LAST_ASSISTANT_ID = "m-a1";
|
||||
export const CONVERSATION_ID = "c-dialog";
|
||||
|
||||
@@ -113,6 +113,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: h.m.getCoachSystemPrompt,
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: h.m.getSelfContextTextForUser,
|
||||
@@ -652,21 +653,34 @@ describe("coach chat golden transcripts", () => {
|
||||
expect(transcript.thrown).toBeNull();
|
||||
expect(m.runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
const { params } = m.runStreamingRawCompletionWithFallback.mock
|
||||
.calls[0][0] as { params: { system: string } };
|
||||
expect(params.system).toContain(CLARIFIED);
|
||||
.calls[0][0] as {
|
||||
params: { system: string; messages: Array<{ content: string }> };
|
||||
};
|
||||
// #1217 — on the user side, never in the cached system prompt.
|
||||
const user = params.messages.map((msg) => msg.content).join("\n");
|
||||
expect(user).toContain(CLARIFIED);
|
||||
expect(params.system).not.toContain(CLARIFIED);
|
||||
// Server-authored: the request's choice id picks the stored value,
|
||||
// the label the model once wrote never rides along.
|
||||
expect(params.system).not.toContain("Resting pulse");
|
||||
const line = user
|
||||
.split("\n")
|
||||
.find((text) => text.startsWith("CLARIFIED:"));
|
||||
expect(line).toContain(CLARIFIED);
|
||||
expect(line).not.toContain("Resting pulse");
|
||||
});
|
||||
|
||||
it("the tool-mode prompt carries the same line", async () => {
|
||||
reply("Your resting pulse averaged 62 bpm.");
|
||||
const transcript = await runTurn(post, body, {});
|
||||
expect(transcript.thrown).toBeNull();
|
||||
const { system } = m.runCoachToolLoop.mock.calls[0][0] as {
|
||||
const { system, messages } = m.runCoachToolLoop.mock.calls[0][0] as {
|
||||
system: string;
|
||||
messages: Array<{ content: string }>;
|
||||
};
|
||||
expect(system).toContain(CLARIFIED);
|
||||
expect(messages.map((msg) => msg.content).join("\n")).toContain(
|
||||
CLARIFIED,
|
||||
);
|
||||
expect(system).not.toContain(CLARIFIED);
|
||||
});
|
||||
|
||||
it("an unanswered turn sends neither prompt the line", async () => {
|
||||
@@ -680,8 +694,13 @@ describe("coach chat golden transcripts", () => {
|
||||
{},
|
||||
);
|
||||
const { params } = m.runStreamingRawCompletionWithFallback.mock
|
||||
.calls[0][0] as { params: { system: string } };
|
||||
.calls[0][0] as {
|
||||
params: { system: string; messages: Array<{ content: string }> };
|
||||
};
|
||||
expect(params.system).not.toContain("CLARIFIED:");
|
||||
expect(
|
||||
params.messages.map((msg) => msg.content).join("\n"),
|
||||
).not.toContain("CLARIFIED:");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -116,6 +116,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
|
||||
@@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("a reuse chip", () => {
|
||||
const run = await tap("f3");
|
||||
expect(run.status).toBe(200);
|
||||
expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1);
|
||||
expect(providerCalls[0].system).toContain(
|
||||
expect(h.userSide(providerCalls[0])).toContain(
|
||||
"Call show_result with ref m1.r1 and view chart",
|
||||
);
|
||||
});
|
||||
@@ -282,15 +282,16 @@ describe("a model chip", () => {
|
||||
anchor: { domain: "glucose", window: "allTime" },
|
||||
});
|
||||
expect(run.status).toBe(200);
|
||||
const system = providerCalls[0].system;
|
||||
expect(system).toContain(
|
||||
const user = h.userSide(providerCalls[0]);
|
||||
expect(user).toContain(
|
||||
"FOLLOW-UP: the person tapped the previous_period chip",
|
||||
);
|
||||
expect(system).toContain(
|
||||
expect(user).toContain(
|
||||
"get_metric_table metric=bp window=last30days period=previous granularity=week",
|
||||
);
|
||||
expect(system).toContain("m1.r1");
|
||||
expect(system).not.toContain("glucose window=allTime");
|
||||
expect(user).toContain("m1.r1");
|
||||
expect(user).not.toContain("glucose window=allTime");
|
||||
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
|
||||
});
|
||||
|
||||
it("degrades a chip that is not on the latest reply to a plain message", async () => {
|
||||
@@ -303,7 +304,7 @@ describe("a model chip", () => {
|
||||
});
|
||||
expect(run.status).toBe(200);
|
||||
expect(providerCalls).toHaveLength(1);
|
||||
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
|
||||
expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
|
||||
expect(annotated("coach.followUp.stale")).toEqual([
|
||||
expect.objectContaining({ meta: { reason: "not_latest" } }),
|
||||
]);
|
||||
@@ -313,7 +314,7 @@ describe("a model chip", () => {
|
||||
conversationWithChips();
|
||||
world.script = [{ text: "Here is what I see." }];
|
||||
await tap("f9");
|
||||
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
|
||||
expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
|
||||
expect(annotated("coach.followUp.stale")).toEqual([
|
||||
expect.objectContaining({ meta: { reason: "unknown_chip" } }),
|
||||
]);
|
||||
@@ -343,16 +344,17 @@ describe("a model chip", () => {
|
||||
{ text: "Having looked further, the readings stay level." },
|
||||
];
|
||||
await tap("f1");
|
||||
const system = providerCalls[0].system;
|
||||
expect(system).toContain(
|
||||
const user = h.userSide(providerCalls[0]);
|
||||
expect(user).toContain(
|
||||
"CONTINUE: the person asked you to keep looking. The unfinished question is their message before that request, in CONVERSATION.",
|
||||
);
|
||||
// The question stays the person's own turn; it never reaches the
|
||||
// system role.
|
||||
expect(system).not.toContain("How was my blood pressure last month?");
|
||||
expect(system).toContain(
|
||||
expect(user).toContain(
|
||||
"Already fetched: get_metric_table(bp, last30days, week) → m1.r1",
|
||||
);
|
||||
// The system role carries neither the line nor the question (#1217).
|
||||
const { system } = providerCalls[0];
|
||||
expect(system).not.toContain("CONTINUE:");
|
||||
expect(system).not.toContain("How was my blood pressure last month?");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,7 +375,7 @@ describe("an answered question", () => {
|
||||
clarification: { messageId: h.LAST_ASSISTANT_ID, choiceId: "c2" },
|
||||
});
|
||||
expect(run.status).toBe(200);
|
||||
expect(providerCalls[0].system).toContain(
|
||||
expect(h.userSide(providerCalls[0])).toContain(
|
||||
"CLARIFIED: the person answered your clarifying question by choosing metric=resting_hr.",
|
||||
);
|
||||
});
|
||||
@@ -385,7 +387,7 @@ describe("an answered question", () => {
|
||||
message: "The one from my watch",
|
||||
clarification: { messageId: h.LAST_ASSISTANT_ID },
|
||||
});
|
||||
expect(providerCalls[0].system).toContain(
|
||||
expect(h.userSide(providerCalls[0])).toContain(
|
||||
"CLARIFIED: the person answered your clarifying question in their own words",
|
||||
);
|
||||
});
|
||||
@@ -397,7 +399,7 @@ describe("an answered question", () => {
|
||||
message: "Resting heart rate",
|
||||
clarification: { messageId: "m-older", choiceId: "c2" },
|
||||
});
|
||||
expect(providerCalls[0].system).not.toContain("CLARIFIED:");
|
||||
expect(h.userSide(providerCalls[0])).not.toContain("CLARIFIED:");
|
||||
expect(annotated("coach.clarification.stale")).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -147,6 +147,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
|
||||
// NB: the outbound guard + numeric verifier are REAL — that is the point.
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
|
||||
@@ -120,6 +120,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -153,6 +153,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -193,6 +193,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
buildCoachTurnNotes: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
|
||||
@@ -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";
|
||||
@@ -671,7 +676,9 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
const compactFeatures = compactSections(
|
||||
features as unknown as Record<string, unknown>,
|
||||
);
|
||||
const featuresJson = JSON.stringify(compactFeatures, null, 2);
|
||||
// Compact on the wire: indentation is about 40% of a pretty-printed
|
||||
// payload, and the model reads the structure without it (#1217).
|
||||
const featuresJson = JSON.stringify(compactFeatures);
|
||||
if (payloadDowngraded) {
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -751,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
|
||||
@@ -1081,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: {
|
||||
@@ -1102,6 +1116,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
aboutMe: aboutMe ?? null,
|
||||
comparisonBaseline: comparisonSnapshot?.baseline ?? "none",
|
||||
generationLocale: locale,
|
||||
...(briefingForm === "short" ? { briefingForm } : {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -248,10 +248,14 @@ export const PUT = apiHandler(
|
||||
// NEW identity ride the list: an edit can move the row across day
|
||||
// boundaries or re-type it, and the helper collapses identical
|
||||
// (type, day) pairs so the common in-place edit fires once.
|
||||
await afterMeasurementMutation(user.id, [
|
||||
{ type: measurement.type, measuredAt: measurement.measuredAt },
|
||||
{ type: existing.type, measuredAt: existing.measuredAt },
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
user.id,
|
||||
[
|
||||
{ type: measurement.type, measuredAt: measurement.measuredAt },
|
||||
{ type: existing.type, measuredAt: existing.measuredAt },
|
||||
],
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
},
|
||||
@@ -312,9 +316,11 @@ export const DELETE = apiHandler(
|
||||
|
||||
// v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops
|
||||
// the rollup row when the day's measurement count goes to zero).
|
||||
await afterMeasurementMutation(user.id, [
|
||||
{ type: existing.type, measuredAt: existing.measuredAt },
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
user.id,
|
||||
[{ type: existing.type, measuredAt: existing.measuredAt }],
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// v1.37.19 (C2-F1) — shared post-mutation tail for every distinct
|
||||
// (type, day) the batch touched. Updates participate as well as
|
||||
// inserts, so this deliberately retains the prepared-row scope.
|
||||
await afterMeasurementMutation(user.id, writtenIdentities);
|
||||
// A batch is the phone's sync, held to the sync gap, unless it wrote one
|
||||
// of the person's own hand-entered readings, which is narrated like any
|
||||
// other hand entry.
|
||||
await afterMeasurementMutation(user.id, writtenIdentities, {
|
||||
source: wroteManualRow ? "manual" : "sync",
|
||||
});
|
||||
|
||||
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
|
||||
// row for last night just landed, kick the debounced morning refresh so the
|
||||
|
||||
@@ -154,10 +154,12 @@ describe("POST /api/measurements/bulk-delete", () => {
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
// And one status re-warm over the distinct types.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
// A delete is the person's own act: narrated like a hand entry.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
["WEIGHT", "PULSE"],
|
||||
{ source: "manual" },
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
|
||||
@@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
"measurements bulk-delete",
|
||||
{ source: "manual", label: "measurements bulk-delete" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -118,9 +118,12 @@ describe("DELETE /api/measurements/by-external-ids", () => {
|
||||
|
||||
// The deleted row's type is re-warmed; the zero-row case below is the
|
||||
// twin that must not reach it.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
]);
|
||||
// The phone's HealthKit deletions arrive with its sync: the sync gap.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
["WEIGHT"],
|
||||
{ source: "sync" },
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => {
|
||||
|
||||
@@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
|
||||
// v1.37.19 (C2-F1) — shared post-mutation tail (identities collected
|
||||
// BEFORE the destructive statement, while readable).
|
||||
await afterMeasurementMutation(user.id, affectedRows);
|
||||
await afterMeasurementMutation(user.id, affectedRows, { source: "sync" });
|
||||
}
|
||||
|
||||
return apiSuccess({ deletedCount: result.count });
|
||||
|
||||
@@ -149,10 +149,12 @@ describe("POST /api/measurements/restore", () => {
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
// And one status re-warm over the distinct types.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
// A restore is the person's own act: narrated like a hand entry.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
["WEIGHT", "PULSE"],
|
||||
{ source: "manual" },
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
|
||||
|
||||
@@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
"measurements restore",
|
||||
{ source: "manual", label: "measurements restore" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -963,6 +963,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
await afterMeasurementMutation(
|
||||
user.id,
|
||||
results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess(shapedResults, 201);
|
||||
@@ -1116,9 +1117,11 @@ async function postMeasurement(request: NextRequest) {
|
||||
// pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the
|
||||
// hood. Best-effort — a populator hiccup never fails the user's
|
||||
// write.
|
||||
await afterMeasurementMutation(user.id, [
|
||||
{ type: measurement.type, measuredAt: measurement.measuredAt },
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
user.id,
|
||||
[{ type: measurement.type, measuredAt: measurement.measuredAt }],
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement), 201);
|
||||
}
|
||||
@@ -1259,10 +1262,11 @@ async function postNamedSleepStage(
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
|
||||
}
|
||||
await afterMeasurementMutation(userId, [
|
||||
{ type, measuredAt: segment.end },
|
||||
...(verdict.dirtyIdentities ?? []),
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
userId,
|
||||
[{ type, measuredAt: segment.end }, ...(verdict.dirtyIdentities ?? [])],
|
||||
{ source: "manual" },
|
||||
);
|
||||
}
|
||||
|
||||
return namedSleepStageResponse(verdict.row.id, status, {
|
||||
|
||||
@@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
// route it through the shared post-mutation tail so the (type, day)
|
||||
// rollup bucket and the cached status assessment converge without
|
||||
// waiting for a nightly discovery pass.
|
||||
await afterMeasurementMutation(user.id, [
|
||||
{
|
||||
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
|
||||
measuredAt: when,
|
||||
},
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
user.id,
|
||||
[
|
||||
{
|
||||
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
|
||||
measuredAt: when,
|
||||
},
|
||||
],
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
|
||||
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — the operator's AI usage bounds.
|
||||
*
|
||||
* The operator pays for the shared key, so the operator sets the highest
|
||||
* level an account may choose and the level an account without a choice runs
|
||||
* at; each person picks within that under Settings > AI. Beside them, the
|
||||
* gap between two status-card regenerations after a sync at `standard`
|
||||
* (whole hours, 1 to 48; empty follows `AI_SYNC_REGEN_GAP_HOURS`, else six).
|
||||
*
|
||||
* Reads and writes the `aiUsage` block of the assistant-flags route, the same
|
||||
* query the assistant switches use. Lowering the ceiling caps every account
|
||||
* above it at once; their stored choice is kept.
|
||||
*/
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
SYNC_REGEN_GAP_HOURS_MAX,
|
||||
SYNC_REGEN_GAP_HOURS_MIN,
|
||||
usageLevelRank,
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
interface AiUsageBounds {
|
||||
maxLevel: AiUsageLevel;
|
||||
defaultLevel: AiUsageLevel;
|
||||
syncRegenGapHours: number | null;
|
||||
effectiveSyncRegenGapHours: number;
|
||||
}
|
||||
|
||||
const LEVEL_LABEL_KEYS = {
|
||||
frugal: "settings.ai.usage.levels.frugal.label",
|
||||
standard: "settings.ai.usage.levels.standard.label",
|
||||
full: "settings.ai.usage.levels.full.label",
|
||||
} as const satisfies Record<AiUsageLevel, string>;
|
||||
|
||||
export function AiUsageSection() {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
queryFn: () =>
|
||||
apiGet<{ aiUsage?: AiUsageBounds }>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
),
|
||||
});
|
||||
const bounds = data?.aiUsage;
|
||||
if (!bounds) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
title={t("admin.aiUsage.title")}
|
||||
description={t("admin.aiUsage.description")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
// Keyed on the stored values, so a save (or another operator's) re-seeds
|
||||
// the form instead of leaving it on stale edits.
|
||||
return (
|
||||
<AiUsageForm
|
||||
key={`${bounds.maxLevel}:${bounds.defaultLevel}:${bounds.syncRegenGapHours}`}
|
||||
bounds={bounds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
|
||||
const { t } = useTranslations();
|
||||
const client = useQueryClient();
|
||||
const [maxLevel, setMaxLevel] = useState<AiUsageLevel>(bounds.maxLevel);
|
||||
const [defaultLevel, setDefaultLevel] = useState<AiUsageLevel>(
|
||||
bounds.defaultLevel,
|
||||
);
|
||||
const [gap, setGap] = useState(
|
||||
bounds.syncRegenGapHours === null ? "" : String(bounds.syncRegenGapHours),
|
||||
);
|
||||
|
||||
const gapValue = gap.trim() === "" ? null : Number(gap);
|
||||
const gapValid =
|
||||
gapValue === null ||
|
||||
(Number.isInteger(gapValue) &&
|
||||
gapValue >= SYNC_REGEN_GAP_HOURS_MIN &&
|
||||
gapValue <= SYNC_REGEN_GAP_HOURS_MAX);
|
||||
const defaultAbove = usageLevelRank(defaultLevel) > usageLevelRank(maxLevel);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut<{ aiUsage: AiUsageBounds }>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
{
|
||||
aiUsage: { maxLevel, defaultLevel, syncRegenGapHours: gapValue },
|
||||
},
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), result);
|
||||
void client.invalidateQueries({
|
||||
queryKey: queryKeys.adminProviderHealth(),
|
||||
});
|
||||
void invalidateKeys(client, aiInputDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!gapValid || defaultAbove) return;
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="admin-ai-usage">
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
title={t("admin.aiUsage.title")}
|
||||
description={t("admin.aiUsage.description")}
|
||||
/>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-usage-max">
|
||||
{t("admin.aiUsage.maxLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="admin-ai-usage-max"
|
||||
value={maxLevel}
|
||||
disabled={save.isPending}
|
||||
onChange={(e) => setMaxLevel(e.target.value as AiUsageLevel)}
|
||||
>
|
||||
{AI_USAGE_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(LEVEL_LABEL_KEYS[level])}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-usage-default">
|
||||
{t("admin.aiUsage.defaultLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="admin-ai-usage-default"
|
||||
value={defaultLevel}
|
||||
disabled={save.isPending}
|
||||
aria-invalid={defaultAbove || undefined}
|
||||
onChange={(e) => setDefaultLevel(e.target.value as AiUsageLevel)}
|
||||
>
|
||||
{AI_USAGE_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(LEVEL_LABEL_KEYS[level])}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
{defaultAbove ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.aiUsage.defaultAboveCeiling")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-usage-gap">
|
||||
{t("admin.aiUsage.gapLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-usage-gap"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={SYNC_REGEN_GAP_HOURS_MIN}
|
||||
max={SYNC_REGEN_GAP_HOURS_MAX}
|
||||
step={1}
|
||||
className="sm:max-w-40"
|
||||
value={gap}
|
||||
placeholder={String(bounds.effectiveSyncRegenGapHours)}
|
||||
disabled={save.isPending}
|
||||
aria-invalid={!gapValid || undefined}
|
||||
aria-describedby="admin-ai-usage-gap-hint"
|
||||
onChange={(e) => setGap(e.target.value)}
|
||||
/>
|
||||
<p
|
||||
id="admin-ai-usage-gap-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("admin.aiUsage.gapHint", {
|
||||
hours: bounds.effectiveSyncRegenGapHours,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={save.isPending || !gapValid || defaultAbove}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +69,11 @@ export function ProviderHealthSection() {
|
||||
const { data, isPending, isError } = useQuery({
|
||||
queryKey: queryKeys.adminProviderHealth(),
|
||||
queryFn: () =>
|
||||
apiGet<{ providers: ProviderHealthRow[]; spendToday: SpendToday }>(
|
||||
"/api/admin/provider-health",
|
||||
),
|
||||
apiGet<{
|
||||
providers: ProviderHealthRow[];
|
||||
spendToday: SpendToday;
|
||||
usageLevels?: { frugal: number; standard: number; full: number };
|
||||
}>("/api/admin/provider-health"),
|
||||
});
|
||||
|
||||
const typeLabel = (type: string): string => {
|
||||
@@ -97,6 +99,7 @@ export function ProviderHealthSection() {
|
||||
|
||||
const providers = data?.providers ?? [];
|
||||
const spendToday = data?.spendToday ?? null;
|
||||
const usageLevels = data?.usageLevels ?? null;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
@@ -114,6 +117,18 @@ export function ProviderHealthSection() {
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{usageLevels ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="ai-usage-levels"
|
||||
>
|
||||
{t("admin.providerHealth.usageLevels", {
|
||||
frugal: groupDigits(usageLevels.frugal),
|
||||
standard: groupDigits(usageLevels.standard),
|
||||
full: groupDigits(usageLevels.full),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{isPending ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.providerHealth.loading")}
|
||||
|
||||
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
// v1.43 — no usage level published: the setting stays out of the way.
|
||||
useAiUsage: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
|
||||
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
// v1.43 — no usage level published: the setting stays out of the way.
|
||||
useAiUsage: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* #1217 — the AI usage setting renders what the server resolved: three
|
||||
* options, the ones above the operator's ceiling disabled with their reason,
|
||||
* each estimate labelled as one, and nothing computed on the client.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AiUsageState } from "@/lib/ai/usage-level";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
usage: null as AiUsageState | null,
|
||||
canConfigure: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiUsage: () => state.usage,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: state.canConfigure,
|
||||
responseTimeoutMs: 60_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AiUsageCard } from "../ai-usage-card";
|
||||
|
||||
function render(usage: AiUsageState | null, canConfigure = true) {
|
||||
state.usage = usage;
|
||||
state.canConfigure = canConfigure;
|
||||
const client = new QueryClient();
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiUsageCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const CAPPED: AiUsageState = {
|
||||
level: "standard",
|
||||
preference: "full",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "admin_cap",
|
||||
options: [
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 48_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 400_000 },
|
||||
],
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 7,
|
||||
};
|
||||
|
||||
describe("AiUsageCard", () => {
|
||||
it("renders nothing on a server that publishes no level", () => {
|
||||
expect(render(null)).toBe("");
|
||||
});
|
||||
|
||||
it("checks the level in effect and disables the ones above the ceiling", () => {
|
||||
const html = render(CAPPED);
|
||||
expect(html.match(/role="radio"/g)).toHaveLength(3);
|
||||
expect(html).toMatch(/aria-checked="true"[^>]*data-level="standard"/);
|
||||
expect(html.match(/aria-checked="true"/g)).toHaveLength(1);
|
||||
expect(html).toContain('data-level="full" data-allowed="false"');
|
||||
expect(html).toContain("Limited by the server operator.");
|
||||
// The capped choice is named and kept.
|
||||
expect(html).toContain('data-slot="ai-usage-capped"');
|
||||
expect(html).toContain("You chose Full");
|
||||
});
|
||||
|
||||
it("shows the server's estimates, labelled, in the reader's number format", () => {
|
||||
const html = render(CAPPED);
|
||||
expect(html).toContain("about 48,000 tokens a day, estimated");
|
||||
expect(html).toContain("about 400,000 tokens a day, estimated");
|
||||
expect(html).toContain(
|
||||
"Measured: 100,000 tokens a day over the last 7 days",
|
||||
);
|
||||
});
|
||||
|
||||
it("says when there is no estimate yet instead of inventing one", () => {
|
||||
const html = render({
|
||||
...CAPPED,
|
||||
source: "default",
|
||||
preference: null,
|
||||
options: CAPPED.options.map((o) => ({
|
||||
...o,
|
||||
estimatedTokensPerDay: null,
|
||||
})),
|
||||
measuredTokensPerDay: null,
|
||||
measuredDays: 0,
|
||||
});
|
||||
expect(html).not.toContain("tokens a day, estimated");
|
||||
expect(html).toContain("An estimate per level appears after three days");
|
||||
expect(html).toContain("You follow the server default (Standard).");
|
||||
// No stored choice: nothing to return to.
|
||||
expect(html).not.toContain('data-slot="ai-usage-use-default"');
|
||||
});
|
||||
|
||||
it("offers the way back to the default when a choice is stored", () => {
|
||||
const html = render({
|
||||
...CAPPED,
|
||||
source: "user",
|
||||
preference: "frugal",
|
||||
level: "frugal",
|
||||
});
|
||||
expect(html).toContain('data-slot="ai-usage-use-default"');
|
||||
expect(html).toContain("Use the server default (Standard)");
|
||||
});
|
||||
|
||||
it("locks the options where the account cannot set its own level", () => {
|
||||
const html = render(CAPPED, false);
|
||||
expect(html).toContain(
|
||||
'role="radiogroup" aria-label="AI usage" aria-disabled="true"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
|
||||
import { AiConsentCard } from "./ai-consent-card";
|
||||
import { AiUsageCard } from "./ai-usage-card";
|
||||
import { AnthropicProviderForm } from "./anthropic-provider-form";
|
||||
import { AutoReadCard } from "./auto-read-card";
|
||||
import { CentralCodexSwitch } from "./central-codex-switch";
|
||||
@@ -233,6 +234,10 @@ export function AiInsightsCard({
|
||||
onSelect={pickProvider}
|
||||
/>
|
||||
|
||||
{/* v1.43 (#1217) — how much background AI work the account
|
||||
asks for: one setting, three levels. */}
|
||||
<AiUsageCard />
|
||||
|
||||
{/* v1.22 (#89) — per-user response timeout (mainly for slow
|
||||
local/self-hosted backends). */}
|
||||
<ResponseTimeoutCard userProvider={userProvider} />
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* v1.43 (#1217) — AI usage: one setting, three levels.
|
||||
*
|
||||
* The level decides how much background AI work the account asks for: how
|
||||
* often status cards regenerate after a sync, the snapshot size, background
|
||||
* reasoning, the length of the daily briefing, the automatic document read
|
||||
* and the label photo. The Coach is the same at every level.
|
||||
*
|
||||
* Everything shown comes off `ai.usage` on `/api/auth/me`, resolved on the
|
||||
* server: which options the operator allows, the estimate per option (only
|
||||
* ever labelled as one), the measured use. The card computes nothing. A pick
|
||||
* is written at once through `PUT /api/auth/me/ai-usage`; the account payload
|
||||
* is refetched so every surface that reads a capability sees the new level.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAiProviderState, useAiUsage } from "@/hooks/use-ai-capability";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LEVEL_KEYS = {
|
||||
frugal: {
|
||||
label: "settings.ai.usage.levels.frugal.label",
|
||||
hint: "settings.ai.usage.levels.frugal.hint",
|
||||
},
|
||||
standard: {
|
||||
label: "settings.ai.usage.levels.standard.label",
|
||||
hint: "settings.ai.usage.levels.standard.hint",
|
||||
},
|
||||
full: {
|
||||
label: "settings.ai.usage.levels.full.label",
|
||||
hint: "settings.ai.usage.levels.full.hint",
|
||||
},
|
||||
} as const satisfies Record<AiUsageLevel, { label: string; hint: string }>;
|
||||
|
||||
export function AiUsageCard() {
|
||||
const { t, locale } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const usage = useAiUsage();
|
||||
const provider = useAiProviderState();
|
||||
const refs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const save = useMutation<AiUsageState, Error, AiUsageLevel | null>({
|
||||
mutationFn: (level) =>
|
||||
apiPut<AiUsageState>("/api/auth/me/ai-usage", { level }),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
// An older server publishes no level: there is nothing to set.
|
||||
if (!usage) return null;
|
||||
|
||||
// Only the account itself sets its level, and only while the operator's
|
||||
// master switch is on (`canConfigure` is false inside somebody else's
|
||||
// record and while AI is off on the server).
|
||||
const editable = provider.canConfigure && !save.isPending;
|
||||
const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
|
||||
const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
|
||||
const allowedLevels = usage.options
|
||||
.filter((option) => option.allowed)
|
||||
.map((option) => option.level);
|
||||
|
||||
function pick(level: AiUsageLevel) {
|
||||
if (!editable || level === usage?.level) return;
|
||||
save.mutate(level);
|
||||
}
|
||||
|
||||
function onKeyDown(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
level: AiUsageLevel,
|
||||
) {
|
||||
const from = allowedLevels.indexOf(level);
|
||||
const next = nextChoiceIndex(event.key, from, allowedLevels.length);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
const target = allowedLevels[next]!;
|
||||
pick(target);
|
||||
refs.current[usage!.options.findIndex((o) => o.level === target)]?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="ai-usage-card">
|
||||
<SettingsCardHeader
|
||||
anchor="ai-usage"
|
||||
icon={Gauge}
|
||||
title={t("settings.ai.usage.title")}
|
||||
description={t("settings.ai.usage.description")}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.ai.usage.title")}
|
||||
aria-disabled={!editable || undefined}
|
||||
className="border-border divide-border divide-y rounded-lg border"
|
||||
>
|
||||
{usage.options.map((option, i) => {
|
||||
const checked = option.level === usage.level;
|
||||
const disabled = !option.allowed || !editable;
|
||||
return (
|
||||
<button
|
||||
key={option.level}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
data-slot="ai-usage-option"
|
||||
data-level={option.level}
|
||||
data-allowed={option.allowed}
|
||||
onClick={() => (option.allowed ? pick(option.level) : undefined)}
|
||||
onKeyDown={(event) => onKeyDown(event, option.level)}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-start gap-3 px-3 py-2.5 text-left first:rounded-t-lg last:rounded-b-lg",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
checked && "bg-primary/10",
|
||||
!option.allowed && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"border-border mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
checked && "border-primary",
|
||||
)}
|
||||
>
|
||||
{checked ? (
|
||||
<span className="bg-primary size-2 rounded-full" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{levelName(option.level)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t(LEVEL_KEYS[option.level].hint)}
|
||||
</span>
|
||||
{!option.allowed ? (
|
||||
<span
|
||||
data-slot="ai-usage-limited"
|
||||
className="text-muted-foreground block text-xs"
|
||||
>
|
||||
{t("settings.ai.usage.limited")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
data-slot="ai-usage-estimate"
|
||||
className="text-muted-foreground shrink-0 text-right text-xs tabular-nums"
|
||||
>
|
||||
{option.estimatedTokensPerDay === null
|
||||
? null
|
||||
: t("settings.ai.usage.estimate", {
|
||||
tokens: tokens(option.estimatedTokensPerDay),
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="ai-usage-measured"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{usage.measuredTokensPerDay === null || usage.measuredDays < 3
|
||||
? t("settings.ai.usage.noEstimate")
|
||||
: t("settings.ai.usage.measured", {
|
||||
tokens: tokens(usage.measuredTokensPerDay),
|
||||
days: usage.measuredDays,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{usage.source === "admin_cap" && usage.preference ? (
|
||||
<p data-slot="ai-usage-capped" className="text-sm">
|
||||
{t("settings.ai.usage.capped", {
|
||||
choice: levelName(usage.preference),
|
||||
level: levelName(usage.level),
|
||||
})}
|
||||
</p>
|
||||
) : usage.source === "default" ? (
|
||||
<p
|
||||
data-slot="ai-usage-default"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.ai.usage.followsDefault", {
|
||||
level: levelName(usage.defaultLevel),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{save.isError ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("settings.ai.errorGeneric")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{usage.preference !== null ? (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!editable}
|
||||
onClick={() => save.mutate(null)}
|
||||
data-slot="ai-usage-use-default"
|
||||
>
|
||||
{t("settings.ai.usage.useDefault", {
|
||||
level: levelName(usage.defaultLevel),
|
||||
})}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAiCapability, useAiUsage } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -41,6 +41,9 @@ export function AutoReadCard() {
|
||||
// already-on preference off stays possible.
|
||||
const documentAi = useAiCapability("documentAi");
|
||||
const operatorOff = documentAi.reason === "operator_disabled";
|
||||
// v1.43 (#1217) — on the frugal AI usage level the switch rests: documents
|
||||
// are read only on request. The choice itself is kept.
|
||||
const restingFrugal = useAiUsage()?.level === "frugal";
|
||||
|
||||
// Whether the honesty confirm is currently shown (user flipped the switch ON
|
||||
// but has not yet acknowledged the trade). Off→on reveals it; the write only
|
||||
@@ -115,6 +118,15 @@ export function AutoReadCard() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{restingFrugal && enabled && !operatorOff ? (
|
||||
<p
|
||||
data-slot="documents-auto-read-resting"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.ai.autoRead.restingFrugal")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pendingEnable ? (
|
||||
<div
|
||||
data-slot="documents-auto-read-confirm"
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import type { AiUsageState } from "@/lib/ai/usage-level";
|
||||
|
||||
/**
|
||||
* The web reader for the `ai` block on `GET /api/auth/me`.
|
||||
@@ -85,6 +86,25 @@ function useCoachReasoningInner(): CoachReasoningState | null {
|
||||
return user?.coachReasoning ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level (`ai.usage` on
|
||||
* `GET /api/auth/me`): the level in effect, the person's choice, the
|
||||
* operator's bounds, the three options with their estimates. `null` while it
|
||||
* is not known or on a server that does not publish it; the setting is then
|
||||
* not shown. Render from it; never compute an estimate or an allowance here.
|
||||
*/
|
||||
export function useAiUsage(): AiUsageState | null {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return null;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useAiUsageInner();
|
||||
}
|
||||
|
||||
function useAiUsageInner(): AiUsageState | null {
|
||||
const { user } = useAuth();
|
||||
return user?.ai?.usage ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's answer for one capability, or `null` while there is none yet
|
||||
* (`/me` loading, no signed-in account, no query client). For the few places
|
||||
|
||||
@@ -47,6 +47,7 @@ const CONTRACT: Record<AiUnavailableReason, [number, string]> = {
|
||||
not_permitted_for_record: [403, "ai.record.notPermitted"],
|
||||
module_disabled: [403, "module.disabled"],
|
||||
user_disabled: [403, "module.disabled"],
|
||||
usage_level: [403, "ai.usage.levelTooLow"],
|
||||
no_provider: [422, "ai.provider.none"],
|
||||
consent_required: [403, "consent.ai.required"],
|
||||
};
|
||||
@@ -75,6 +76,18 @@ describe("the refusal table", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names the level a usage refusal needs (#1217)", () => {
|
||||
expect(aiRefusal("supplementLabel", "usage_level")).toEqual({
|
||||
status: 403,
|
||||
meta: {
|
||||
errorCode: "ai.usage.levelTooLow",
|
||||
capability: "supplementLabel",
|
||||
reason: "usage_level",
|
||||
requiredLevel: "standard",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the code shipped clients know for the Coach", () => {
|
||||
expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe(
|
||||
"assistant.disabled.coach",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* #1217 — the AI usage level: resolution, effects and the estimate.
|
||||
*
|
||||
* The resolution matrix is exhaustive over every stored choice (three levels
|
||||
* and none), every default and every ceiling: 4 x 3 x 3 = 36 cases, each
|
||||
* checked against a rule written out independently of the resolver.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
AI_USAGE_LEVEL_EFFECTS,
|
||||
buildAiUsageState,
|
||||
capUsageLevel,
|
||||
estimateTokensPerDay,
|
||||
parseSyncRegenGapHours,
|
||||
resolveAiUsageLevel,
|
||||
statusRegenGapHours,
|
||||
usageLevelFromDb,
|
||||
usageLevelToDb,
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
const RANK: Record<AiUsageLevel, number> = { frugal: 0, standard: 1, full: 2 };
|
||||
|
||||
describe("resolveAiUsageLevel — every choice x default x ceiling", () => {
|
||||
const choices: Array<AiUsageLevel | null> = [null, ...AI_USAGE_LEVELS];
|
||||
let cases = 0;
|
||||
for (const preference of choices) {
|
||||
for (const defaultLevel of AI_USAGE_LEVELS) {
|
||||
for (const maxLevel of AI_USAGE_LEVELS) {
|
||||
cases++;
|
||||
it(`choice ${preference ?? "none"}, default ${defaultLevel}, ceiling ${maxLevel}`, () => {
|
||||
const got = resolveAiUsageLevel({
|
||||
preference,
|
||||
defaultLevel,
|
||||
maxLevel,
|
||||
});
|
||||
if (preference === null) {
|
||||
// No choice: the default, never above the ceiling.
|
||||
const expected =
|
||||
RANK[defaultLevel] > RANK[maxLevel] ? maxLevel : defaultLevel;
|
||||
expect(got).toEqual({ level: expected, source: "default" });
|
||||
} else if (RANK[preference] > RANK[maxLevel]) {
|
||||
// A choice above the ceiling is capped, and says so.
|
||||
expect(got).toEqual({ level: maxLevel, source: "admin_cap" });
|
||||
} else {
|
||||
expect(got).toEqual({ level: preference, source: "user" });
|
||||
}
|
||||
// Whatever the inputs, the result never passes the ceiling.
|
||||
expect(RANK[got.level]).toBeLessThanOrEqual(RANK[maxLevel]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
it("covered every combination", () => {
|
||||
expect(cases).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what each level does", () => {
|
||||
it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => {
|
||||
expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24);
|
||||
expect(statusRegenGapHours("standard", "sync", 6)).toBe(6);
|
||||
expect(statusRegenGapHours("standard", "sync", 12)).toBe(12);
|
||||
expect(statusRegenGapHours("full", "sync", 12)).toBe(1);
|
||||
});
|
||||
|
||||
it("regenerates after a hand entry: six hours on frugal, the hour otherwise", () => {
|
||||
expect(statusRegenGapHours("frugal", "manual", 6)).toBe(6);
|
||||
expect(statusRegenGapHours("standard", "manual", 24)).toBe(1);
|
||||
expect(statusRegenGapHours("full", "manual", 24)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the status snapshot at 10 000 on frugal and 14 000 above", () => {
|
||||
expect(
|
||||
AI_USAGE_LEVELS.map((l) => AI_USAGE_LEVEL_EFFECTS[l].statusSnapshotChars),
|
||||
).toEqual([10_000, 14_000, 14_000]);
|
||||
});
|
||||
|
||||
it("switches background reasoning and the automatic document read off on frugal only", () => {
|
||||
for (const level of AI_USAGE_LEVELS) {
|
||||
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
||||
expect(effect.backgroundReasoning, level).toBe(level !== "frugal");
|
||||
expect(effect.documentsAutoRead, level).toBe(level !== "frugal");
|
||||
}
|
||||
});
|
||||
|
||||
it("orders the token factors with the levels, full at one", () => {
|
||||
const factors = AI_USAGE_LEVELS.map(
|
||||
(l) => AI_USAGE_LEVEL_EFFECTS[l].tokenFactor,
|
||||
);
|
||||
expect(factors).toEqual([...factors].sort((a, b) => a - b));
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.full.tokenFactor).toBe(1);
|
||||
// Calibrated by the watch-day simulation: standard ~0.31 of full,
|
||||
// frugal ~0.15, the nightly warm included.
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.standard.tokenFactor).toBe(0.3);
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.frugal.tokenFactor).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the estimate", () => {
|
||||
it("is null without three days of history", () => {
|
||||
for (const measuredDays of [0, 1, 2]) {
|
||||
expect(
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays,
|
||||
from: "standard",
|
||||
target: "full",
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: null,
|
||||
measuredDays: 7,
|
||||
from: "standard",
|
||||
target: "full",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("scales the measured use by the factor ratio, whole tokens", () => {
|
||||
const at = (target: AiUsageLevel) =>
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 5,
|
||||
from: "standard",
|
||||
target,
|
||||
});
|
||||
expect(at("standard")).toBe(100_000);
|
||||
expect(at("full")).toBe(333_333);
|
||||
expect(at("frugal")).toBe(50_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAiUsageState", () => {
|
||||
it("publishes three options in ceiling order with allowance and estimate", () => {
|
||||
const state = buildAiUsageState({
|
||||
preference: "full",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
measuredTokensPerDay: 50_000,
|
||||
measuredDays: 7,
|
||||
});
|
||||
expect(state.level).toBe("standard");
|
||||
expect(state.source).toBe("admin_cap");
|
||||
expect(state.preference).toBe("full");
|
||||
expect(state.options.map((o) => [o.level, o.allowed])).toEqual([
|
||||
["frugal", true],
|
||||
["standard", true],
|
||||
["full", false],
|
||||
]);
|
||||
expect(state.options.map((o) => o.estimatedTokensPerDay)).toEqual([
|
||||
25_000, 50_000, 166_667,
|
||||
]);
|
||||
expect(state.measuredTokensPerDay).toBe(50_000);
|
||||
expect(state.measuredDays).toBe(7);
|
||||
});
|
||||
|
||||
it("publishes no estimate without history", () => {
|
||||
const state = buildAiUsageState({
|
||||
preference: null,
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "full",
|
||||
measuredTokensPerDay: null,
|
||||
measuredDays: 0,
|
||||
});
|
||||
expect(state.options.every((o) => o.estimatedTokensPerDay === null)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(state.source).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spelling and parsing", () => {
|
||||
it("round-trips the database spelling", () => {
|
||||
for (const level of AI_USAGE_LEVELS) {
|
||||
expect(usageLevelFromDb(usageLevelToDb(level))).toBe(level);
|
||||
}
|
||||
expect(usageLevelFromDb(null)).toBeNull();
|
||||
expect(usageLevelFromDb("LAVISH")).toBeNull();
|
||||
});
|
||||
|
||||
it("caps a level at a ceiling", () => {
|
||||
expect(capUsageLevel("full", "standard")).toBe("standard");
|
||||
expect(capUsageLevel("frugal", "standard")).toBe("frugal");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["6", 6],
|
||||
[12, 12],
|
||||
["48", 48],
|
||||
["0", null],
|
||||
["49", null],
|
||||
["2.5", null],
|
||||
["", null],
|
||||
[undefined, null],
|
||||
["abc", null],
|
||||
])("parses the sync gap %s as %s", (raw, expected) => {
|
||||
expect(parseSyncRegenGapHours(raw)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs {
|
||||
},
|
||||
activeConsentKinds: new Set(),
|
||||
recordKind: "self",
|
||||
usageLevel: "full",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +104,11 @@ const PERTURB: Record<Exclude<AiUnavailableReason, "check_failed">, Perturb> = {
|
||||
inputs.moduleAccess = access;
|
||||
return true;
|
||||
},
|
||||
usage_level: (inputs, key) => {
|
||||
if (AI_CAPABILITIES[key].minUsageLevel === undefined) return false;
|
||||
inputs.usageLevel = "frugal";
|
||||
return true;
|
||||
},
|
||||
no_provider: (inputs) => {
|
||||
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
|
||||
return true;
|
||||
@@ -429,16 +435,18 @@ describe("on-device permission", () => {
|
||||
not_permitted_for_record: false,
|
||||
module_disabled: false,
|
||||
user_disabled: false,
|
||||
usage_level: false,
|
||||
no_provider: true,
|
||||
consent_required: true,
|
||||
};
|
||||
for (const reason of PERTURBABLE) {
|
||||
const inputs = baseline();
|
||||
PERTURB[reason](inputs, "briefing");
|
||||
expect(
|
||||
resolveAiCapability("briefing", inputs).onDeviceAllowed,
|
||||
reason,
|
||||
).toBe(expected[reason]);
|
||||
// `usage_level` only applies to a capability with a minimum level.
|
||||
const key = reason === "usage_level" ? "supplementLabel" : "briefing";
|
||||
PERTURB[reason](inputs, key);
|
||||
expect(resolveAiCapability(key, inputs).onDeviceAllowed, reason).toBe(
|
||||
expected[reason],
|
||||
);
|
||||
}
|
||||
expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe(
|
||||
expected.check_failed,
|
||||
@@ -486,3 +494,43 @@ describe("the provider block", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the AI usage level (#1217)", () => {
|
||||
it("closes only the capabilities that need a higher level", () => {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = "frugal";
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
const needsMore = AI_CAPABILITIES[key].minUsageLevel !== undefined;
|
||||
expect(resolved[key].reason, key).toBe(needsMore ? "usage_level" : null);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the label photo from standard up", () => {
|
||||
for (const [level, available] of [
|
||||
["frugal", false],
|
||||
["standard", true],
|
||||
["full", true],
|
||||
] as const) {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = level;
|
||||
expect(
|
||||
resolveAiCapability("supplementLabel", inputs).available,
|
||||
level,
|
||||
).toBe(available);
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks after the person's opt-out and before the provider", () => {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = "frugal";
|
||||
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
|
||||
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
|
||||
"usage_level",
|
||||
);
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, nutrients: "disabled" };
|
||||
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
|
||||
"module_disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@
|
||||
* memoised reads the account payload and every module gate use;
|
||||
* - provider presence: the record's credential row and the operator's
|
||||
* provider columns (`probeProviderChain`), presence only;
|
||||
* - consent: the kinds of the record's active receipts, one indexed read.
|
||||
* - consent: the kinds of the record's active receipts, one indexed read;
|
||||
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
|
||||
* and one point read of the record's choice.
|
||||
*
|
||||
* Any failure resolves to `null`, which the resolver turns into
|
||||
* `check_failed` for every capability. Failing closed is safe because no data
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
type AiRecordKind,
|
||||
} from "./resolve";
|
||||
import type { AiCapabilities } from "./types";
|
||||
import { loadAiUsageState, loadRecordUsageLevel } from "./usage";
|
||||
|
||||
/** Whose record, seen by whom, through which grant. */
|
||||
export interface AiCapabilityScope {
|
||||
@@ -89,14 +92,21 @@ export function loadAiCapabilityInputs(
|
||||
`ai-capability-inputs:${scopeKey(scope)}`,
|
||||
async () => {
|
||||
try {
|
||||
const [switches, modules, availability, provider, activeConsentKinds] =
|
||||
await Promise.all([
|
||||
loadAssistantSwitches(),
|
||||
resolveModuleMap(scope.recordId),
|
||||
getOperatorModuleAvailability(),
|
||||
probeProviderChain(scope.recordId, scope.authority),
|
||||
loadActiveConsentKinds(scope.recordId),
|
||||
]);
|
||||
const [
|
||||
switches,
|
||||
modules,
|
||||
availability,
|
||||
provider,
|
||||
activeConsentKinds,
|
||||
usageLevel,
|
||||
] = await Promise.all([
|
||||
loadAssistantSwitches(),
|
||||
resolveModuleMap(scope.recordId),
|
||||
getOperatorModuleAvailability(),
|
||||
probeProviderChain(scope.recordId, scope.authority),
|
||||
loadActiveConsentKinds(scope.recordId),
|
||||
loadRecordUsageLevel(scope.recordId),
|
||||
]);
|
||||
if (switches === null) return null;
|
||||
const { moduleAccess } = buildModuleDisclosure(
|
||||
modules,
|
||||
@@ -110,6 +120,7 @@ export function loadAiCapabilityInputs(
|
||||
provider,
|
||||
activeConsentKinds,
|
||||
recordKind: scope.recordKind,
|
||||
usageLevel,
|
||||
};
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
@@ -122,9 +133,22 @@ export function loadAiCapabilityInputs(
|
||||
);
|
||||
}
|
||||
|
||||
/** The whole `ai` block for one scope. */
|
||||
/**
|
||||
* The whole `ai` block for one scope, the record's AI usage level included
|
||||
* (v1.43). The measured use and the estimates are published for one's own
|
||||
* record only: a delegate sees which level a record runs at, not what its
|
||||
* owner spends. A usage read that fails leaves `usage` out rather than
|
||||
* failing the block; the capabilities already carry the level they resolved.
|
||||
*/
|
||||
export async function loadAiCapabilities(
|
||||
scope: AiCapabilityScope,
|
||||
): Promise<AiCapabilities> {
|
||||
return resolveAiBlock(await loadAiCapabilityInputs(scope));
|
||||
const [inputs, usage] = await Promise.all([
|
||||
loadAiCapabilityInputs(scope),
|
||||
loadAiUsageState(scope.recordId, {
|
||||
withMeasurement: scope.recordKind === "self",
|
||||
}).catch(() => undefined),
|
||||
]);
|
||||
const block = resolveAiBlock(inputs);
|
||||
return usage ? { ...block, usage } : block;
|
||||
}
|
||||
@@ -8,12 +8,14 @@
|
||||
*/
|
||||
import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { AiUsageLevel } from "@/lib/ai/usage-level";
|
||||
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_PROVIDER_NONE_ERROR_CODE,
|
||||
AI_RECORD_NOT_PERMITTED_ERROR_CODE,
|
||||
AI_UNAVAILABLE_ERROR_CODE,
|
||||
AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
|
||||
type AiCapabilityKey,
|
||||
type AiUnavailableReason,
|
||||
} from "./types";
|
||||
@@ -33,6 +35,8 @@ export interface AiRefusalMeta {
|
||||
capability: AiCapabilityKey;
|
||||
reason: AiUnavailableReason;
|
||||
module?: ModuleKey;
|
||||
/** Beside `ai.usage.levelTooLow`: the lowest level the capability needs. */
|
||||
requiredLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
/** The HTTP shape of one refusal. Pure; exported for the envelope test. */
|
||||
@@ -72,6 +76,17 @@ export function aiRefusal(
|
||||
...(module === null ? {} : { module }),
|
||||
},
|
||||
};
|
||||
case "usage_level":
|
||||
return {
|
||||
status: 403,
|
||||
meta: {
|
||||
...base,
|
||||
errorCode: AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
|
||||
...(AI_CAPABILITIES[capability].minUsageLevel
|
||||
? { requiredLevel: AI_CAPABILITIES[capability].minUsageLevel }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
case "no_provider":
|
||||
return {
|
||||
status: noProvider?.status ?? 422,
|
||||
@@ -99,6 +114,7 @@ const REFUSAL_MESSAGE: Record<AiUnavailableReason, string> = {
|
||||
not_permitted_for_record: "AI work is not available for this record",
|
||||
module_disabled: "This AI feature's module is turned off for this record",
|
||||
user_disabled: "This AI feature is turned off in your settings",
|
||||
usage_level: "This AI feature is not available at the current AI usage level",
|
||||
no_provider: "No AI provider is set up for this feature",
|
||||
consent_required: "AI consent is required for this feature",
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isExternalProvider,
|
||||
isOperatorHeldProvider,
|
||||
} from "@/lib/ai/provider-egress";
|
||||
import { usageLevelAtLeast, type AiUsageLevel } from "@/lib/ai/usage-level";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
@@ -88,6 +89,11 @@ export interface AiCapabilityInputs {
|
||||
/** Kinds of the record's active (non-revoked) consent receipts. */
|
||||
activeConsentKinds: ReadonlySet<string>;
|
||||
recordKind: AiRecordKind;
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level in effect. The loader always
|
||||
* sets it; absent (a hand-built set of inputs) means no level restriction.
|
||||
*/
|
||||
usageLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
|
||||
@@ -271,6 +277,15 @@ function consentReason(
|
||||
: "consent_required";
|
||||
}
|
||||
|
||||
/** Whether the record's usage level is below what the capability needs. */
|
||||
function usageReason(
|
||||
def: AiCapabilityDefinition,
|
||||
level: AiUsageLevel | undefined,
|
||||
): AiUnavailableReason | null {
|
||||
if (def.minUsageLevel === undefined || level === undefined) return null;
|
||||
return usageLevelAtLeast(level, def.minUsageLevel) ? null : "usage_level";
|
||||
}
|
||||
|
||||
function stateFor(reason: AiUnavailableReason | null): AiCapabilityState {
|
||||
if (reason === null) {
|
||||
return { available: true, reason: null, onDeviceAllowed: true };
|
||||
@@ -314,6 +329,7 @@ export function explainAiCapability(
|
||||
switchReason,
|
||||
inputs.providerWorkAdmitted ? null : "not_permitted_for_record",
|
||||
modules.reason,
|
||||
usageReason(def, inputs.usageLevel),
|
||||
providerReason(def, inputs.provider),
|
||||
consentReason(def, inputs.provider, inputs.activeConsentKinds),
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* contract, the structural guards — can depend on it without pulling a
|
||||
* database client along.
|
||||
*/
|
||||
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** Every AI capability, in publication order. */
|
||||
@@ -29,6 +30,7 @@ export const AI_CAPABILITY_KEYS = [
|
||||
"documentAi",
|
||||
"labsOcr",
|
||||
"medicationExtract",
|
||||
"supplementLabel",
|
||||
] as const;
|
||||
|
||||
export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
|
||||
@@ -49,6 +51,9 @@ export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
|
||||
* record's own switch or by the edge of the active grant.
|
||||
* - `user_disabled`: the record's own AI opt-out: `disableCoach` for the
|
||||
* Coach, the `insights` module ("AI analysis") for everything else.
|
||||
* - `usage_level`: the record's AI usage level (v1.43, #1217) is below the
|
||||
* level the capability needs (`minUsageLevel`). A choice, like the
|
||||
* opt-out, so it ranks right after it and before the provider layers.
|
||||
* - `no_provider`: no configured provider can serve this capability's input.
|
||||
* - `consent_required`: the provider chain needs a consent receipt and none
|
||||
* is active.
|
||||
@@ -59,6 +64,7 @@ export const AI_UNAVAILABLE_REASONS = [
|
||||
"not_permitted_for_record",
|
||||
"module_disabled",
|
||||
"user_disabled",
|
||||
"usage_level",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const;
|
||||
@@ -166,6 +172,11 @@ export interface AiCapabilityDefinition {
|
||||
consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] };
|
||||
modality: AiModality;
|
||||
providerOrder: AiProviderOrder;
|
||||
/**
|
||||
* v1.43 (#1217) — the lowest AI usage level at which the capability is
|
||||
* offered. Absent: every level.
|
||||
*/
|
||||
minUsageLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,6 +300,18 @@ export const AI_CAPABILITIES: Readonly<
|
||||
modality: "text",
|
||||
providerOrder: "chain",
|
||||
},
|
||||
// v1.43 — reading a supplement's label photo into its ingredients. A photo
|
||||
// is a document, and an extra model run per label, so it is not offered on
|
||||
// the `frugal` usage level.
|
||||
supplementLabel: {
|
||||
key: "supplementLabel",
|
||||
operatorSwitch: "documentAi",
|
||||
modules: { mode: "all", keys: ["medications", "nutrients"] },
|
||||
consent: { rule: "document", kinds: EXTRACTION_KINDS },
|
||||
modality: "document",
|
||||
providerOrder: "document",
|
||||
minUsageLevel: "standard",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -328,6 +351,11 @@ export interface AiProviderState {
|
||||
export interface AiCapabilities {
|
||||
capabilities: Record<AiCapabilityKey, AiCapabilityState>;
|
||||
provider: AiProviderState;
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level, its bounds and the estimate.
|
||||
* Absent on servers before v1.43 and on blocks resolved without it.
|
||||
*/
|
||||
usage?: AiUsageState;
|
||||
}
|
||||
|
||||
// ── Refusal codes ──────────────────────────────────────────────────────────
|
||||
@@ -345,3 +373,5 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted";
|
||||
export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none";
|
||||
/** The capability's inputs could not be loaded; the answer failed closed. */
|
||||
export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable";
|
||||
/** v1.43 — the record's AI usage level is below what the capability needs. */
|
||||
export const AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE = "ai.usage.levelTooLow";
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* v1.43 (#1217) — the reads behind an AI usage level.
|
||||
*
|
||||
* The vocabulary, the resolver and the estimate are pure and live in
|
||||
* `@/lib/ai/usage-level`; this file gathers their inputs:
|
||||
*
|
||||
* - the operator's bounds and sync gap: the `AppSettings` singleton, one
|
||||
* memoised read per request (read afresh inside a background run, so a
|
||||
* changed ceiling reaches the next account of a nightly pass);
|
||||
* - the record's stored choice: one point read of `users.ai_usage_level`;
|
||||
* - the measured use: the record's `coach_usage` rows of the last seven
|
||||
* full days in its own time zone.
|
||||
*
|
||||
* A level is resolved for the RECORD, like every AI capability: a background
|
||||
* job runs per record, and a delegate or guardian inside somebody else's
|
||||
* record sees that record's level. Only the account itself can change it
|
||||
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
|
||||
* and follows the instance default.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
AI_USAGE_LEVEL_EFFECTS,
|
||||
AI_USAGE_MEASURE_DAYS,
|
||||
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
|
||||
DEFAULT_AI_USAGE_MAX_LEVEL,
|
||||
DEFAULT_SYNC_REGEN_GAP_HOURS,
|
||||
buildAiUsageState,
|
||||
parseSyncRegenGapHours,
|
||||
resolveAiUsageLevel,
|
||||
usageLevelFromDb,
|
||||
type AiUsageLevel,
|
||||
type AiUsageResolution,
|
||||
type AiUsageState,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
/** The operator's side of the usage levels. */
|
||||
export interface AiUsageSettings {
|
||||
maxLevel: AiUsageLevel;
|
||||
defaultLevel: AiUsageLevel;
|
||||
/** The stored sync gap (1..48), `null` when the operator set none. */
|
||||
storedSyncRegenGapHours: number | null;
|
||||
/** The sync gap in effect: the stored one, else the environment, else six. */
|
||||
syncRegenGapHours: number;
|
||||
}
|
||||
|
||||
/** The environment's sync gap, else six. */
|
||||
export function envSyncRegenGapHours(): number {
|
||||
return (
|
||||
parseSyncRegenGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ??
|
||||
DEFAULT_SYNC_REGEN_GAP_HOURS
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure: the operator's settings from the raw row (or its absence). */
|
||||
export function aiUsageSettingsFromRow(
|
||||
row: {
|
||||
aiUsageMaxLevel?: string | null;
|
||||
aiUsageDefaultLevel?: string | null;
|
||||
aiSyncRegenGapHours?: number | null;
|
||||
} | null,
|
||||
): AiUsageSettings {
|
||||
const stored = parseSyncRegenGapHours(row?.aiSyncRegenGapHours);
|
||||
return {
|
||||
maxLevel:
|
||||
usageLevelFromDb(row?.aiUsageMaxLevel) ?? DEFAULT_AI_USAGE_MAX_LEVEL,
|
||||
defaultLevel:
|
||||
usageLevelFromDb(row?.aiUsageDefaultLevel) ??
|
||||
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
|
||||
storedSyncRegenGapHours: stored,
|
||||
syncRegenGapHours: stored ?? envSyncRegenGapHours(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's bounds and sync gap. An unreadable row reads as the
|
||||
* instance defaults (`standard` / `standard`, the environment's gap): the
|
||||
* levels bound spend, and the default is what an untouched instance runs at.
|
||||
*/
|
||||
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
|
||||
const read = async (): Promise<AiUsageSettings> => {
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
},
|
||||
});
|
||||
return aiUsageSettingsFromRow(row);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
"Failed to load the AI usage settings; the instance defaults apply",
|
||||
);
|
||||
return aiUsageSettingsFromRow(null);
|
||||
}
|
||||
};
|
||||
try {
|
||||
return memoizePerRequest("ai-usage-settings", read, {
|
||||
freshInBackground: true,
|
||||
});
|
||||
} catch {
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
/** One record's level: its stored choice against the operator's bounds. */
|
||||
export interface RecordAiUsage extends AiUsageResolution {
|
||||
preference: AiUsageLevel | null;
|
||||
settings: AiUsageSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level in effect for one record. A missing row (an account deleted
|
||||
* under a running job) reads as no choice, so the default applies.
|
||||
*/
|
||||
export function loadRecordAiUsage(recordId: string): Promise<RecordAiUsage> {
|
||||
const read = async (): Promise<RecordAiUsage> => {
|
||||
const [settings, row] = await Promise.all([
|
||||
loadAiUsageSettings(),
|
||||
prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: { aiUsageLevel: true },
|
||||
}),
|
||||
]);
|
||||
const preference = usageLevelFromDb(row?.aiUsageLevel);
|
||||
return {
|
||||
...resolveAiUsageLevel({
|
||||
preference,
|
||||
defaultLevel: settings.defaultLevel,
|
||||
maxLevel: settings.maxLevel,
|
||||
}),
|
||||
preference,
|
||||
settings,
|
||||
};
|
||||
};
|
||||
try {
|
||||
return memoizePerRequest(`ai-usage:${recordId}`, read, {
|
||||
freshInBackground: true,
|
||||
});
|
||||
} catch {
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
/** Just the effective level, for a job or a gate. */
|
||||
export async function loadRecordUsageLevel(
|
||||
recordId: string,
|
||||
): Promise<AiUsageLevel> {
|
||||
return (await loadRecordAiUsage(recordId)).level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status snapshot's char cap for this record's level. An unreadable level
|
||||
* reads as the instance default's cap, never as an uncapped snapshot.
|
||||
*/
|
||||
export async function statusSnapshotCharsForRecord(
|
||||
recordId: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const level = await loadRecordUsageLevel(recordId);
|
||||
return AI_USAGE_LEVEL_EFFECTS[level].statusSnapshotChars;
|
||||
} catch {
|
||||
return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL]
|
||||
.statusSnapshotChars;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's measured AI use: the mean of its `coach_usage` totals over the
|
||||
* last seven full days in its own time zone (today is still running and does
|
||||
* not count). `coach_usage` counts the Coach and the background work
|
||||
* together. A day without a row had no AI use recorded and is not counted, so
|
||||
* `days` says how many days stand behind the mean.
|
||||
*/
|
||||
export async function loadMeasuredAiUsage(
|
||||
recordId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ tokensPerDay: number | null; days: number }> {
|
||||
const today = userDayKey(now, await resolveUserTimezone(recordId));
|
||||
const keys = Array.from({ length: AI_USAGE_MEASURE_DAYS }, (_, i) =>
|
||||
shiftDateKey(today, -(i + 1)),
|
||||
);
|
||||
const rows = await prisma.coachUsage.findMany({
|
||||
where: { userId: recordId, dateKey: { in: keys } },
|
||||
select: { totalTokens: true },
|
||||
});
|
||||
if (rows.length === 0) return { tokensPerDay: null, days: 0 };
|
||||
const total = rows.reduce((sum, row) => sum + row.totalTokens, 0);
|
||||
return { tokensPerDay: Math.round(total / rows.length), days: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ai.usage` for one record. `withMeasurement: false` publishes the levels
|
||||
* without the measured use or the estimates: a delegate sees which level the
|
||||
* record runs at, never how much the owner spends.
|
||||
*/
|
||||
export async function loadAiUsageState(
|
||||
recordId: string,
|
||||
options: { withMeasurement: boolean },
|
||||
): Promise<AiUsageState> {
|
||||
const [usage, measured] = await Promise.all([
|
||||
loadRecordAiUsage(recordId),
|
||||
options.withMeasurement
|
||||
? loadMeasuredAiUsage(recordId).catch(() => ({
|
||||
tokensPerDay: null,
|
||||
days: 0,
|
||||
}))
|
||||
: Promise.resolve({ tokensPerDay: null, days: 0 }),
|
||||
]);
|
||||
return buildAiUsageState({
|
||||
preference: usage.preference,
|
||||
defaultLevel: usage.settings.defaultLevel,
|
||||
maxLevel: usage.settings.maxLevel,
|
||||
measuredTokensPerDay: measured.tokensPerDay,
|
||||
measuredDays: measured.days,
|
||||
});
|
||||
}
|
||||
@@ -225,7 +225,10 @@ describe("buildCoachSnapshot", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(out.snapshotJson).toBe(JSON.stringify(out.sections, null, 2));
|
||||
// The prompt carries the snapshot compact (#1217): the same content,
|
||||
// without the indentation.
|
||||
expect(out.snapshotJson).toBe(JSON.stringify(out.sections));
|
||||
expect(JSON.parse(out.snapshotJson)).toEqual(out.sections);
|
||||
expect(out).toMatchInlineSnapshot(`
|
||||
{
|
||||
"provenance": {
|
||||
@@ -468,199 +471,7 @@ describe("buildCoachSnapshot", () => {
|
||||
"unit": "kg",
|
||||
},
|
||||
},
|
||||
"snapshotJson": "{
|
||||
"bloodPressure": {
|
||||
"aggregate": {
|
||||
"avgSys30": 125,
|
||||
"avgDia30": 80,
|
||||
"coverage": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"weekday": "Fri",
|
||||
"sys": 128,
|
||||
"dia": 82
|
||||
}
|
||||
],
|
||||
"weeklySys": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 122,
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"weeklyDia": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 78,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": {
|
||||
"unit": "kg",
|
||||
"aggregate": {
|
||||
"latest": 80.8,
|
||||
"bmi": 24.7,
|
||||
"coverage": {
|
||||
"count": 2
|
||||
},
|
||||
"unit": "kg"
|
||||
},
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"weekday": "Thu",
|
||||
"value": 80.8
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 81.4,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"pulse": {
|
||||
"aggregate": {
|
||||
"avg7": 63,
|
||||
"coverage": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-17",
|
||||
"weekday": "Fri",
|
||||
"value": 63
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 67,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"aggregate": {
|
||||
"avg30": 4,
|
||||
"coverage": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-18",
|
||||
"weekday": "Sat",
|
||||
"value": 5
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 3,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bodyMassIndex": {
|
||||
"unit": "kg/m²",
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"weekday": "Wed",
|
||||
"value": 24.8
|
||||
},
|
||||
{
|
||||
"date": "2026-07-15",
|
||||
"weekday": "Wed",
|
||||
"value": 24.6
|
||||
}
|
||||
],
|
||||
"weekly": []
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"unit": "steps",
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-18",
|
||||
"weekday": "Sat",
|
||||
"value": 8400
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 7200,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"skinTemperature": {
|
||||
"unit": "°C",
|
||||
"timeline": {
|
||||
"recent": [
|
||||
{
|
||||
"date": "2026-07-19",
|
||||
"weekday": "Sun",
|
||||
"value": 33.5
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"weekISO": "2026-W27",
|
||||
"mean": 33.2,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"anthropometrics": {
|
||||
"heightCm": 181,
|
||||
"ageYears": 41,
|
||||
"gender": "MALE"
|
||||
},
|
||||
"dayStrain": {
|
||||
"source": "WHOOP-native",
|
||||
"scale": "0-21",
|
||||
"latest": 13.9,
|
||||
"recentMean": 12.6,
|
||||
"days": 2,
|
||||
"note": "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."
|
||||
},
|
||||
"scope": {
|
||||
"window": "last30days",
|
||||
"sources": [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"steps",
|
||||
"bmi",
|
||||
"skin_temp",
|
||||
"hrv"
|
||||
],
|
||||
"timelineRecentDays": 14
|
||||
}
|
||||
}",
|
||||
"snapshotJson": "{"bloodPressure":{"aggregate":{"avgSys30":125,"avgDia30":80,"coverage":{"count":4}},"timeline":{"recent":[{"date":"2026-07-10","weekday":"Fri","sys":128,"dia":82}],"weeklySys":[{"weekISO":"2026-W27","mean":122,"count":1}],"weeklyDia":[{"weekISO":"2026-W27","mean":78,"count":1}]}},"weight":{"unit":"kg","aggregate":{"latest":80.8,"bmi":24.7,"coverage":{"count":2},"unit":"kg"},"timeline":{"recent":[{"date":"2026-07-16","weekday":"Thu","value":80.8}],"weekly":[{"weekISO":"2026-W27","mean":81.4,"count":1}]}},"pulse":{"aggregate":{"avg7":63,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-17","weekday":"Fri","value":63}],"weekly":[{"weekISO":"2026-W27","mean":67,"count":1}]}},"mood":{"aggregate":{"avg30":4,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":5}],"weekly":[{"weekISO":"2026-W27","mean":3,"count":1}]}},"bodyMassIndex":{"unit":"kg/m²","timeline":{"recent":[{"date":"2026-07-08","weekday":"Wed","value":24.8},{"date":"2026-07-15","weekday":"Wed","value":24.6}],"weekly":[]}},"steps":{"unit":"steps","timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":8400}],"weekly":[{"weekISO":"2026-W27","mean":7200,"count":1}]}},"skinTemperature":{"unit":"°C","timeline":{"recent":[{"date":"2026-07-19","weekday":"Sun","value":33.5}],"weekly":[{"weekISO":"2026-W27","mean":33.2,"count":1}]}},"anthropometrics":{"heightCm":181,"ageYears":41,"gender":"MALE"},"dayStrain":{"source":"WHOOP-native","scale":"0-21","latest":13.9,"recentMean":12.6,"days":2,"note":"Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."},"scope":{"window":"last30days","sources":["bp","weight","pulse","mood","steps","bmi","skin_temp","hrv"],"timelineRecentDays":14}}",
|
||||
"units": {
|
||||
"glucoseUnit": "mg/dL",
|
||||
"system": "metric",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* #1217 — the Coach's cached prefix (tools plus system prompt) is the same
|
||||
* string on every turn of a conversation. What changes per turn (the opener
|
||||
* hint, the name clause, a resolved follow-up, the table rules once a table
|
||||
* exists) travels in the user message, so a provider's prompt cache holds
|
||||
* from one turn to the next instead of being rewritten every time.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCoachToolRequest } from "@/lib/ai/coach/chat-request-builder";
|
||||
import {
|
||||
buildCoachTurnNotes,
|
||||
getCoachSystemPrompt,
|
||||
} from "@/lib/ai/coach/system-prompt";
|
||||
import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum";
|
||||
import {
|
||||
buildDialogAddenda,
|
||||
buildTableRulesAddendum,
|
||||
} from "@/lib/ai/coach/turn/addenda";
|
||||
import {
|
||||
openerArchetypeHint,
|
||||
shouldUseNameForTurn,
|
||||
} from "@/lib/ai/prompts/opener-archetype";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const sha = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
/** One turn's request, assembled the way the turn model assembles it. */
|
||||
function turnRequest(locale: Locale, turnIndex: number, hints: string[]) {
|
||||
const systemPrompt = getCoachSystemPrompt(locale, undefined, "I run.");
|
||||
const turnNotes = buildCoachTurnNotes(locale, {
|
||||
firstName: "Alex",
|
||||
mayUseName: shouldUseNameForTurn(`u1:${turnIndex}`),
|
||||
openerHint: openerArchetypeHint(`u1:${turnIndex}`, locale),
|
||||
});
|
||||
const tables = turnIndex > 1 ? [buildTableRulesAddendum(locale)] : [];
|
||||
return buildCoachToolRequest({
|
||||
systemPrompt,
|
||||
toolModeAddendum: `${buildToolModeAddendum(locale)}\n\n${buildDialogAddenda(
|
||||
locale,
|
||||
{ tableRules: false },
|
||||
)}`,
|
||||
focusHint: "",
|
||||
workoutEvidence: null,
|
||||
dataInventory: "DATA INVENTORY\n- pulse",
|
||||
guidedBlock: "",
|
||||
transcript: `user: question ${turnIndex}`,
|
||||
languageName: "English",
|
||||
turnNotes: [...tables, turnNotes, ...hints]
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n\n"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("stable Coach prefix (#1217)", () => {
|
||||
it.each(["en", "de", "fr", "ko"] as Locale[])(
|
||||
"hashes the system prompt identically on every turn (%s)",
|
||||
(locale) => {
|
||||
const hashes = new Set<string>();
|
||||
for (let turn = 0; turn < 12; turn++) {
|
||||
const request = turnRequest(
|
||||
locale,
|
||||
turn,
|
||||
turn % 3 === 0 ? [`FOLLOW-UP: earlier answer ${turn}`] : [],
|
||||
);
|
||||
hashes.add(sha(request.system));
|
||||
}
|
||||
expect(hashes.size).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("carries the per-turn lines in the user message instead", () => {
|
||||
const opener = openerArchetypeHint("u1:4", "en");
|
||||
const request = turnRequest("en", 4, ["FOLLOW-UP: the pulse table"]);
|
||||
const user = String(request.messages[0]!.content);
|
||||
expect(user).toContain(opener);
|
||||
expect(user).toContain("FOLLOW-UP: the pulse table");
|
||||
expect(user).toContain(buildTableRulesAddendum("en"));
|
||||
expect(request.system).not.toContain(opener);
|
||||
expect(request.system).not.toContain("FOLLOW-UP");
|
||||
expect(request.system).not.toContain(buildTableRulesAddendum("en"));
|
||||
});
|
||||
|
||||
it("keeps the opener hint and the name out of the system prompt", () => {
|
||||
for (const locale of ["en", "de"] as Locale[]) {
|
||||
const system = getCoachSystemPrompt(locale, undefined, null);
|
||||
expect(system).not.toMatch(
|
||||
/OPENER HINT for this turn|OPENER-HINWEIS für diesen Turn/,
|
||||
);
|
||||
expect(system).not.toContain("Alex");
|
||||
}
|
||||
});
|
||||
|
||||
it("names the turn's opener and, on a gated turn, the first name", () => {
|
||||
const notes = buildCoachTurnNotes("en", {
|
||||
firstName: "Alex",
|
||||
mayUseName: true,
|
||||
openerHint: "Lead with the finding.",
|
||||
});
|
||||
expect(notes).toContain(
|
||||
"OPENER HINT for this turn: Lead with the finding.",
|
||||
);
|
||||
expect(notes).toContain('the user\'s first name is "Alex"');
|
||||
expect(buildCoachTurnNotes("en", {})).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -105,11 +105,24 @@ function lastParams() {
|
||||
const calls = runRawCompletionWithFallback.mock.calls;
|
||||
return calls[calls.length - 1][0].params as {
|
||||
system: string;
|
||||
messages: Array<{ role: string; content: unknown }>;
|
||||
toolChoice?: string;
|
||||
tools?: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The final round's reason, as the model reads it: the last user-side line.
|
||||
* #1217 — it no longer rides the system prompt, which stays the cached
|
||||
* prefix every round of the turn shares.
|
||||
*/
|
||||
function finalRoundLine(): string {
|
||||
const last = lastParams().messages.at(-1);
|
||||
expect(last?.role).toBe("user");
|
||||
expect(lastParams().system).toBe(baseArgs.system);
|
||||
return String(last?.content);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
emitSignal.mockReset();
|
||||
executeCoachTool.mockReset();
|
||||
@@ -138,9 +151,7 @@ describe("stop reasons", () => {
|
||||
// The tools stay on the final round: the history holds the calls, and
|
||||
// Anthropic refuses tool_use/tool_result blocks without `tools` (400).
|
||||
expect(lastParams().tools).toEqual(COACH_TOOL_DEFS);
|
||||
expect(lastParams().system).toMatch(
|
||||
/FINAL ROUND: this turn's token budget/,
|
||||
);
|
||||
expect(finalRoundLine()).toMatch(/FINAL ROUND: this turn's token budget/);
|
||||
expect(out.result.content).toBe("Answer from what I have.");
|
||||
});
|
||||
|
||||
@@ -209,7 +220,7 @@ describe("stop reasons", () => {
|
||||
expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 });
|
||||
expect(out.forcedFinal).toBe(false);
|
||||
expectFinalRoundKeepsTools();
|
||||
expect(lastParams().system).toMatch(/brought nothing new/);
|
||||
expect(finalRoundLine()).toMatch(/brought nothing new/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -148,6 +148,13 @@ export function buildCoachToolRequest(args: {
|
||||
guidedBlock: string;
|
||||
transcript: string;
|
||||
languageName: string;
|
||||
/**
|
||||
* v1.43 (#1217) — what changes from one turn to the next: the opener hint,
|
||||
* the name clause, a resolved follow-up, the table rules once a table
|
||||
* exists. Carried here, on the user side, so `system` is the same string
|
||||
* on every turn and every round. Empty or absent: nothing is added.
|
||||
*/
|
||||
turnNotes?: string;
|
||||
}): { system: string; messages: AiMessage[] } {
|
||||
const focusBlock = args.focusHint ? `${args.focusHint}\n\n` : "";
|
||||
const workoutDataBlock =
|
||||
@@ -160,7 +167,7 @@ ${fenceHealthData(JSON.stringify({ thisWorkout: args.workoutEvidence }))}
|
||||
const messages: AiMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}
|
||||
content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}${args.turnNotes ? `\n\n${args.turnNotes}` : ""}
|
||||
|
||||
CONVERSATION
|
||||
${args.transcript}
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("runCoachToolLoop — result tables", () => {
|
||||
expect(JSON.stringify(out.toolResults)).not.toContain("987654");
|
||||
});
|
||||
|
||||
it("adds the table rules to the rounds after the first table, not before", async () => {
|
||||
it("adds the table rules to the rounds after the first table, not before, without touching the system prompt", async () => {
|
||||
executeCoachTool
|
||||
.mockResolvedValueOnce({ present: true, data: { n: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -113,16 +113,27 @@ describe("runCoachToolLoop — result tables", () => {
|
||||
name,
|
||||
arguments: '{"metric":"pulse"}',
|
||||
});
|
||||
runRawCompletionWithFallback
|
||||
.mockResolvedValueOnce(round("", [call("a", "get_metric_series")]))
|
||||
.mockResolvedValueOnce(round("", [call("b", "get_metric_table")]))
|
||||
.mockResolvedValueOnce(round("answer"));
|
||||
// The loop hands every round the same growing message array, so record
|
||||
// what each round saw at the moment it was called.
|
||||
const seen: Array<{ system: string; messages: unknown[] }> = [];
|
||||
const replies = [
|
||||
round("", [call("a", "get_metric_series")]),
|
||||
round("", [call("b", "get_metric_table")]),
|
||||
round("answer"),
|
||||
];
|
||||
runRawCompletionWithFallback.mockImplementation(async (arg: unknown) => {
|
||||
const { params } = arg as {
|
||||
params: { system: string; messages: unknown[] };
|
||||
};
|
||||
seen.push({ system: params.system, messages: [...params.messages] });
|
||||
return replies[seen.length - 1];
|
||||
});
|
||||
|
||||
await runCoachToolLoop({
|
||||
userId: "u1",
|
||||
providers: [],
|
||||
system: "sys",
|
||||
systemOnceTableShown: "sys+tables",
|
||||
tableRulesOnceShown: "TABLE RULES",
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
tools: COACH_TOOL_DEFS,
|
||||
reach: UNBOUNDED_REACH,
|
||||
@@ -133,9 +144,26 @@ describe("runCoachToolLoop — result tables", () => {
|
||||
refs: createResultRefAllocator(),
|
||||
},
|
||||
});
|
||||
const systems = runRawCompletionWithFallback.mock.calls.map(
|
||||
([arg]) => (arg as { params: { system: string } }).params.system,
|
||||
// #1217 — one system prompt for every round, so the cached prefix holds.
|
||||
expect(seen.map((round) => round.system)).toEqual(["sys", "sys", "sys"]);
|
||||
const rulesIn = seen.map((round) =>
|
||||
round.messages.some(
|
||||
(m) =>
|
||||
(m as { role: string; content: unknown }).role === "user" &&
|
||||
(m as { content: unknown }).content === "TABLE RULES",
|
||||
),
|
||||
);
|
||||
expect(rulesIn).toEqual([false, false, true]);
|
||||
// The rules follow the tool results that produced the table.
|
||||
const last = seen[2]!.messages as Array<{ role: string }>;
|
||||
expect(last.at(-1)).toEqual({ role: "user", content: "TABLE RULES" });
|
||||
expect(last.at(-2)?.role).toBe("tool");
|
||||
// Each round's messages extend the previous round's: a stable prefix.
|
||||
expect(seen[1]!.messages.slice(0, seen[0]!.messages.length)).toEqual(
|
||||
seen[0]!.messages,
|
||||
);
|
||||
expect(seen[2]!.messages.slice(0, seen[1]!.messages.length)).toEqual(
|
||||
seen[1]!.messages,
|
||||
);
|
||||
expect(systems).toEqual(["sys", "sys", "sys+tables"]);
|
||||
});
|
||||
});
|
||||
@@ -36,16 +36,19 @@ import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
|
||||
export const DAILY_TIMELINE_DAYS = 14;
|
||||
|
||||
/**
|
||||
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
|
||||
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
|
||||
* token proxy and, if it exceeds this cap, progressively degrade the
|
||||
* lowest-priority clusters (drop `timeline.recent`, then collapse the
|
||||
* weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
|
||||
* sits comfortably inside every provider's context alongside the system
|
||||
* prompt + history window. The daily token ledger (`budget.ts`) stays
|
||||
* the per-day cost backstop; this is the per-prompt shape backstop.
|
||||
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is built we
|
||||
* measure its compact JSON (`JSON.stringify(snapshot).length`, the form the
|
||||
* prompt and every tool result ship) and, if it exceeds this cap,
|
||||
* progressively degrade the lowest-priority clusters (drop
|
||||
* `timeline.recent`, then collapse the weekly buckets) until it fits. The
|
||||
* daily token ledger (`budget.ts`) stays the per-day cost backstop; this is
|
||||
* the per-prompt shape backstop.
|
||||
*
|
||||
* v1.43 (#1217) — 24 000 pretty-printed characters became 14 000 compact
|
||||
* ones. Compact JSON is ~0.42 to ~0.5 of the pretty form for these blocks, so
|
||||
* everything that fitted before still fits: the same data, fewer tokens.
|
||||
*/
|
||||
export const MAX_SNAPSHOT_CHARS = 24_000;
|
||||
export const MAX_SNAPSHOT_CHARS = 14_000;
|
||||
|
||||
/**
|
||||
* v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
|
||||
|
||||
@@ -1128,7 +1128,7 @@ async function buildCoachSnapshotImpl(
|
||||
}
|
||||
|
||||
return {
|
||||
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
|
||||
snapshotJson: JSON.stringify(compactSnapshot),
|
||||
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
|
||||
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
|
||||
// executor slices a single domain block out of this so a retrieval tool
|
||||
@@ -1175,10 +1175,9 @@ export function degradeToBudget(
|
||||
cluster: CoachDataCluster;
|
||||
pass: number;
|
||||
}> = [];
|
||||
// Measure against the SAME pretty-printed form the prompt ships
|
||||
// (`JSON.stringify(snapshot, null, 2)`), not the compact form —
|
||||
// otherwise the cap under-counts by ~2× and the prompt overflows.
|
||||
const size = () => JSON.stringify(snapshot, null, 2).length;
|
||||
// Measure against the SAME compact form the prompt ships
|
||||
// (`JSON.stringify(snapshot)`), so the cap counts what the model reads.
|
||||
const size = () => JSON.stringify(snapshot).length;
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) return degraded;
|
||||
|
||||
// Build a degrade order: blocks grouped by cluster, lowest priority
|
||||
|
||||
@@ -78,7 +78,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Above this pretty-printed size the section goes to the full builder, whose
|
||||
* Above this compact size the section goes to the full builder, whose
|
||||
* budget pass may trim it (see the module comment). A 30-day section is a few
|
||||
* thousand characters; a year of blood pressure with its coarse tail is the
|
||||
* kind of section that crosses it.
|
||||
@@ -240,7 +240,7 @@ export async function buildCoachSourceSnapshot(
|
||||
const sections = compactSections(snapshot);
|
||||
annotateSnapshotFreshness(sections);
|
||||
|
||||
const size = JSON.stringify(sections, null, 2).length;
|
||||
const size = JSON.stringify(sections).length;
|
||||
if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) {
|
||||
annotate({
|
||||
action: { name: "coach.source_snapshot.full" },
|
||||
|
||||
@@ -1249,8 +1249,12 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
|
||||
/**
|
||||
* v1.22 (W6) — per-turn personalization passed by the chat route. All fields
|
||||
* are optional and the addendum is omitted when none apply, so the prompt is
|
||||
* byte-identical to the pre-feature one for the coverage test + unnamed users.
|
||||
* are optional and the notes are empty when none apply.
|
||||
*
|
||||
* v1.43 (#1217) — these lines change every turn, so they no longer ride the
|
||||
* system prompt: a system prompt that changed per turn rewrote the provider's
|
||||
* cached prefix (tools plus system, 14 to 19 K tokens) on every turn. They
|
||||
* are composed by {@link buildCoachTurnNotes} and travel in the user message.
|
||||
*/
|
||||
export interface CoachPersonalization {
|
||||
/** The user's first name; null when no display name is set. */
|
||||
@@ -1261,11 +1265,17 @@ export interface CoachPersonalization {
|
||||
openerHint?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coach system prompt. Stable for one person across every turn and every
|
||||
* round of a turn: it depends on the locale, the Coach preferences and the
|
||||
* about-me text only, so the provider's prompt cache holds. Anything that
|
||||
* changes per turn (the opener hint, the name clause, a resolved follow-up)
|
||||
* goes into the user message instead.
|
||||
*/
|
||||
export function getCoachSystemPrompt(
|
||||
locale: Locale,
|
||||
prefs: CoachPrefs = DEFAULT_COACH_PREFS,
|
||||
aboutMe: string | null = null,
|
||||
personalization: CoachPersonalization = {},
|
||||
): string {
|
||||
let base: string;
|
||||
if (locale === "de") {
|
||||
@@ -1313,11 +1323,11 @@ export function getCoachSystemPrompt(
|
||||
// may only link a slug it lists, so a fabricated /learn URL is impossible.
|
||||
const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`;
|
||||
// v1.22 (W6) — the narrative-quality addendum: the context-layer rule, the
|
||||
// single-chart-token exception, the BP usual-range verbatim clause, plus the
|
||||
// per-turn opener hint and sparse first-name personalization. Composed after
|
||||
// the shared contracts so the chart-token exception can carve out the one
|
||||
// place a `metric:<TYPE>` token is permitted.
|
||||
const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale, personalization)}`;
|
||||
// single-chart-token exception, the BP usual-range verbatim clause. Composed
|
||||
// after the shared contracts so the chart-token exception can carve out the
|
||||
// one place a `metric:<TYPE>` token is permitted. The per-turn opener hint
|
||||
// and name clause are in `buildCoachTurnNotes`.
|
||||
const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale)}`;
|
||||
// v1.15.20 — the user-authored "about me" self-description rides the
|
||||
// system prompt as a delimited, user-provided block (Settings → AI).
|
||||
const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : "";
|
||||
@@ -1338,14 +1348,11 @@ export function getCoachSystemPrompt(
|
||||
* - BP USUAL RANGE — when the snapshot's blood-pressure block carries a
|
||||
* `usualRange`, quote THOSE bounds verbatim; never invent a band from window
|
||||
* means (kills the fabricated "153–150" range).
|
||||
* - OPENER HINT — vary the turn's opening so a multi-turn session does not open
|
||||
* identically every time.
|
||||
* - NAME — sparse, anti-formulaic first-name address on the gated turns only.
|
||||
*
|
||||
* The per-turn opener hint and first-name clause that used to close this
|
||||
* addendum are {@link buildCoachTurnNotes} now.
|
||||
*/
|
||||
function buildCoachV122Addendum(
|
||||
locale: Locale,
|
||||
personalization: CoachPersonalization,
|
||||
): string {
|
||||
function buildCoachV122Addendum(locale: Locale): string {
|
||||
const isDe = instructionLocale(locale) === "de";
|
||||
const parts: string[] = [];
|
||||
parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)");
|
||||
@@ -1392,6 +1399,21 @@ function buildCoachV122Addendum(
|
||||
: `EXPERIMENT REVIEW — when the snapshot carries an "experimentOutcomes" block, read the result back in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
|
||||
);
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — the lines that change every Coach turn: the hash-seeded
|
||||
* opener hint and, on the gated turns, the first-name clause. They travel in
|
||||
* the user message so the system prompt stays byte-stable across turns and
|
||||
* the provider's prompt cache holds. Empty when neither applies.
|
||||
*/
|
||||
export function buildCoachTurnNotes(
|
||||
locale: Locale,
|
||||
personalization: CoachPersonalization,
|
||||
): string {
|
||||
const isDe = instructionLocale(locale) === "de";
|
||||
const parts: string[] = [];
|
||||
if (personalization.openerHint) {
|
||||
parts.push(
|
||||
isDe
|
||||
@@ -1409,7 +1431,8 @@ function buildCoachV122Addendum(
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
if (parts.length === 0) return "";
|
||||
return [isDe ? "FÜR DIESEN TURN" : "FOR THIS TURN", ...parts].join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,10 +194,13 @@ export async function runCoachToolLoop(args: {
|
||||
providers: ProviderChainResolved[];
|
||||
system: string;
|
||||
/**
|
||||
* v1.39.4 — the system prompt for the rounds after a call produced a
|
||||
* table, when it differs (the table rules ride it).
|
||||
* v1.39.4 — the table rules, for the rounds after a call produced a table.
|
||||
* v1.43 (#1217) — they join the conversation as a user-side note after
|
||||
* that round's tool results instead of switching the system prompt, so the
|
||||
* cached prefix (tools plus system) survives the switch. Absent when the
|
||||
* turn already carries them.
|
||||
*/
|
||||
systemOnceTableShown?: string;
|
||||
tableRulesOnceShown?: string;
|
||||
/** The conversation messages (history + the new user turn). */
|
||||
messages: AiMessage[];
|
||||
tools: AiToolDef[];
|
||||
@@ -284,7 +287,12 @@ export async function runCoachToolLoop(args: {
|
||||
const cacheKey = coachPromptCacheKey(turn?.conversationId);
|
||||
|
||||
const messages: AiMessage[] = [...args.messages];
|
||||
let system = args.system;
|
||||
// v1.43 (#1217) — the system prompt never changes inside a turn: what used
|
||||
// to be swapped or appended into it (the table rules, the final-round line)
|
||||
// goes on the user side, so every round reads the same cached prefix.
|
||||
const system = args.system;
|
||||
let tableRulesPending = args.tableRulesOnceShown;
|
||||
let tableShownThisRound = false;
|
||||
let totalTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let rounds = 0;
|
||||
@@ -352,10 +360,15 @@ export async function runCoachToolLoop(args: {
|
||||
surface: "coach",
|
||||
ledger,
|
||||
params: {
|
||||
system: stopReason
|
||||
? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}`
|
||||
: system,
|
||||
messages,
|
||||
system,
|
||||
// The final round says why it is final as the last user-side line,
|
||||
// after everything the earlier rounds cached.
|
||||
messages: stopReason
|
||||
? [
|
||||
...messages,
|
||||
{ role: "user", content: FINAL_ROUND_LINE[stopReason] },
|
||||
]
|
||||
: messages,
|
||||
temperature,
|
||||
maxTokens,
|
||||
signal,
|
||||
@@ -522,9 +535,7 @@ export async function runCoachToolLoop(args: {
|
||||
// prose against that same summary.
|
||||
notify(() => onCallSettled?.(call, toolResult, index, round));
|
||||
const { table, ...forModel } = toolResult;
|
||||
if (table && args.systemOnceTableShown) {
|
||||
system = args.systemOnceTableShown;
|
||||
}
|
||||
if (table) tableShownThisRound = true;
|
||||
const validArgs =
|
||||
call.name === COMPARE_SERIES_TOOL_NAME
|
||||
? compareArgs(call.arguments)
|
||||
@@ -551,6 +562,14 @@ export async function runCoachToolLoop(args: {
|
||||
budget.addInput(textTokens(serialised));
|
||||
messages.push({ role: "tool", toolCallId: call.id, content: serialised });
|
||||
}
|
||||
// The first table of the turn brings the table rules along, once, right
|
||||
// after the results that produced it.
|
||||
if (tableShownThisRound && tableRulesPending) {
|
||||
messages.push({ role: "user", content: tableRulesPending });
|
||||
budget.addInput(textTokens(tableRulesPending));
|
||||
tableRulesPending = undefined;
|
||||
}
|
||||
tableShownThisRound = false;
|
||||
budget.addInput(textTokens(result.content ?? ""));
|
||||
notify(() => onRoundSettled?.(round));
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* #1217 — the turn model sends the same system prompt on every turn. The
|
||||
* per-turn lines (opener hint, name clause, a resolved follow-up) reach the
|
||||
* model on the user side, on the tool path and on the streaming no-tools
|
||||
* path alike, so the provider's cached prefix survives from turn to turn.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
loop: vi.fn(),
|
||||
stream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/memory/contract", () => ({
|
||||
buildMemoryContextBlock: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
admittedPriorResults: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
buildCoachDataInventory: vi.fn(async () => ({
|
||||
entries: [],
|
||||
probeScope: undefined,
|
||||
})),
|
||||
renderDataInventory: () => "DATA INVENTORY",
|
||||
runCoachToolLoop: (args: unknown) => m.loop(args),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
runStreamingRawCompletionWithFallback: (args: unknown) => m.stream(args),
|
||||
}));
|
||||
|
||||
import { runTurnModel } from "../model";
|
||||
import type { TurnContext } from "../context";
|
||||
|
||||
const sha = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
function ctx(turnNotes: string): TurnContext {
|
||||
return {
|
||||
coachPrefs: {},
|
||||
reach: { kind: "unbounded" },
|
||||
aiResponseTimeoutMs: 60_000,
|
||||
effectiveScope: undefined,
|
||||
snapshot: {
|
||||
snapshotJson: "{}",
|
||||
sections: {},
|
||||
provenance: { windows: [], metrics: [], counts: {} },
|
||||
},
|
||||
aboutMe: null,
|
||||
scheduleDoses: [],
|
||||
medicationNames: [],
|
||||
turnContext: {
|
||||
allTurns: [],
|
||||
window: [],
|
||||
transcript: "user: hi",
|
||||
guidedBlock: "",
|
||||
historyElided: false,
|
||||
isFirstTurn: true,
|
||||
includeFullSnapshot: true,
|
||||
},
|
||||
workoutEvidence: null,
|
||||
systemPrompt: "STABLE SYSTEM",
|
||||
userPrompt: "SNAPSHOT AND CONVERSATION",
|
||||
turnNotes,
|
||||
} as unknown as TurnContext;
|
||||
}
|
||||
|
||||
function run(turnNotes: string, turnHints: string[], toolMode: boolean) {
|
||||
return runTurnModel({
|
||||
userId: "u1",
|
||||
locale: "en",
|
||||
signal: new AbortController().signal,
|
||||
conversationId: "c1",
|
||||
ctx: ctx(turnNotes),
|
||||
chain: [] as never,
|
||||
toolMode,
|
||||
ledger: {
|
||||
settleRound: vi.fn(async () => {}),
|
||||
reserveRound: vi.fn(async () => true),
|
||||
close: vi.fn(async () => {}),
|
||||
} as never,
|
||||
emitter: { emit: () => {}, aborted: () => false },
|
||||
turnHints,
|
||||
message: "hi",
|
||||
payer: "user",
|
||||
conversationWindowSet: false,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
m.loop.mockReset().mockRejectedValue(new Error("stop after capture"));
|
||||
m.stream.mockReset().mockRejectedValue(new Error("stop after capture"));
|
||||
});
|
||||
|
||||
describe("runTurnModel keeps the system prompt stable (#1217)", () => {
|
||||
it("on the tool path", async () => {
|
||||
await run("OPENER HINT for this turn: A", [], true);
|
||||
await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], true);
|
||||
const [first, second] = m.loop.mock.calls.map(
|
||||
([args]) =>
|
||||
args as { system: string; messages: Array<{ content: string }> },
|
||||
);
|
||||
expect(sha(first!.system)).toBe(sha(second!.system));
|
||||
expect(first!.system).not.toContain("OPENER HINT");
|
||||
expect(second!.system).not.toContain("FOLLOW-UP");
|
||||
expect(first!.messages[0]!.content).toContain(
|
||||
"OPENER HINT for this turn: A",
|
||||
);
|
||||
expect(second!.messages[0]!.content).toContain("FOLLOW-UP: the table");
|
||||
});
|
||||
|
||||
it("on the streaming no-tools path", async () => {
|
||||
await run("OPENER HINT for this turn: A", [], false);
|
||||
await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], false);
|
||||
const params = m.stream.mock.calls.map(
|
||||
([args]) =>
|
||||
(
|
||||
args as {
|
||||
params: { system: string; messages: Array<{ content: string }> };
|
||||
}
|
||||
).params,
|
||||
);
|
||||
expect(params[0]!.system).toBe("STABLE SYSTEM");
|
||||
expect(params[1]!.system).toBe("STABLE SYSTEM");
|
||||
const users = params.map((p) => JSON.stringify(p.messages));
|
||||
expect(users[0]).toContain("OPENER HINT for this turn: A");
|
||||
expect(users[1]).toContain("FOLLOW-UP: the table");
|
||||
});
|
||||
});
|
||||
@@ -31,3 +31,16 @@ export function buildDialogAddenda(
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — the table rules alone (how a table was fetched, how to
|
||||
* follow up on one). They apply once the conversation holds a table, which can
|
||||
* change mid-turn, so they travel as a user-side note rather than switching the
|
||||
* system prompt: a switched system prompt rewrites the provider's cached
|
||||
* prefix on the costliest round of the turn.
|
||||
*/
|
||||
export function buildTableRulesAddendum(locale: Locale): string {
|
||||
return [methodAddendum(locale), followUpsAddendum(locale)]
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import type { CoachScope } from "@/lib/ai/coach/types";
|
||||
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
|
||||
import { instructionLocale } from "@/lib/ai/prompts/output-language";
|
||||
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
|
||||
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
||||
import {
|
||||
buildCoachTurnNotes,
|
||||
getCoachSystemPrompt,
|
||||
} from "@/lib/ai/coach/system-prompt";
|
||||
import {
|
||||
openerArchetypeHint,
|
||||
shouldUseNameForTurn,
|
||||
@@ -69,6 +72,11 @@ export interface TurnContext {
|
||||
workoutEvidence: Record<string, unknown> | null;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
/**
|
||||
* v1.43 (#1217) — the lines that change every turn (opener hint, name
|
||||
* clause), for the user message. Empty when none apply.
|
||||
*/
|
||||
turnNotes: string;
|
||||
}
|
||||
|
||||
export async function assembleTurnContext(args: {
|
||||
@@ -150,12 +158,11 @@ export async function assembleTurnContext(args: {
|
||||
firstName != null && shouldUseNameForTurn(`${userId}:${turnIndex}`),
|
||||
openerHint: openerArchetypeHint(`${userId}:${turnIndex}`, locale),
|
||||
};
|
||||
const baseSystemPrompt = getCoachSystemPrompt(
|
||||
locale,
|
||||
coachPrefs,
|
||||
aboutMe,
|
||||
coachPersonalization,
|
||||
);
|
||||
// v1.43 (#1217) — the system prompt is stable per person; the per-turn
|
||||
// personalization travels in the user message (`turnNotes`), so the
|
||||
// provider's cached prefix survives from one turn to the next.
|
||||
const baseSystemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
|
||||
const turnNotes = buildCoachTurnNotes(locale, coachPersonalization);
|
||||
const turnContext = buildCoachTurnContext({
|
||||
priorTurns,
|
||||
priorSummary,
|
||||
@@ -240,5 +247,6 @@ export async function assembleTurnContext(args: {
|
||||
workoutEvidence,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
turnNotes,
|
||||
};
|
||||
}
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
type PriorResultTurn,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
|
||||
import { buildDialogAddenda } from "./addenda";
|
||||
import { buildDialogAddenda, buildTableRulesAddendum } from "./addenda";
|
||||
import type { TurnLedger } from "./budget";
|
||||
import type { TurnReasoning } from "./reasoning";
|
||||
import type { TurnChain } from "./chain";
|
||||
@@ -228,7 +228,7 @@ export function buildTurnResults(
|
||||
}
|
||||
}
|
||||
|
||||
/** v1.39.4 — server-authored lines appended to the system prompt. */
|
||||
/** v1.39.4 — server-authored lines appended to a prompt block. */
|
||||
function appendBlocks(base: string, blocks: string[]): string {
|
||||
const extra = blocks.filter((block) => block.length > 0);
|
||||
return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
|
||||
@@ -374,28 +374,35 @@ export async function runTurnModel(args: {
|
||||
reach: ctx.reach,
|
||||
prior: args.priorResults ?? [],
|
||||
});
|
||||
const requestWith = (tableRules: boolean) =>
|
||||
buildCoachToolRequest({
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
|
||||
buildDialogAddenda(locale, { tableRules }),
|
||||
...args.turnHints,
|
||||
]),
|
||||
focusHint: renderFocusHint(effectiveScope?.sources),
|
||||
workoutEvidence,
|
||||
dataInventory: renderDataInventory(inventory),
|
||||
priorResults: renderPriorResultRefs(priorResults),
|
||||
guidedBlock: turnContext.guidedBlock,
|
||||
transcript: turnContext.transcript,
|
||||
languageName: LANGUAGE_NAMES[locale],
|
||||
...(memory ? { memoryBlock: memory.text } : {}),
|
||||
});
|
||||
// The recheck and follow-up rules are about tables: they ride the
|
||||
// prompt once the conversation holds one, or from the round after this
|
||||
// turn produced its first.
|
||||
const withTables = requestWith(true);
|
||||
const toolRequest =
|
||||
priorResults.length > 0 ? withTables : requestWith(false);
|
||||
// The recheck and follow-up rules are about tables: they ride the turn
|
||||
// once the conversation holds one, or from the round after this turn
|
||||
// produced its first. v1.43 (#1217) — like every other line that can
|
||||
// change from one turn or round to the next (the opener hint, the name
|
||||
// clause, a resolved follow-up), they travel on the user side, so the
|
||||
// system prompt and the tools stay one cached prefix for the whole
|
||||
// conversation.
|
||||
const tableRules = buildTableRulesAddendum(locale);
|
||||
const toolRequest = buildCoachToolRequest({
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
|
||||
buildDialogAddenda(locale, { tableRules: false }),
|
||||
]),
|
||||
focusHint: renderFocusHint(effectiveScope?.sources),
|
||||
workoutEvidence,
|
||||
dataInventory: renderDataInventory(inventory),
|
||||
priorResults: renderPriorResultRefs(priorResults),
|
||||
guidedBlock: turnContext.guidedBlock,
|
||||
transcript: turnContext.transcript,
|
||||
languageName: LANGUAGE_NAMES[locale],
|
||||
turnNotes: [
|
||||
...(priorResults.length > 0 ? [tableRules] : []),
|
||||
ctx.turnNotes,
|
||||
...args.turnHints,
|
||||
]
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n\n"),
|
||||
...(memory ? { memoryBlock: memory.text } : {}),
|
||||
});
|
||||
// v1.39.4 — every call this turn settled, by its turn-wide index, for
|
||||
// the result tables. Only this turn's own calls ever reach them.
|
||||
const settled: SettledToolCall[] = [];
|
||||
@@ -420,7 +427,7 @@ export async function runTurnModel(args: {
|
||||
userId,
|
||||
providers: chain,
|
||||
system: toolRequest.system,
|
||||
systemOnceTableShown: withTables.system,
|
||||
...(priorResults.length > 0 ? {} : { tableRulesOnceShown: tableRules }),
|
||||
messages: toolRequest.messages,
|
||||
tools: COACH_TURN_TOOL_DEFS,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
@@ -631,10 +638,18 @@ export async function runTurnModel(args: {
|
||||
});
|
||||
const summaries: string[] = [];
|
||||
// v1.41 — the memory block rides the user turn here too, ahead of the
|
||||
// snapshot, fenced like the rest of the person's own context.
|
||||
const user = memory
|
||||
? `${memory.text}\n\n${ctx.userPrompt}`
|
||||
: ctx.userPrompt;
|
||||
// snapshot, fenced like the rest of the person's own context. v1.43
|
||||
// (#1217) — so do this turn's own lines (opener hint, name clause, a
|
||||
// resolved follow-up), which used to ride the system prompt and rewrote
|
||||
// its cached prefix on every turn.
|
||||
const user = [
|
||||
memory?.text,
|
||||
ctx.turnNotes,
|
||||
...args.turnHints,
|
||||
ctx.userPrompt,
|
||||
]
|
||||
.filter((block): block is string => Boolean(block))
|
||||
.join("\n\n");
|
||||
const fallback = await runStreamingRawCompletionWithFallback({
|
||||
surface: "coach",
|
||||
userId,
|
||||
@@ -644,7 +659,7 @@ export async function runTurnModel(args: {
|
||||
},
|
||||
params: {
|
||||
...singleUserTurn({
|
||||
system: appendBlocks(ctx.systemPrompt, args.turnHints),
|
||||
system: ctx.systemPrompt,
|
||||
user,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
maxTokens: AI_BUDGETS.coach.maxTokens,
|
||||
|
||||
@@ -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,311 @@
|
||||
/**
|
||||
* v1.43 (#1217) — AI usage in three levels.
|
||||
*
|
||||
* One setting per account instead of many knobs. The operator sets the highest
|
||||
* level an account may choose (`maxLevel`) and the level an account without a
|
||||
* choice runs at (`defaultLevel`), since the operator pays for the shared key;
|
||||
* each person picks within that. A level decides how much background model
|
||||
* work the account asks for. It never decides what data is stored or shown,
|
||||
* and the interactive Coach is the same at every level (its own budget caps
|
||||
* apply as before).
|
||||
*
|
||||
* This file is the vocabulary: the levels, what each one does, the resolver
|
||||
* and the estimate. Pure, no I/O, so the account payload, the jobs, the
|
||||
* routes, the admin readout and the tests all read the same table. The reads
|
||||
* live in `./capabilities/usage.ts`.
|
||||
*
|
||||
* Wire spelling is lower case (`frugal` / `standard` / `full`); the database
|
||||
* enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling
|
||||
* order: a level is allowed when it is at or below `maxLevel`.
|
||||
*/
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
type AiUsageLevelWire,
|
||||
} from "@/lib/validations/ai-usage";
|
||||
|
||||
export { AI_USAGE_LEVELS };
|
||||
export type AiUsageLevel = AiUsageLevelWire;
|
||||
|
||||
/**
|
||||
* Where the effective level comes from:
|
||||
*
|
||||
* - `user`: the account's own stored choice;
|
||||
* - `default`: no stored choice, so the operator's default;
|
||||
* - `admin_cap`: a stored choice above the operator's ceiling, capped at
|
||||
* read time. The choice itself is kept, so raising the ceiling again
|
||||
* restores it.
|
||||
*/
|
||||
export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const;
|
||||
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
|
||||
|
||||
/** What one level does. Every effect a level has is a row here. */
|
||||
export interface AiUsageLevelEffect {
|
||||
/**
|
||||
* Minimum hours between two status-card regenerations of one scope after a
|
||||
* sync (a watch, an integration). `"instance"` reads the operator's sync
|
||||
* gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`,
|
||||
* else six).
|
||||
*/
|
||||
syncRegenGapHours: number | "instance";
|
||||
/** The same gap after a hand entry. */
|
||||
manualRegenGapHours: number;
|
||||
/**
|
||||
* The status snapshot's char cap, measured on the compact JSON the prompt
|
||||
* ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every
|
||||
* snapshot that fitted the old 24 000 pretty-printed characters.
|
||||
*/
|
||||
statusSnapshotChars: number;
|
||||
/**
|
||||
* Whether the background jobs that may reason (the briefing, the period
|
||||
* narratives, the Coach memory) ask for it. When true the job's own level
|
||||
* applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator.
|
||||
*/
|
||||
backgroundReasoning: boolean;
|
||||
/**
|
||||
* The daily briefing's form. `short`: the lead plus today, at most one
|
||||
* recommendation; every field of the contract stays present, so a client
|
||||
* decodes it unchanged.
|
||||
*/
|
||||
briefing: "full" | "short";
|
||||
/**
|
||||
* Whether the account's own "read documents automatically" setting may
|
||||
* act. When false, documents are read on request only.
|
||||
*/
|
||||
documentsAutoRead: boolean;
|
||||
/**
|
||||
* Relative token use, `full` = 1. Scales the measured use of the effective
|
||||
* level into an estimate for another level. Calibrated with the generation
|
||||
* simulation of a watch-synced account
|
||||
* (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card
|
||||
* generations a day, the nightly warm included, are 141 on `full`, 44 on
|
||||
* `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing
|
||||
* and switched-off reasoning). The Coach is part of the measured use and
|
||||
* does not scale, so the result is published and shown as an estimate.
|
||||
*/
|
||||
tokenFactor: number;
|
||||
}
|
||||
|
||||
export const AI_USAGE_LEVEL_EFFECTS: Readonly<
|
||||
Record<AiUsageLevel, Readonly<AiUsageLevelEffect>>
|
||||
> = Object.freeze({
|
||||
frugal: {
|
||||
syncRegenGapHours: 24,
|
||||
manualRegenGapHours: 6,
|
||||
statusSnapshotChars: 10_000,
|
||||
backgroundReasoning: false,
|
||||
briefing: "short",
|
||||
documentsAutoRead: false,
|
||||
tokenFactor: 0.15,
|
||||
},
|
||||
standard: {
|
||||
syncRegenGapHours: "instance",
|
||||
manualRegenGapHours: 1,
|
||||
statusSnapshotChars: 14_000,
|
||||
backgroundReasoning: true,
|
||||
briefing: "full",
|
||||
documentsAutoRead: true,
|
||||
tokenFactor: 0.3,
|
||||
},
|
||||
full: {
|
||||
syncRegenGapHours: 1,
|
||||
manualRegenGapHours: 1,
|
||||
statusSnapshotChars: 14_000,
|
||||
backgroundReasoning: true,
|
||||
briefing: "full",
|
||||
documentsAutoRead: true,
|
||||
tokenFactor: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/** The instance's own defaults, before an operator changes anything. */
|
||||
export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard";
|
||||
export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard";
|
||||
|
||||
// ── Order ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usageLevelRank(level: AiUsageLevel): number {
|
||||
return AI_USAGE_LEVELS.indexOf(level);
|
||||
}
|
||||
|
||||
/** Whether `level` is at or above `min`. */
|
||||
export function usageLevelAtLeast(
|
||||
level: AiUsageLevel,
|
||||
min: AiUsageLevel,
|
||||
): boolean {
|
||||
return usageLevelRank(level) >= usageLevelRank(min);
|
||||
}
|
||||
|
||||
/** `level`, lowered to `max` when it lies above it. */
|
||||
export function capUsageLevel(
|
||||
level: AiUsageLevel,
|
||||
max: AiUsageLevel,
|
||||
): AiUsageLevel {
|
||||
return usageLevelRank(level) > usageLevelRank(max) ? max : level;
|
||||
}
|
||||
|
||||
export function isAiUsageLevel(value: unknown): value is AiUsageLevel {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(AI_USAGE_LEVELS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Database spelling ───────────────────────────────────────────────────────
|
||||
|
||||
export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL";
|
||||
|
||||
export function usageLevelFromDb(
|
||||
value: string | null | undefined,
|
||||
): AiUsageLevel | null {
|
||||
if (value == null) return null;
|
||||
const lower = value.toLowerCase();
|
||||
return isAiUsageLevel(lower) ? lower : null;
|
||||
}
|
||||
|
||||
export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb {
|
||||
return level.toUpperCase() as AiUsageLevelDb;
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiUsageBounds {
|
||||
/** The account's stored choice; `null` follows the default. */
|
||||
preference: AiUsageLevel | null;
|
||||
defaultLevel: AiUsageLevel;
|
||||
maxLevel: AiUsageLevel;
|
||||
}
|
||||
|
||||
export interface AiUsageResolution {
|
||||
level: AiUsageLevel;
|
||||
source: AiUsageSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level in effect. A stored choice at or below the ceiling wins; above it
|
||||
* the ceiling applies and the choice is kept (`admin_cap`). Without a choice
|
||||
* the default applies, held to the ceiling too, so a default stored above
|
||||
* the ceiling (the admin route refuses one, an old row might hold one) can
|
||||
* never lift an account over it.
|
||||
*/
|
||||
export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution {
|
||||
const { preference, defaultLevel, maxLevel } = bounds;
|
||||
if (preference === null) {
|
||||
return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" };
|
||||
}
|
||||
if (usageLevelRank(preference) > usageLevelRank(maxLevel)) {
|
||||
return { level: maxLevel, source: "admin_cap" };
|
||||
}
|
||||
return { level: preference, source: "user" };
|
||||
}
|
||||
|
||||
// ── Effects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */
|
||||
export type IngestSource = "manual" | "sync";
|
||||
|
||||
/** Default sync gap when neither the operator nor the environment sets one. */
|
||||
export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
|
||||
export const SYNC_REGEN_GAP_HOURS_MIN = 1;
|
||||
export const SYNC_REGEN_GAP_HOURS_MAX = 48;
|
||||
|
||||
/** A sync gap in whole hours, or `null` when the value is not a usable one. */
|
||||
export function parseSyncRegenGapHours(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hours a scope waits after its last regeneration before an ingest of this
|
||||
* source may regenerate it again.
|
||||
*
|
||||
* @param instanceSyncGapHours The operator's resolved sync gap, read by
|
||||
* `standard`.
|
||||
*/
|
||||
export function statusRegenGapHours(
|
||||
level: AiUsageLevel,
|
||||
source: IngestSource,
|
||||
instanceSyncGapHours: number,
|
||||
): number {
|
||||
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
||||
if (source === "manual") return effect.manualRegenGapHours;
|
||||
return effect.syncRegenGapHours === "instance"
|
||||
? instanceSyncGapHours
|
||||
: effect.syncRegenGapHours;
|
||||
}
|
||||
|
||||
// ── Estimate ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Full days behind the measured mean, at most. */
|
||||
export const AI_USAGE_MEASURE_DAYS = 7;
|
||||
/** Days of history an estimate needs. */
|
||||
export const AI_USAGE_ESTIMATE_MIN_DAYS = 3;
|
||||
|
||||
/**
|
||||
* Tokens per day at `target`, scaled from what the account used at `from`.
|
||||
* `null` without enough history.
|
||||
*/
|
||||
export function estimateTokensPerDay(args: {
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
from: AiUsageLevel;
|
||||
target: AiUsageLevel;
|
||||
}): number | null {
|
||||
if (args.measuredTokensPerDay === null) return null;
|
||||
if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null;
|
||||
const ratio =
|
||||
AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor /
|
||||
AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor;
|
||||
return Math.round(args.measuredTokensPerDay * ratio);
|
||||
}
|
||||
|
||||
/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */
|
||||
export interface AiUsageState {
|
||||
level: AiUsageLevel;
|
||||
preference: AiUsageLevel | null;
|
||||
defaultLevel: AiUsageLevel;
|
||||
maxLevel: AiUsageLevel;
|
||||
source: AiUsageSource;
|
||||
options: Array<{
|
||||
level: AiUsageLevel;
|
||||
allowed: boolean;
|
||||
estimatedTokensPerDay: number | null;
|
||||
}>;
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
}
|
||||
|
||||
/** The whole published state, from the bounds and the measured use. */
|
||||
export function buildAiUsageState(
|
||||
args: AiUsageBounds & {
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
},
|
||||
): AiUsageState {
|
||||
const { level, source } = resolveAiUsageLevel(args);
|
||||
return {
|
||||
level,
|
||||
preference: args.preference,
|
||||
defaultLevel: args.defaultLevel,
|
||||
maxLevel: args.maxLevel,
|
||||
source,
|
||||
options: AI_USAGE_LEVELS.map((option) => ({
|
||||
level: option,
|
||||
allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel),
|
||||
estimatedTokensPerDay: estimateTokensPerDay({
|
||||
measuredTokensPerDay: args.measuredTokensPerDay,
|
||||
measuredDays: args.measuredDays,
|
||||
from: level,
|
||||
target: option,
|
||||
}),
|
||||
})),
|
||||
measuredTokensPerDay: args.measuredTokensPerDay,
|
||||
measuredDays: args.measuredDays,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,9 @@ const fakeDb = vi.hoisted(() => {
|
||||
encounter: { findMany: none },
|
||||
// The AI capability loader's consent read: no receipts.
|
||||
consentReceipt: { findMany: none },
|
||||
// v1.43 — the loader's usage-level read: no stored choice, so the
|
||||
// instance default applies.
|
||||
user: { findUnique: async () => ({ aiUsageLevel: null }) },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -900,6 +900,7 @@ export async function upsertFitbitMeasurements(
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
{ source: "sync" },
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -129,7 +129,9 @@ export async function syncUserFitbit(
|
||||
// the aggregator's `< to` upper bound covers it.
|
||||
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
|
||||
await recomputeUserRollups(userId, { types, from, to });
|
||||
invalidateStatusInsightsForTypes(userId, types).catch((err) => {
|
||||
invalidateStatusInsightsForTypes(userId, types, {
|
||||
source: "sync",
|
||||
}).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
|
||||
@@ -1606,6 +1606,7 @@ export async function upsertGoogleHealthMeasurements(
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
{ source: "sync" },
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`google-health: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -370,7 +370,9 @@ async function runGoogleHealthSyncCycle(
|
||||
const from = new Date(sorted[0]!);
|
||||
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
|
||||
await recomputeUserRollups(userId, { types, from, to });
|
||||
invalidateStatusInsightsForTypes(userId, types).catch((err) => {
|
||||
invalidateStatusInsightsForTypes(userId, types, {
|
||||
source: "sync",
|
||||
}).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`google-health: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* #1217 — the status snapshots travel as compact JSON, and the char cap was
|
||||
* lowered with the switch so the model sees at least the same data for fewer
|
||||
* tokens, never less.
|
||||
*
|
||||
* Two properties pin that:
|
||||
* 1. compact JSON parses back to exactly the object the pretty form did, so
|
||||
* dropping the indentation changes no content;
|
||||
* 2. every snapshot (and every shed stage of one) that fitted the old cap,
|
||||
* 24 000 pretty-printed characters, fits the new compact cap, so the
|
||||
* shedding never starts earlier than it used to.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_SNAPSHOT_CHARS,
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
|
||||
/** The cap before v1.43, measured on `JSON.stringify(snapshot, null, 2)`. */
|
||||
const PRETTY_CAP_BEFORE = 24_000;
|
||||
|
||||
const NOW = new Date("2026-10-15T12:00:00Z");
|
||||
|
||||
function series(days: number, perDay: number, base: number, seed: number) {
|
||||
const points: Array<{ measuredAt: Date; value: number }> = [];
|
||||
let x = seed;
|
||||
for (let d = 0; d < days; d++) {
|
||||
for (let k = 0; k < perDay; k++) {
|
||||
x = (x * 9301 + 49297) % 233280;
|
||||
points.push({
|
||||
measuredAt: new Date(NOW.getTime() - d * 86_400_000 - k * 3_600_000),
|
||||
value: Math.round((base + (x / 233280) * base * 0.2) * 10) / 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
return buildGradedSeriesFromPoints(points, NOW, "Europe/Berlin");
|
||||
}
|
||||
|
||||
/** A general-status-shaped snapshot over `metrics` series of `days` days. */
|
||||
function snapshot(metrics: number, days: number): Record<string, unknown> {
|
||||
const measurementSeries: Record<string, unknown> = {};
|
||||
for (let i = 0; i < metrics; i++) {
|
||||
measurementSeries[`METRIC_${i}`] = {
|
||||
unit: i % 2 === 0 ? "bpm" : "kg",
|
||||
summary: { points: days * 2, mean: 61.2, min: 50, max: 80, latest: 63 },
|
||||
series: series(days, 2, 50 + i * 10, i + 1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
locale: "de",
|
||||
generatedForDay: "2026-10-15",
|
||||
interpretationHint:
|
||||
"Use trend direction and deltas. Prioritize the newest data if trends conflict.",
|
||||
dataCoverage: {
|
||||
totalMeasurements: days * 2 * metrics,
|
||||
totalSpanDays: days,
|
||||
newestMeasurementDaysAgo: 0,
|
||||
},
|
||||
measurementSeries,
|
||||
correlations: Array.from({ length: 40 }, (_, i) => ({
|
||||
a: `METRIC_${i % metrics}`,
|
||||
b: `METRIC_${(i + 1) % metrics}`,
|
||||
r: Math.round(((i % 7) / 10 - 0.3) * 100) / 100,
|
||||
n: 30 + i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURES: Array<[number, number]> = [];
|
||||
for (const metrics of [1, 2, 3, 4, 6, 8, 10, 16]) {
|
||||
for (const days of [14, 30, 90, 200, 400, 1000]) {
|
||||
FIXTURES.push([metrics, days]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The shed stages `degradeStatusSnapshotToBudget` walks, in order. */
|
||||
function stages(snap: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [structuredClone(snap)];
|
||||
const drop = (node: unknown, field: string): void => {
|
||||
if (Array.isArray(node)) return node.forEach((n) => drop(n, field));
|
||||
if (node && typeof node === "object") {
|
||||
const obj = node as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === field && Array.isArray(obj[key])) delete obj[key];
|
||||
else drop(obj[key], field);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tails = (node: unknown): void => {
|
||||
if (Array.isArray(node)) return node.forEach(tails);
|
||||
if (node && typeof node === "object") {
|
||||
const obj = node as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (Array.isArray(value) && key !== "recent" && value.length > 30) {
|
||||
obj[key] = value.slice(-30);
|
||||
}
|
||||
tails(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
let current = structuredClone(snap);
|
||||
drop(current, "yearly");
|
||||
out.push(structuredClone(current));
|
||||
drop(current, "weekly");
|
||||
out.push(structuredClone(current));
|
||||
tails(current);
|
||||
out.push(structuredClone(current));
|
||||
current = structuredClone(current);
|
||||
drop(current, "recent");
|
||||
out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("compact status snapshots (#1217)", () => {
|
||||
it.each(FIXTURES)(
|
||||
"parses back to the same object (%i metrics, %i days)",
|
||||
(metrics, days) => {
|
||||
const snap = snapshot(metrics, days);
|
||||
const compact = JSON.stringify(snap);
|
||||
expect(JSON.parse(compact)).toEqual(
|
||||
JSON.parse(JSON.stringify(snap, null, 2)),
|
||||
);
|
||||
expect(compact.length).toBeLessThan(JSON.stringify(snap, null, 2).length);
|
||||
},
|
||||
);
|
||||
|
||||
it("fits every stage that fitted the old pretty cap under the new compact cap", () => {
|
||||
let checked = 0;
|
||||
for (const [metrics, days] of FIXTURES) {
|
||||
for (const stage of stages(snapshot(metrics, days))) {
|
||||
if (JSON.stringify(stage, null, 2).length > PRETTY_CAP_BEFORE) continue;
|
||||
checked += 1;
|
||||
expect(JSON.stringify(stage).length).toBeLessThanOrEqual(
|
||||
MAX_SNAPSHOT_CHARS,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The property is only worth something if it saw real cases near the cap.
|
||||
expect(checked).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("sheds nothing from a snapshot that fitted before", () => {
|
||||
let fitted = 0;
|
||||
for (const [metrics, days] of FIXTURES) {
|
||||
const snap = snapshot(metrics, days);
|
||||
if (JSON.stringify(snap, null, 2).length > PRETTY_CAP_BEFORE) continue;
|
||||
fitted += 1;
|
||||
const before = structuredClone(snap);
|
||||
expect(degradeStatusSnapshotToBudget(snap)).toEqual([]);
|
||||
expect(snap).toEqual(before);
|
||||
}
|
||||
expect(fitted).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("still sheds a snapshot far over the cap, coarsest-last", () => {
|
||||
const snap = snapshot(16, 1000);
|
||||
const size = JSON.stringify(snap).length;
|
||||
const shed = degradeStatusSnapshotToBudget(snap);
|
||||
expect(shed[0]).toBe("yearly");
|
||||
expect(JSON.stringify(snap).length).toBeLessThan(size);
|
||||
});
|
||||
});
|
||||
@@ -350,7 +350,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
|
||||
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
|
||||
|
||||
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
|
||||
expect(prompt).toContain('\\"unit\\": \\"lb\\"');
|
||||
expect(prompt).toContain('\\"unit\\":\\"lb\\"');
|
||||
expect(prompt).toContain("179.5");
|
||||
expect(prompt).not.toContain("81.4");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const noteFindMany = vi.fn();
|
||||
const aiCapabilityForRecord = vi.fn();
|
||||
const enqueueStatusGeneration = vi.fn();
|
||||
const userFindUnique = vi.fn();
|
||||
const appSettingsFindUnique = vi.fn();
|
||||
const measurementFindMany = vi.fn();
|
||||
// v1.42 — type discovery goes through the loose-index-scan helper; the
|
||||
// rows it answers with are the types themselves.
|
||||
@@ -40,6 +41,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: (...a: unknown[]) => noteFindMany(...a),
|
||||
},
|
||||
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
|
||||
appSettings: {
|
||||
findUnique: (...a: unknown[]) => appSettingsFindUnique(...a),
|
||||
},
|
||||
measurement: {
|
||||
findMany: (...a: unknown[]) => measurementFindMany(...a),
|
||||
},
|
||||
@@ -85,10 +89,19 @@ beforeEach(() => {
|
||||
});
|
||||
enqueueStatusGeneration.mockResolvedValue(undefined);
|
||||
userFindUnique.mockResolvedValue({ locale: "de" });
|
||||
appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: null });
|
||||
measurementFindMany.mockResolvedValue([]);
|
||||
measurementGroupBy.mockResolvedValue([]);
|
||||
delete process.env.AI_SYNC_REGEN_GAP_HOURS;
|
||||
});
|
||||
|
||||
/** How far in the past the freshness probe's cutoff sat, in hours. */
|
||||
function probedGapHours(before: number): number {
|
||||
const where = noteFindMany.mock.calls[0][0].where;
|
||||
const cutoff = (where.generatedAt.gte as Date).getTime();
|
||||
return Math.round((before - cutoff) / (60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/** Distinct scopes the invalidator enqueued a regenerate for. */
|
||||
function enqueuedScopes(): string[] {
|
||||
return [
|
||||
@@ -102,7 +115,9 @@ function enqueuedScopes(): string[] {
|
||||
|
||||
describe("invalidateStatusInsightsForTypes", () => {
|
||||
it("maps WEIGHT to weight + bmi + general", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
expect(
|
||||
enqueueStatusGeneration.mock.calls.every(
|
||||
@@ -112,7 +127,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
});
|
||||
|
||||
it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
// The rows stay for stale-while-revalidate AND for the content-hash
|
||||
// gate the worker's forced regeneration runs; deleting them here would
|
||||
// force a full LLM regeneration even for a re-synced unchanged batch.
|
||||
@@ -120,18 +137,20 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
});
|
||||
|
||||
it("maps both blood-pressure components to blood-pressure + general", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
]);
|
||||
await invalidateStatusInsightsForTypes(
|
||||
"u1",
|
||||
["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
{ source: "manual" },
|
||||
);
|
||||
expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]);
|
||||
});
|
||||
|
||||
it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
]);
|
||||
await invalidateStatusInsightsForTypes(
|
||||
"u1",
|
||||
["PULSE", "RESTING_HEART_RATE"],
|
||||
{ source: "manual" },
|
||||
);
|
||||
// PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE
|
||||
// feeds those too AND carries its own generic assessment card
|
||||
// (`metric:RESTING_HEART_RATE`), so all three are dirtied.
|
||||
@@ -146,16 +165,18 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a
|
||||
// generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the
|
||||
// general overview AND that generic scope.
|
||||
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
|
||||
});
|
||||
|
||||
it("dedupes scopes across a mixed batch", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
await invalidateStatusInsightsForTypes(
|
||||
"u1",
|
||||
["WEIGHT", "PULSE", "BLOOD_GLUCOSE"],
|
||||
{ source: "manual" },
|
||||
);
|
||||
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪
|
||||
// general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE)
|
||||
expect(enqueuedScopes()).toEqual([
|
||||
@@ -168,14 +189,16 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
});
|
||||
|
||||
it("is a no-op for an empty type set (no DB call)", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", []);
|
||||
await invalidateStatusInsightsForTypes("u1", [], { source: "manual" });
|
||||
expect(noteFindMany).not.toHaveBeenCalled();
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues nothing while statusText is unavailable for the record", async () => {
|
||||
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText");
|
||||
expect(noteFindMany).not.toHaveBeenCalled();
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
@@ -183,7 +206,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
|
||||
it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => {
|
||||
userFindUnique.mockResolvedValue({ locale: "de" });
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
// weight + bmi + general, each warmed once for the user's resolved locale.
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3);
|
||||
@@ -197,7 +222,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
|
||||
it("resolves the user's locale (en) for the regenerate, never the unused one", async () => {
|
||||
userFindUnique.mockResolvedValue({ locale: "en" });
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
const locales = new Set(
|
||||
enqueueStatusGeneration.mock.calls.map(
|
||||
(c) => (c[0] as { locale: string }).locale,
|
||||
@@ -210,7 +237,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// A steps sample touches only the general overview, never the weight /
|
||||
// pulse / bp scopes — the constant Apple-Health sync must not thrash
|
||||
// every card's cache.
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]);
|
||||
});
|
||||
|
||||
@@ -219,14 +248,18 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the
|
||||
// freshly-synced metric's card refreshes in the background instead of
|
||||
// lagging until the nightly warm pass.
|
||||
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
|
||||
});
|
||||
|
||||
it("maps a remapped MeasurementType to its registry metric id", async () => {
|
||||
// ACTIVE_ENERGY_BURNED is stored under that DB type but the generic
|
||||
// metric id (route param + cache scope) is ACTIVE_ENERGY.
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]);
|
||||
});
|
||||
|
||||
@@ -234,7 +267,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// A weight sample feeds weight + bmi + general; WEIGHT is one of the
|
||||
// seven specialised metrics and has no generic registry entry, so no
|
||||
// `metric:` scope is enqueued.
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
expect(enqueuedScopes().some((s) => s.startsWith("metric:"))).toBe(false);
|
||||
});
|
||||
@@ -249,7 +284,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// `general` was regenerated within the gap; a fresh WEIGHT sample
|
||||
// must not re-enqueue it. weight + bmi are still stale, so they refresh.
|
||||
noteFindMany.mockResolvedValue([freshRow("general")]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "weight"]);
|
||||
});
|
||||
|
||||
@@ -261,7 +298,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// this is what lets same-day data be narrated same-day without
|
||||
// reopening the per-batch regeneration storm.
|
||||
noteFindMany.mockResolvedValue([]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
});
|
||||
|
||||
@@ -271,7 +310,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
freshRow("bmi"),
|
||||
freshRow("general"),
|
||||
]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -280,7 +321,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// scope that recently stalled must retry rather than be debounced into
|
||||
// staying cold. The probe asks the database for noted rows only; with
|
||||
// none returned, every dirtied scope refreshes.
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
const where = noteFindMany.mock.calls[0][0].where;
|
||||
expect(where.textEncrypted).toEqual({ not: null });
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
@@ -289,7 +332,9 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => {
|
||||
userFindUnique.mockResolvedValue({ locale: "en" });
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
const where = noteFindMany.mock.calls[0][0].where;
|
||||
expect(where.userId).toBe("u1");
|
||||
// Only the en notes for the dirtied scopes are probed.
|
||||
@@ -387,3 +432,127 @@ describe("enqueueStatusRefillForUser", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// #1217 — the gap is chosen by where the batch came from. A watch drips
|
||||
// syncs all day and every drip moves today's partial bucket, so the content
|
||||
// hash cannot hold them back; a hand entry still has to be narrated the
|
||||
// same day.
|
||||
describe("regeneration gap by ingest source", () => {
|
||||
it("holds a sync to six hours by default", async () => {
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "sync",
|
||||
});
|
||||
expect(probedGapHours(before)).toBe(6);
|
||||
});
|
||||
|
||||
it("keeps a hand entry at one hour", async () => {
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
|
||||
source: "manual",
|
||||
});
|
||||
expect(probedGapHours(before)).toBe(1);
|
||||
});
|
||||
|
||||
it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => {
|
||||
process.env.AI_SYNC_REGEN_GAP_HOURS = "12";
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "sync",
|
||||
});
|
||||
expect(probedGapHours(before)).toBe(12);
|
||||
});
|
||||
|
||||
it("prefers the operator's stored gap over the environment", async () => {
|
||||
process.env.AI_SYNC_REGEN_GAP_HOURS = "12";
|
||||
appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: 3 });
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "sync",
|
||||
});
|
||||
expect(probedGapHours(before)).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores an out-of-range environment value", async () => {
|
||||
process.env.AI_SYNC_REGEN_GAP_HOURS = "0";
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "sync",
|
||||
});
|
||||
expect(probedGapHours(before)).toBe(6);
|
||||
});
|
||||
|
||||
it("falls back to the environment when the settings row cannot be read", async () => {
|
||||
process.env.AI_SYNC_REGEN_GAP_HOURS = "24";
|
||||
appSettingsFindUnique.mockRejectedValue(new Error("db down"));
|
||||
const before = Date.now();
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
|
||||
source: "sync",
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -131,12 +131,17 @@ describe("upsertScoreRow", () => {
|
||||
now: new Date("2026-06-02T03:00:00Z"),
|
||||
});
|
||||
|
||||
expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [
|
||||
{
|
||||
type: "STRAIN_SCORE",
|
||||
measuredAt: new Date("2026-06-01T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
expect(afterMeasurementMutation).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
[
|
||||
{
|
||||
type: "STRAIN_SCORE",
|
||||
measuredAt: new Date("2026-06-01T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
// A computed score is not a hand entry: it waits the sync gap.
|
||||
{ source: "sync", label: "score" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -315,7 +315,7 @@ export async function generateBiomarkerStatus(args: {
|
||||
value: r.value,
|
||||
})),
|
||||
};
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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,8 +642,9 @@ export async function prepareBloodPressureStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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,8 +335,9 @@ export async function prepareBmiStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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";
|
||||
@@ -965,7 +970,9 @@ export async function generateComprehensiveInsight(
|
||||
const compactFeatures = compactSections(
|
||||
features as unknown as Record<string, unknown>,
|
||||
);
|
||||
const featuresJson = JSON.stringify(compactFeatures, null, 2);
|
||||
// Compact on the wire: indentation is about 40% of a pretty-printed
|
||||
// payload, and the model reads the structure without it (#1217).
|
||||
const featuresJson = JSON.stringify(compactFeatures);
|
||||
|
||||
// v1.16.8 — content-hash gate. When the compacted feature snapshot is
|
||||
// byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts)
|
||||
@@ -1006,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.
|
||||
@@ -1074,6 +1086,7 @@ export async function generateComprehensiveInsight(
|
||||
locale,
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
userPrompt += buildBriefingShortFormBlock(locale, briefingForm);
|
||||
const systemPrompt = buildSystemPromptWithReferences(
|
||||
locale,
|
||||
referenceMetrics,
|
||||
@@ -1587,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.
|
||||
|
||||
@@ -133,11 +133,12 @@ function scoreUserPrompt(
|
||||
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
|
||||
tz: string,
|
||||
): string {
|
||||
const snapshot = JSON.stringify(
|
||||
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const snapshot = JSON.stringify({
|
||||
promptVersion: PROMPT_VERSION,
|
||||
generatedForDay: todayKey,
|
||||
band,
|
||||
signal,
|
||||
});
|
||||
if (instructionLocale(locale) === "en") {
|
||||
return `Date: ${todayKey} (${tz})
|
||||
OPENER HINT: ${openerHint}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
|
||||
import {
|
||||
normalizeLocale,
|
||||
finalizeStatusSummary,
|
||||
@@ -418,8 +419,9 @@ export async function prepareGeneralStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -682,17 +682,25 @@ export function convertGradedSeries(
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembled-snapshot soft char cap, mirroring the Coach's
|
||||
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
|
||||
* pretty-printed form). The status snapshots had no cap at all; a
|
||||
* many-metric `general-status` could balloon past 100 K tokens.
|
||||
* Assembled-snapshot soft char cap, measured against the COMPACT JSON the
|
||||
* prompt ships (`JSON.stringify(snapshot)`). The status snapshots had no cap
|
||||
* at all once; a many-metric `general-status` could balloon past 100 K
|
||||
* tokens.
|
||||
*
|
||||
* Until v1.43 the cap was 24 000 characters of pretty-printed JSON. The
|
||||
* prompt is compact now, and a graded series loses about 58% of its
|
||||
* characters to indentation (compact is ~0.42 of pretty across one to sixteen
|
||||
* metrics and thirty to a thousand days), so 24 000 pretty characters are
|
||||
* ~10 100 compact ones. 14 000 keeps every snapshot that fitted before
|
||||
* fitting now, with room for the string-heavier blocks whose ratio is higher:
|
||||
* the model sees at least the same data for fewer tokens, never less.
|
||||
*/
|
||||
export const MAX_SNAPSHOT_CHARS = 24_000;
|
||||
export const MAX_SNAPSHOT_CHARS = 14_000;
|
||||
|
||||
/**
|
||||
* Walk an arbitrary snapshot object and, while it exceeds
|
||||
* `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed 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
|
||||
@@ -706,11 +714,21 @@ export const MAX_SNAPSHOT_CHARS = 24_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, null, 2).length;
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
|
||||
const size = () => JSON.stringify(snapshot).length;
|
||||
if (size() <= maxChars) return shed;
|
||||
|
||||
const walk = (
|
||||
node: unknown,
|
||||
@@ -738,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;
|
||||
}
|
||||
@@ -761,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,8 +406,9 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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,8 +454,9 @@ 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, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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,8 +665,9 @@ export async function prepareMoodStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -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,8 +392,9 @@ export async function preparePulseStatusForUser(
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
|
||||
@@ -105,9 +105,11 @@ export async function upsertScoreRow(
|
||||
// it through the shared post-mutation tail so the (type, day) rollup
|
||||
// bucket and the cached status assessment converge on the same tick that
|
||||
// wrote the score, instead of waiting for a discovery pass.
|
||||
await afterMeasurementMutation(args.userId, [
|
||||
{ type: args.type, measuredAt },
|
||||
]);
|
||||
await afterMeasurementMutation(
|
||||
args.userId,
|
||||
[{ type: args.type, measuredAt }],
|
||||
{ source: "sync", label: "score" },
|
||||
);
|
||||
}
|
||||
|
||||
/** The tally every score pass returns. */
|
||||
|
||||
@@ -31,42 +31,52 @@ 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";
|
||||
|
||||
/**
|
||||
* 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 this window is left intact on a fresh measurement
|
||||
* ingest — not re-enqueued.
|
||||
* Where a batch of fresh measurements came from, which decides how soon the
|
||||
* status cards it dirties may regenerate.
|
||||
*
|
||||
* A constantly-syncing client (Apple Health drips batches every few
|
||||
* minutes) used to delete + re-enqueue every dirtied scope on every batch,
|
||||
* and because each delete dropped the cache row the per-(user,scope,locale)
|
||||
* enqueue singleton (120 s) could not coalesce across batches that arrive
|
||||
* minutes apart — so STEPS / general / a metric card regenerated several
|
||||
* times an hour and the assessment felt "regenerated on every visit". This
|
||||
* window coalesces the storm: a genuinely stale scope (no fresh assessment)
|
||||
* still refreshes immediately, but a scope refreshed inside the window is
|
||||
* skipped, so a fresh assessment survives the day's sync drip.
|
||||
*
|
||||
* v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but
|
||||
* a 6 h wall meant same-day data was not narrated same-day: the nightly
|
||||
* warm at 04:30 restarted the window, so a notable 08:00 reading never
|
||||
* re-enqueued its scopes and the card showed the pre-reading text until
|
||||
* the next nightly tick. The window is now a ONE-HOUR minimum gap, and
|
||||
* the budget role the 6 h wall carried moves to the worker's content-hash
|
||||
* gate: every ingest that lands past the gap re-enqueues the dirtied
|
||||
* scopes, the worker's forced run re-gathers the snapshot, and the gate
|
||||
* (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a
|
||||
* timestamp refresh with zero provider cost. So this clock only bounds
|
||||
* the SQL-gather frequency — at most one worker run per scope per hour
|
||||
* under a constant sync drip — while provider spend tracks actual data
|
||||
* change, which is what the gate exists to meter. Comparing the hash
|
||||
* inline at invalidation time was rejected: it would run the full
|
||||
* per-scope data gather inside the ingest path, which is exactly the
|
||||
* work the queue exists to keep off that path.
|
||||
* - `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, 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
|
||||
* (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.
|
||||
*/
|
||||
const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000;
|
||||
export type StatusIngestSource = IngestSource;
|
||||
|
||||
/**
|
||||
* 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. 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`.
|
||||
*/
|
||||
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,
|
||||
);
|
||||
return { gapMs: hours * 60 * 60 * 1000, level: usage.level };
|
||||
}
|
||||
|
||||
/**
|
||||
* The seven per-metric assessment scopes. Each generator persists its
|
||||
@@ -130,6 +140,7 @@ function statusScopesForMeasurementType(
|
||||
export async function invalidateStatusInsightsForTypes(
|
||||
userId: string,
|
||||
types: Iterable<MeasurementType>,
|
||||
options: { source: StatusIngestSource },
|
||||
): Promise<void> {
|
||||
// One ordered set of every scope the batch dirties. The seven specialised
|
||||
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
|
||||
@@ -184,14 +195,25 @@ 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 freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
|
||||
const { gapMs, level } = await ingestRegenGapMs(userId, options.source);
|
||||
const freshScopes = await findRecentlyWarmedScopes(
|
||||
userId,
|
||||
locale,
|
||||
scopes,
|
||||
gapMs,
|
||||
);
|
||||
const staleScopes = Array.from(scopes).filter(
|
||||
(scope) => !freshScopes.has(scope),
|
||||
);
|
||||
if (staleScopes.length === 0) {
|
||||
annotate({
|
||||
action: { name: "insights.status.invalidate.debounced" },
|
||||
meta: { skipped: scopes.size, refreshed: 0 },
|
||||
meta: {
|
||||
skipped: scopes.size,
|
||||
refreshed: 0,
|
||||
source: options.source,
|
||||
usage_level: level,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +232,12 @@ export async function invalidateStatusInsightsForTypes(
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.status.invalidate.debounced" },
|
||||
meta: { skipped: freshScopes.size, refreshed: staleScopes.length },
|
||||
meta: {
|
||||
skipped: freshScopes.size,
|
||||
refreshed: staleScopes.length,
|
||||
source: options.source,
|
||||
usage_level: level,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,7 +295,7 @@ export async function enqueueStatusRefillForUser(
|
||||
|
||||
/**
|
||||
* v1.9.0 — return the subset of `scopes` whose cached assessment for
|
||||
* `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a
|
||||
* `locale` was generated within `gapMs` and is a
|
||||
* real (non-stub) assessment. Those scopes are skipped by the ingest
|
||||
* invalidator so a fresh assessment survives the sync drip.
|
||||
*
|
||||
@@ -281,8 +308,9 @@ async function findRecentlyWarmedScopes(
|
||||
userId: string,
|
||||
locale: SupportedLocale,
|
||||
scopes: ReadonlySet<InsightStatusScope>,
|
||||
gapMs: number,
|
||||
): Promise<Set<InsightStatusScope>> {
|
||||
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
|
||||
const cutoff = new Date(Date.now() - gapMs);
|
||||
// Match exactly the cache actions for the candidate scopes in this locale.
|
||||
const candidateActions = Array.from(scopes, (scope) =>
|
||||
statusCacheAction(scope, locale),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user