mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user