mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(coach): resolve the turn's thinking depth from the person and the operator
The chat route hands the turn the level resolveCoachTurnReasoningLevel returns: the stored Coach preference, absent read as medium, after the operator's switch and cap. The turn still applies the operator-paid ceiling once its chain is known. The controls loader no longer throws when the request cache cannot serve it; it fails closed to off. The admin guard pins the route to the resolver.
This commit is contained in:
1 parent
85ebdad5c9
commit
ae5cee331e
6 files changed
+168
-10
No files matched your search
@@ -149,3 +149,28 @@ describe("every call that carries reasoning took it from the resolver", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Coach turn receives its level on `TurnInput.reasoningLevel` and carries
|
||||
* it through the loop under its own names, which the setter shapes above do
|
||||
* not see. The one place that level is decided is the chat route, so the
|
||||
* route is pinned: it resolves the level, and it does not fall back to a
|
||||
* constant that would skip the operator's switch.
|
||||
*/
|
||||
describe("the Coach turn's level comes from the resolver", () => {
|
||||
const route = stripComments(
|
||||
readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"),
|
||||
);
|
||||
|
||||
it("resolves the level in the chat route", () => {
|
||||
expect(route).toMatch(
|
||||
/reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/,
|
||||
);
|
||||
expect(route).toMatch(RESOLVER_IMPORT);
|
||||
});
|
||||
|
||||
it("never hands the turn a constant level", () => {
|
||||
expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/);
|
||||
expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,8 @@ export function resetGolden(): void {
|
||||
},
|
||||
);
|
||||
m.getEvent.mockReturnValue({
|
||||
// A request event, so the per-request cache memoises as in production.
|
||||
getKind: () => "request",
|
||||
setError: (err: Error) =>
|
||||
record("event.setError", { message: err.message }),
|
||||
});
|
||||
|
||||
@@ -63,6 +63,8 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate },
|
||||
coachConversation: { findFirst: h.m.conversationFindFirst },
|
||||
coachMessage: { findMany: h.m.coachMessageFindMany },
|
||||
// v1.41 — the operator's reasoning controls; an untouched instance.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
|
||||
@@ -37,10 +37,8 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import { listConversations } from "@/lib/ai/coach/persistence";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import { runCoachTurn } from "@/lib/ai/coach/turn/pipeline";
|
||||
import {
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
type ReasoningLevel,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import type { ReasoningLevel } from "@/lib/ai/reasoning/levels";
|
||||
import { resolveCoachTurnReasoningLevel } from "@/lib/ai/reasoning/controls";
|
||||
import { streamProviderError, streamRefusal } from "@/lib/ai/coach/turn/sse";
|
||||
|
||||
/**
|
||||
@@ -182,10 +180,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
// v1.41 — the person's effective reasoning level: their choice after the
|
||||
// operator's switch and highest level. Integration points this one call
|
||||
// site at the shared resolver (`src/lib/ai/reasoning/resolve.ts`); until
|
||||
// then every turn thinks at the default level.
|
||||
const reasoningLevel: ReasoningLevel = DEFAULT_REASONING_LEVEL;
|
||||
// operator's switch and highest level, from the shared resolver. Who pays
|
||||
// is not known until the turn resolves its chain, so the turn applies the
|
||||
// operator-paid ceiling itself (`turn/reasoning.ts`); the provider's own
|
||||
// ability is left to its client.
|
||||
const reasoningLevel: ReasoningLevel = await resolveCoachTurnReasoningLevel(
|
||||
auth.user.coachPrefsJson,
|
||||
);
|
||||
|
||||
// v1.41 — the person's turn slot. The counter is the rate-limit row's own
|
||||
// (an atomic upsert), given back when the turn is over; a refused slot is
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* v1.41 — the Coach turn's level as the chat route resolves it: the stored
|
||||
* preference against the operator's controls, read through the real loader
|
||||
* against a stubbed settings row.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { row } = vi.hoisted(() => ({
|
||||
row: { current: null as unknown, fail: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => {
|
||||
if (row.fail) throw new Error("db down");
|
||||
return row.current;
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => undefined,
|
||||
}));
|
||||
|
||||
import {
|
||||
loadReasoningControls,
|
||||
resolveCoachTurnReasoningLevel,
|
||||
} from "../controls";
|
||||
|
||||
beforeEach(() => {
|
||||
row.current = null;
|
||||
row.fail = false;
|
||||
});
|
||||
|
||||
describe("loadReasoningControls", () => {
|
||||
it("reads an untouched instance as on and uncapped", async () => {
|
||||
expect(await loadReasoningControls()).toEqual({
|
||||
enabled: true,
|
||||
maxEffort: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the row cannot be read", async () => {
|
||||
row.fail = true;
|
||||
expect((await loadReasoningControls()).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("reads an unknown stored cap as no cap", async () => {
|
||||
row.current = { aiReasoningEnabled: true, aiReasoningMaxEffort: "xhigh" };
|
||||
expect((await loadReasoningControls()).maxEffort).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCoachTurnReasoningLevel", () => {
|
||||
it("runs a never-chosen preference at medium", async () => {
|
||||
expect(await resolveCoachTurnReasoningLevel(null)).toBe("medium");
|
||||
expect(await resolveCoachTurnReasoningLevel({ tone: "warm" })).toBe(
|
||||
"medium",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a malformed stored level as the default", async () => {
|
||||
expect(await resolveCoachTurnReasoningLevel({ reasoning: "max" })).toBe(
|
||||
"medium",
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps the stored level to the operator's cap", async () => {
|
||||
row.current = { aiReasoningEnabled: true, aiReasoningMaxEffort: "low" };
|
||||
expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe(
|
||||
"low",
|
||||
);
|
||||
});
|
||||
|
||||
it("is off when the operator switched reasoning off", async () => {
|
||||
row.current = { aiReasoningEnabled: false, aiReasoningMaxEffort: "high" };
|
||||
expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
it("is off when the controls cannot be read", async () => {
|
||||
row.fail = true;
|
||||
expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
type CoachPrefs,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
import type { BackgroundReasoningJob } from "./levels";
|
||||
import {
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
isReasoningLevel,
|
||||
type BackgroundReasoningJob,
|
||||
type ReasoningLevel,
|
||||
} from "./levels";
|
||||
import {
|
||||
coachReasoningCeiling,
|
||||
completionReasoning,
|
||||
@@ -45,7 +50,17 @@ const UNREADABLE: ReasoningAdminControls = Object.freeze({
|
||||
* inside a background run, so switching reasoning off at 02:05 stops the
|
||||
* nightly pass that began at 02:00 for every account after the switch.
|
||||
*/
|
||||
export function loadReasoningControls(): Promise<ReasoningAdminControls> {
|
||||
export async function loadReasoningControls(): Promise<ReasoningAdminControls> {
|
||||
// The request cache itself can fail (an event without a kind); the
|
||||
// controls then fail closed like an unreadable row, never the caller.
|
||||
try {
|
||||
return await loadMemoisedControls();
|
||||
} catch {
|
||||
return UNREADABLE;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMemoisedControls(): Promise<ReasoningAdminControls> {
|
||||
return memoizePerRequest(
|
||||
"ai-reasoning-controls",
|
||||
async () => {
|
||||
@@ -174,3 +189,26 @@ export async function resolveJobReasoning(
|
||||
});
|
||||
return completionReasoning(resolved, job);
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective Coach level for a turn: the person's stored choice
|
||||
* (`coachPrefsJson.reasoning`, absent read as the default) after the
|
||||
* operator's switch and cap. Who pays is applied inside the turn, once its
|
||||
* chain is known; the provider's ability is left to its client. Never
|
||||
* throws: an unreadable preference reads as the default, unreadable
|
||||
* controls as "off".
|
||||
*/
|
||||
export async function resolveCoachTurnReasoningLevel(
|
||||
coachPrefsJson: unknown,
|
||||
): Promise<ReasoningLevel> {
|
||||
const stored =
|
||||
coachPrefsJson && typeof coachPrefsJson === "object"
|
||||
? (coachPrefsJson as { reasoning?: unknown }).reasoning
|
||||
: undefined;
|
||||
return resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: isReasoningLevel(stored) ? stored : DEFAULT_REASONING_LEVEL,
|
||||
admin: await loadReasoningControls(),
|
||||
costOwner: "user",
|
||||
}).effort;
|
||||
}
|
||||
Reference in new issue
Block a user