mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(coach): run the turn on a budget with a live trail, questions and comparisons
Replace the fixed three tool rounds with a budget fixed by who pays:
120k tokens, 150 s and 12 rounds on the person's own plan, key or local
model, 40k, 90 s and 6 rounds when the operator pays. Cached input counts
at a tenth. The final answer is reserved from the start, so a turn whose
budget, clock or round cap runs out answers from what it read. A
no-progress brake stops a turn that repeats calls or reads nothing new,
and a repeated call is never run again.
The day's ledger is booked round by round: round one and the final
reserve when the turn starts, each further round right before it runs,
every round settled as it returns, the unused reserve given back at the
end. A round the daily ceiling refuses ends the turn with an answer.
Every phase goes out live as an activity frame (thinking, fetching,
summarising, asking, remembering, planning, the stop, the answer), with
screened reasoning titles and summaries; the structure rides the
provenance and the text is sealed in trail_encrypted, served by
GET /api/insights/chat/{id}/messages/{messageId}/trail. Tables go out as
interim results as soon as they are read.
Add ask_clarification with a server brake, remember_fact and
propose_plan over the memory contract, model-free decision turns,
compare_series with the compare chart, eight tables per message,
assumptions with change-assumption chips, a reasoning level per turn and
at most two concurrent turns per person.
This commit is contained in:
1 parent
210becb28b
commit
cf68055cb2
59 files changed
+5477
-614
No files matched your search
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.41 — the trail route: owner-narrowed, 404 for anything else, and the
|
||||
* model-written text only while the Coach's text may be shown.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const { requireAuth, readMessageTrail, aiCapabilityToServe } = vi.hoisted(
|
||||
() => ({
|
||||
requireAuth: vi.fn(),
|
||||
readMessageTrail: vi.fn(),
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ApiHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
|
||||
handler,
|
||||
requireAuth,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
const TRAIL = {
|
||||
entries: [{ id: "a1", title: "Weighing the two weeks", text: "Looked at sleep first." }],
|
||||
};
|
||||
|
||||
function call(id = "c1", messageId = "m1") {
|
||||
return GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`,
|
||||
),
|
||||
{ params: Promise.resolve({ id, messageId }) },
|
||||
) as Promise<Response>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
|
||||
it("reads under the caller's own id and the path ids only", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
const response = await call("c1", "m1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1");
|
||||
// Requires a session or a full-access token, like every Coach read.
|
||||
expect(requireAuth).toHaveBeenCalledWith();
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toEqual(TRAIL);
|
||||
expect(body.data.ai.available).toBe(true);
|
||||
});
|
||||
|
||||
it("answers 404 for a message the caller does not own", async () => {
|
||||
readMessageTrail.mockResolvedValue(null);
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
expect(aiCapabilityToServe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves no model text while the Coach's text may not be shown", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: false,
|
||||
reason: "disabled_by_user",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
expect(body.data.ai.available).toBe(false);
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
|
||||
});
|
||||
|
||||
it("answers a message without a trail with null", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: null });
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the model-written
|
||||
* text of one assistant message's trail (v1.41): the screened reasoning
|
||||
* titles and summaries and checkpoint sentences by trail entry, decrypted for
|
||||
* their owner. The structure of the trail (phases, statuses, counts) is
|
||||
* plaintext on the message's `metricSource.activity`; only the text is here,
|
||||
* read lazily so a long thread does not carry every trail.
|
||||
*
|
||||
* Narrowed exactly like `…/results`: the message must sit in a conversation
|
||||
* the caller owns, and a foreign or unknown id is 404, never 403. A session
|
||||
* or a full-access token reads it; a narrow token is refused by
|
||||
* `requireAuth()` like on every Coach read.
|
||||
*
|
||||
* A mixed read: the text is model-written, so it is served only while the
|
||||
* Coach's text may be shown for the record (`aiCapabilityToServe`). When it
|
||||
* may not, the answer is 200 with `trail: null` and the `ai` state beside it,
|
||||
* and the client keeps the trail's catalog labels.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { readMessageTrail } from "@/lib/ai/coach/persistence";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string; messageId: string }>;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
const userId = auth.user.id;
|
||||
const { id, messageId } = await ctx.params;
|
||||
if (!id || !messageId) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const read = await readMessageTrail(userId, id, messageId);
|
||||
if (!read) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const ai = await aiCapabilityToServe(userId, "coach");
|
||||
const trail = ai.available ? read.trail : null;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach.trail.read" },
|
||||
meta: {
|
||||
conversationId: id,
|
||||
entries: trail?.entries.length ?? 0,
|
||||
served: ai.available,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ trail, ai });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
+1393
-108
File diff suppressed because it is too large.
Load diff
@@ -473,7 +473,10 @@ export const modules = {
|
||||
labResult: { aggregate: async () => EMPTY_AGGREGATE },
|
||||
},
|
||||
}),
|
||||
rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
|
||||
rateLimit: () => ({
|
||||
checkRateLimit: m.checkRateLimit,
|
||||
refundRateLimit: async () => {},
|
||||
}),
|
||||
serverLocale: () => ({
|
||||
resolveServerLocale: async (args: { override?: string }) =>
|
||||
args.override ?? "en",
|
||||
|
||||
@@ -373,7 +373,26 @@ export async function runCoachToolLoop(args: Record<string, unknown>) {
|
||||
timeoutMs: args.timeoutMs,
|
||||
signal: args.signal instanceof AbortSignal,
|
||||
});
|
||||
return m.runCoachToolLoop(args);
|
||||
const out = await m.runCoachToolLoop(args);
|
||||
// The real loop settles each round on the turn's ledger as it returns;
|
||||
// the scripted loop settles its whole cost as one round.
|
||||
const spend = args.spend as
|
||||
| {
|
||||
settleRound(usage: {
|
||||
tokens: number;
|
||||
cachedTokens: number;
|
||||
servedBy: string;
|
||||
final: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
await spend?.settleRound({
|
||||
tokens: out.totalTokens,
|
||||
cachedTokens: out.cachedTokens,
|
||||
servedBy: out.workingProviderType,
|
||||
final: false,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runStreamingRawCompletionWithFallback(
|
||||
|
||||
@@ -65,7 +65,10 @@ vi.mock("@/lib/db", () => ({
|
||||
coachMessage: { findMany: h.m.coachMessageFindMany },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: h.m.checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: h.m.resolveServerLocale,
|
||||
}));
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -69,7 +69,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -53,7 +53,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -38,7 +38,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -70,7 +70,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
@@ -316,21 +319,25 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
expect(args.messages[0].content).toContain("2400");
|
||||
});
|
||||
|
||||
it("reserves the multi-round budget in tool mode", async () => {
|
||||
it("reserves round one and the final answer up front in tool mode", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
// maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware
|
||||
// daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain.
|
||||
// v1.41 — the first round (its input estimate plus a round's answer and
|
||||
// the default medium thinking budget) and the final answer's reserve, in
|
||||
// one reservation. The 4th arg is the provider-aware daily cap —
|
||||
// the user-plan ceiling for this BYOK chain.
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
4500,
|
||||
expect.any(Number),
|
||||
"2026-06-21",
|
||||
2_000_000,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
const reserved = reserveBudget.mock.calls[0][1] as number;
|
||||
expect(reserved).toBeGreaterThanOrEqual(600 + 4_096 + 1_200);
|
||||
});
|
||||
|
||||
it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -63,6 +63,19 @@ async function coachCapabilityRefusal(): Promise<Response | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — at most this many Coach turns of one person at once. A budgeted
|
||||
* turn can run for minutes; three tabs firing at the same time would run
|
||||
* three of them against one daily budget and one provider account.
|
||||
*/
|
||||
const COACH_CONCURRENT_TURNS = 2;
|
||||
/**
|
||||
* How long a turn's slot is held at most: a turn's wall time (150 s) plus
|
||||
* the streaming of its reply. A slot whose release never arrived (a crashed
|
||||
* process) frees itself when the window ends.
|
||||
*/
|
||||
const COACH_TURN_SLOT_MS = 180_000;
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// The `coach` capability, right after auth and before the rate limit and
|
||||
@@ -111,6 +124,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
} = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling layered in front of the daily budget
|
||||
@@ -162,6 +177,29 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// given back at once, since the refused attempt never ran.
|
||||
const slotKey = `coach-turn-active:${userId}`;
|
||||
const slot = await checkRateLimit(
|
||||
slotKey,
|
||||
COACH_CONCURRENT_TURNS,
|
||||
COACH_TURN_SLOT_MS,
|
||||
);
|
||||
const releaseSlot = () => {
|
||||
void refundRateLimit(slotKey).catch(() => {
|
||||
// The window frees the slot on its own.
|
||||
});
|
||||
};
|
||||
if (!slot.allowed) {
|
||||
releaseSlot();
|
||||
annotate({
|
||||
action: { name: "insights.coach.concurrent-limited" },
|
||||
meta: { limit: COACH_CONCURRENT_TURNS },
|
||||
});
|
||||
return apiError("Too many Coach requests, please wait a moment", 429);
|
||||
}
|
||||
|
||||
// Conversation, context, chain, egress re-check, budget, and the streamed
|
||||
// reply: `src/lib/ai/coach/turn/`.
|
||||
return runCoachTurn({
|
||||
@@ -175,6 +213,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
releaseSlot,
|
||||
recheckCapability: coachCapabilityRefusal,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user