mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(admin): carry the reasoning switch and cap on the assistant flags
PUT /api/admin/settings/assistant-flags accepts aiReasoningEnabled and
aiReasoningMaxEffort (low, medium, high), writes them field by field
with an audit row like the other switches, and both methods echo them
as reasoning: { enabled, maxEffort }.
This commit is contained in:
1 parent
74e68745c1
commit
adb4cd5c40
2 files changed
+109
-11
No files matched your search
@@ -46,6 +46,8 @@ const ALL_ON = {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: "high",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => {
|
||||
expect(body.data.raw.assistantEnabled).toBe(true);
|
||||
expect(body.data.resolved.coach).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes the reasoning controls, defaulted on and uncapped", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" });
|
||||
});
|
||||
|
||||
it("reads a stored cap and an off switch back", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "low",
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
@@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
expect(body.data.resolved.documentAi).toBe(false);
|
||||
});
|
||||
|
||||
it("switches reasoning off and caps it, with an audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "medium",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
|
||||
expect(upsert.update).toEqual({
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "medium",
|
||||
});
|
||||
const { auditLog } = await import("@/lib/auth/audit");
|
||||
expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({
|
||||
details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" },
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({
|
||||
enabled: false,
|
||||
maxEffort: "medium",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["off", "xhigh", "", 3])(
|
||||
"refuses %s as the highest level",
|
||||
async (value) => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(putReq({ aiReasoningMaxEffort: value }));
|
||||
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({}));
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
* so the admin UI can render the master vs sub-flag distinction
|
||||
* visually.
|
||||
*
|
||||
* v1.41 — the same route carries the operator's two reasoning controls:
|
||||
* `aiReasoningEnabled` (off wins over every person's Coach setting and every
|
||||
* background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the
|
||||
* highest level any call may take). Echoed as `reasoning: { enabled,
|
||||
* maxEffort }`. They sit beside the assistant switches rather than in them:
|
||||
* switching reasoning off stops no AI capability, it only stops the model
|
||||
* thinking longer before it answers.
|
||||
*
|
||||
* `requireAdmin()` gates the route — non-admins get 403.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
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";
|
||||
|
||||
const assistantFlagsSchema = z
|
||||
.object({
|
||||
@@ -53,6 +63,8 @@ const assistantFlagsSchema = z
|
||||
assistantBriefingEnabled: z.boolean().optional(),
|
||||
assistantInsightStatusEnabled: z.boolean().optional(),
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
aiReasoningEnabled: z.boolean().optional(),
|
||||
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -62,8 +74,20 @@ type AssistantFlagsRow = {
|
||||
assistantBriefingEnabled: boolean;
|
||||
assistantInsightStatusEnabled: boolean;
|
||||
assistantDocumentAiEnabled: boolean;
|
||||
aiReasoningEnabled: boolean;
|
||||
aiReasoningMaxEffort: string;
|
||||
};
|
||||
|
||||
const ROW_SELECT = {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: true,
|
||||
} as const;
|
||||
|
||||
function buildResponseShape(row: AssistantFlagsRow) {
|
||||
const resolved = resolveAssistantFlags({
|
||||
enabled: row.assistantEnabled,
|
||||
@@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
assistantDocumentAiEnabled: row.assistantDocumentAiEnabled,
|
||||
},
|
||||
resolved,
|
||||
reasoning: {
|
||||
enabled: row.aiReasoningEnabled,
|
||||
maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,13 +120,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
},
|
||||
select: ROW_SELECT,
|
||||
});
|
||||
|
||||
const row: AssistantFlagsRow = {
|
||||
@@ -108,6 +130,8 @@ export const GET = apiHandler(async () => {
|
||||
assistantInsightStatusEnabled:
|
||||
settings?.assistantInsightStatusEnabled ?? true,
|
||||
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
|
||||
aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
|
||||
aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
@@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Field by field from the parsed body; the schema is strict, so every key
|
||||
// here is one the route owns.
|
||||
const updates: Partial<AssistantFlagsRow> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (typeof value === "boolean") {
|
||||
(updates as Record<string, boolean>)[key] = value;
|
||||
auditDetails[key] = value;
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, boolean | string>)[key] = value;
|
||||
}
|
||||
}
|
||||
const auditDetails: Record<string, unknown> = { ...updates };
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
@@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: "singleton" },
|
||||
update: updates,
|
||||
create: { id: "singleton", ...updates },
|
||||
select: ROW_SELECT,
|
||||
});
|
||||
|
||||
await auditLog("admin.settings.assistant-flags.update", {
|
||||
@@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
|
||||
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
|
||||
aiReasoningEnabled: settings.aiReasoningEnabled,
|
||||
aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
|
||||
Reference in new issue
Block a user