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:
Marc-André Bombeck committed 2026-10-15 23:12:51 +02:00
commit fd44a8381b
143 files changed
+4806 -628

No files matched your search

@@ -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);
});
});
+54 -3
View File
@@ -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 }));
});
+4 -1
View File
@@ -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", {
+6 -1
View File
@@ -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),
+16 -1
View File
@@ -94,6 +94,11 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
applyBriefingShortForm,
briefingFormForRecord,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
import { featuresInReaderUnits } from "@/lib/insights/features-units";
import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
@@ -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 } : {}),
}),
},
});
+13 -7
View File
@@ -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 });
},
+6 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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" },
);
}
+11 -7
View File
@@ -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, {
+10 -6
View File
@@ -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