mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): let the daily briefing and the period narratives reason
runStatusCompletion and runBriefingCompletion take an optional reasoningJob. Only a named job carries reasoning, resolved against the operator's switch and cap and the payer; a call without one, which is every status card, batch note, derived score and workout line, sends exactly what it sent before. Thinking is reserved on the day's ledger with the answer budget, and a reasoning status call falls back to a 90 s ceiling instead of 60 s. The briefing reasons on its generation and phrasing re-roll, the weekly and monthly narratives on their first pass; the JSON and grounding repairs do not. An inventory test freezes which files may name a job.
This commit is contained in:
1 parent
df0689ffbe
commit
fadb2ff6ed
9 files changed
+557
-26
No files matched your search
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* v1.41 — which background calls may reason, frozen.
|
||||
*
|
||||
* Reasoning in the background is paid for every night, for every account, by
|
||||
* nobody watching. So it is admitted where it changes the result a person
|
||||
* reads (the daily briefing and the period narratives) and nowhere else: not
|
||||
* on status cards, batch notes, derived scores, workout and reaction lines,
|
||||
* nudges, document reads or OCR, which are routine, latency-bound and
|
||||
* seed-reproducible.
|
||||
*
|
||||
* The only way a background call asks is the `reasoningJob` argument of the
|
||||
* two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test
|
||||
* finds every place that sets it and holds the set, file by file and job by
|
||||
* job, against the inventory below. A status card that started passing a job
|
||||
* goes red here; so does a new caller nobody admitted.
|
||||
*
|
||||
* Its limit: it reads source text for a `reasoningJob:` property and the job
|
||||
* literals beside it. A call that built the argument object elsewhere and
|
||||
* spread it in would slip the matcher; the chokepoint signature is the only
|
||||
* documented way in, and the wire test (`background-reasoning-wire.test.ts`)
|
||||
* proves a call without the argument sends nothing.
|
||||
*
|
||||
* Mutation check: add `reasoningJob: "daily_briefing"` to
|
||||
* `src/lib/insights/status-batch.ts`, or delete it from the narrative
|
||||
* generator: either goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** File → the jobs it may name, each with the reason it may. */
|
||||
const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
|
||||
"lib/insights/comprehensive-generate.ts": {
|
||||
jobs: ["daily_briefing"],
|
||||
why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.",
|
||||
},
|
||||
"lib/insights/narrative/period-narrative-generate.ts": {
|
||||
jobs: ["period_narrative_month", "period_narrative_week"],
|
||||
why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.",
|
||||
},
|
||||
};
|
||||
|
||||
/** The routine generators that must never name a job. */
|
||||
const NEVER = [
|
||||
"lib/insights/status-batch.ts",
|
||||
"lib/insights/status-card-generation.ts",
|
||||
"lib/insights/metric-status.ts",
|
||||
"lib/insights/biomarker-status.ts",
|
||||
"lib/insights/derived/derived-assessment-ai.ts",
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
];
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */
|
||||
const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g;
|
||||
|
||||
function scan(): Map<string, { setters: number; jobs: Set<string> }> {
|
||||
const found = new Map<string, { setters: number; jobs: Set<string> }>();
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
for (const rel of files) {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
const setters = code.match(SETTER)?.length ?? 0;
|
||||
if (setters === 0) continue;
|
||||
const jobs = new Set<string>();
|
||||
for (const m of code.matchAll(SETTER)) {
|
||||
const window = code.slice(m.index, m.index + 160);
|
||||
for (const job of BACKGROUND_REASONING_JOBS) {
|
||||
if (window.includes(`"${job}"`)) jobs.add(job);
|
||||
}
|
||||
}
|
||||
found.set(rel, { setters, jobs });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("background reasoning inventory", () => {
|
||||
const found = scan();
|
||||
|
||||
it("finds the admitted callers (an empty set is a failure, not a pass)", () => {
|
||||
expect(found.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("only the admitted files name a job", () => {
|
||||
expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort());
|
||||
});
|
||||
|
||||
it.each(Object.entries(ADMITTED))(
|
||||
"%s names exactly its admitted jobs",
|
||||
(rel, { jobs }) => {
|
||||
expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual(
|
||||
[...jobs].sort(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NEVER)("%s never reasons", (rel) => {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
expect(code).not.toMatch(/\breasoningJob\b/);
|
||||
expect(code).not.toMatch(/\breasoning\s*:/);
|
||||
});
|
||||
|
||||
it("every admitted job is a known background job", () => {
|
||||
for (const { jobs } of Object.values(ADMITTED)) {
|
||||
for (const job of jobs) {
|
||||
expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* v1.41 — what the two background chokepoints put on the wire about
|
||||
* reasoning.
|
||||
*
|
||||
* Status cards and every other routine generation call `runStatusCompletion`
|
||||
* without a job and must send no `reasoning` at all. The daily briefing and
|
||||
* the period narratives name their job and get the resolved level, unless the
|
||||
* operator switched reasoning off, which wins over every job. The operator's
|
||||
* controls are read through the real loader against a stubbed settings row,
|
||||
* so the switch is exercised the way production reads it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { settingsRow } = vi.hoisted(() => ({
|
||||
settingsRow: {
|
||||
current: null as null | {
|
||||
aiReasoningEnabled: boolean;
|
||||
aiReasoningMaxEffort: string;
|
||||
},
|
||||
fail: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => {
|
||||
if (settingsRow.fail) throw new Error("db down");
|
||||
return settingsRow.current;
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({ aiResponseTimeoutSeconds: null })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => undefined,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain,
|
||||
resolveProvider: vi.fn(),
|
||||
probeProviderPresence: vi.fn(async () => true),
|
||||
probeProviderChain: vi.fn(),
|
||||
isAnthropicBaseUrl: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForRecord: vi.fn(async () => ({ available: true })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const { reserveBudget } = vi.hoisted(() => ({ reserveBudget: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/budget", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/ai/coach/budget")>();
|
||||
return {
|
||||
...actual,
|
||||
reserveBudget,
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
import { runStatusCompletion } from "../status-provider";
|
||||
import { runBriefingCompletion } from "../briefing-provider";
|
||||
import type { CompletionParams } from "@/lib/ai/types";
|
||||
|
||||
const BYOK = [{ providerType: "anthropic" as const, instance: {} as never }];
|
||||
const OPERATOR = [
|
||||
{ providerType: "admin-openai" as const, instance: {} as never },
|
||||
];
|
||||
|
||||
function sentParams(): CompletionParams {
|
||||
const call = runRawCompletionWithFallback.mock.calls.at(-1)?.[0] as {
|
||||
params: CompletionParams;
|
||||
};
|
||||
return call.params;
|
||||
}
|
||||
|
||||
function reserved(): number {
|
||||
return reserveBudget.mock.calls.at(-1)?.[1] as number;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
settingsRow.current = null;
|
||||
settingsRow.fail = false;
|
||||
resolveProviderChain.mockResolvedValue(BYOK);
|
||||
reserveBudget.mockImplementation(async (_u: string, n: number) => ({
|
||||
allowed: true,
|
||||
reserved: n,
|
||||
owner: "user",
|
||||
}));
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: '{"summary":"ok"}', tokensUsed: 10 },
|
||||
workingProvider: BYOK[0],
|
||||
});
|
||||
});
|
||||
|
||||
const statusArgs = {
|
||||
userId: "u1",
|
||||
cacheAction: "status:test",
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
capability: "statusText" as const,
|
||||
};
|
||||
|
||||
describe("runStatusCompletion", () => {
|
||||
it("sends no reasoning for a call that names no job (every status card)", async () => {
|
||||
await runStatusCompletion(statusArgs);
|
||||
expect(sentParams().reasoning).toBeUndefined();
|
||||
expect(sentParams().timeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("sends the job's level for a monthly narrative and reserves its thinking", async () => {
|
||||
await runStatusCompletion({
|
||||
...statusArgs,
|
||||
capability: "periodNarrative",
|
||||
maxTokens: 400,
|
||||
reasoningJob: "period_narrative_month",
|
||||
});
|
||||
expect(sentParams().reasoning).toEqual({
|
||||
effort: "medium",
|
||||
summaries: false,
|
||||
});
|
||||
// The answer budget only; the client adds the thinking budget.
|
||||
expect(sentParams().maxTokens).toBe(400);
|
||||
expect(sentParams().timeoutMs).toBe(90_000);
|
||||
expect(reserved()).toBe(400 + 4_096 + Math.ceil(10 / 4));
|
||||
});
|
||||
|
||||
it("drops to the operator level when the operator pays", async () => {
|
||||
resolveProviderChain.mockResolvedValue(OPERATOR);
|
||||
await runStatusCompletion({
|
||||
...statusArgs,
|
||||
capability: "periodNarrative",
|
||||
reasoningJob: "period_narrative_month",
|
||||
});
|
||||
expect(sentParams().reasoning?.effort).toBe("low");
|
||||
});
|
||||
|
||||
it("honours the operator's cap", async () => {
|
||||
settingsRow.current = {
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: "low",
|
||||
};
|
||||
await runStatusCompletion({
|
||||
...statusArgs,
|
||||
capability: "periodNarrative",
|
||||
reasoningJob: "period_narrative_month",
|
||||
});
|
||||
expect(sentParams().reasoning?.effort).toBe("low");
|
||||
});
|
||||
|
||||
it("sends nothing when the operator switched reasoning off", async () => {
|
||||
settingsRow.current = {
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "high",
|
||||
};
|
||||
await runStatusCompletion({
|
||||
...statusArgs,
|
||||
capability: "periodNarrative",
|
||||
maxTokens: 400,
|
||||
reasoningJob: "period_narrative_month",
|
||||
});
|
||||
expect(sentParams().reasoning).toBeUndefined();
|
||||
expect(sentParams().timeoutMs).toBe(60_000);
|
||||
expect(reserved()).toBe(400 + Math.ceil(10 / 4));
|
||||
});
|
||||
|
||||
it("sends nothing when the controls cannot be read (fails closed)", async () => {
|
||||
settingsRow.fail = true;
|
||||
const outcome = await runStatusCompletion({
|
||||
...statusArgs,
|
||||
capability: "periodNarrative",
|
||||
reasoningJob: "period_narrative_week",
|
||||
});
|
||||
expect(outcome.kind).toBe("ok");
|
||||
expect(sentParams().reasoning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBriefingCompletion", () => {
|
||||
const briefingArgs = {
|
||||
userId: "u1",
|
||||
chain: BYOK,
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
temperature: 0.3,
|
||||
maxTokens: 3000,
|
||||
timeoutMs: 180_000,
|
||||
stage: "generate" as const,
|
||||
};
|
||||
|
||||
it("sends no reasoning without a job (the retries, the on-demand route)", async () => {
|
||||
await runBriefingCompletion(briefingArgs);
|
||||
expect(sentParams().reasoning).toBeUndefined();
|
||||
expect(reserved()).toBe(3000 + Math.ceil(10 / 4));
|
||||
});
|
||||
|
||||
it("thinks at medium on the person's own provider", async () => {
|
||||
await runBriefingCompletion({
|
||||
...briefingArgs,
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
expect(sentParams().reasoning).toEqual({
|
||||
effort: "medium",
|
||||
summaries: false,
|
||||
});
|
||||
expect(sentParams().maxTokens).toBe(3000);
|
||||
expect(reserved()).toBe(3000 + 4_096 + Math.ceil(10 / 4));
|
||||
});
|
||||
|
||||
it("thinks at low on the operator's provider", async () => {
|
||||
await runBriefingCompletion({
|
||||
...briefingArgs,
|
||||
chain: OPERATOR,
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
expect(sentParams().reasoning?.effort).toBe("low");
|
||||
});
|
||||
|
||||
it("sends nothing when the operator switched reasoning off", async () => {
|
||||
settingsRow.current = {
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "high",
|
||||
};
|
||||
await runBriefingCompletion({
|
||||
...briefingArgs,
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
expect(sentParams().reasoning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
},
|
||||
auditLog: { deleteMany: vi.fn() },
|
||||
// v1.41 — the operator's reasoning controls; an untouched instance.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
// The `briefing` capability is available in these fixtures; the refusal
|
||||
@@ -163,6 +165,8 @@ describe("daily-briefing re-roll on unchanged data", () => {
|
||||
const params = runRawCompletionWithFallback.mock.calls[0][0].params;
|
||||
expect(params.temperature).toBe(0.6);
|
||||
expect(params.seed).toBeUndefined();
|
||||
// v1.41 — the re-roll thinks like the generation it replaces.
|
||||
expect(params.reasoning?.effort).toBe("medium");
|
||||
|
||||
// The cache write swaps only the paragraph; findings + summary preserved.
|
||||
const write = userUpdate.mock.calls.find(
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
},
|
||||
auditLog: { deleteMany: vi.fn() },
|
||||
// v1.41 — the operator's reasoning controls; an untouched instance.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
// The `briefing` capability is available in these fixtures; the refusal
|
||||
@@ -141,6 +143,14 @@ describe("comprehensive JSON-retry", () => {
|
||||
expect(retryParams.messages[0].content).toContain(
|
||||
"did not satisfy the required",
|
||||
);
|
||||
// v1.41 — the generation reasons as the daily-briefing job; the JSON
|
||||
// repair does not.
|
||||
const firstParams = runRawCompletionWithFallback.mock.calls[0][0].params;
|
||||
expect(firstParams.reasoning).toEqual({
|
||||
effort: "medium",
|
||||
summaries: false,
|
||||
});
|
||||
expect(retryParams.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails with invalid-json when both attempts miss", async () => {
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REASONING_THINKING_BUDGET,
|
||||
type BackgroundReasoningJob,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import { resolveJobReasoning } from "@/lib/ai/reasoning/controls";
|
||||
|
||||
/**
|
||||
* Which call on the briefing path this was. Threaded onto the refusal
|
||||
@@ -90,6 +95,14 @@ export interface RunBriefingCompletionArgs {
|
||||
seed?: number;
|
||||
/** UTC day-key override; defaults to today. Tests pin it. */
|
||||
dateKey?: string;
|
||||
/**
|
||||
* v1.41 — `daily_briefing` on the calls that may reason (the generation and
|
||||
* its phrasing re-roll). Absent on the JSON-shape and grounding retries,
|
||||
* which repair a reply rather than think it through again, and on every
|
||||
* caller outside the background generator. `maxTokens` stays the answer
|
||||
* budget; the client adds the thinking budget itself.
|
||||
*/
|
||||
reasoningJob?: BackgroundReasoningJob;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,11 +124,23 @@ export async function runBriefingCompletion(
|
||||
): Promise<RunRawWithFallbackResult> {
|
||||
const dateKey = args.dateKey ?? buildDateKey();
|
||||
|
||||
// v1.41 — one resolution per call: the operator's switch and cap, the payer
|
||||
// from the chain's primary. `undefined` is today's wire.
|
||||
const reasoning = args.reasoningJob
|
||||
? await resolveJobReasoning(args.reasoningJob, args.chain)
|
||||
: undefined;
|
||||
const thinkingTokens =
|
||||
reasoning && reasoning.effort !== "off"
|
||||
? REASONING_THINKING_BUDGET[reasoning.effort]
|
||||
: 0;
|
||||
|
||||
// Reserve an ESTIMATE up front — the output ceiling plus a ~4-chars-per-token
|
||||
// approximation of the prompt about to be sent — and reconcile against the
|
||||
// provider's reported count afterwards. Same shape as the status tier.
|
||||
// Thinking is billed as output, so its budget is reserved too.
|
||||
const estimatedTokens =
|
||||
args.maxTokens +
|
||||
thinkingTokens +
|
||||
Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4);
|
||||
|
||||
// v1.38.19 — a background surface: half the day's ceiling when the
|
||||
@@ -153,17 +178,21 @@ export async function runBriefingCompletion(
|
||||
// the job share, exactly as the reservation above was.
|
||||
surface: "job",
|
||||
providers: args.chain,
|
||||
params: singleUserTurn({
|
||||
system: args.systemPrompt,
|
||||
user: args.userPrompt,
|
||||
temperature: args.temperature,
|
||||
maxTokens: args.maxTokens,
|
||||
seed: args.seed,
|
||||
timeoutMs: args.timeoutMs,
|
||||
// Every briefing call parses its reply with `JSON.parse`, so opt the
|
||||
// chains with a native JSON mode into it.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
params: {
|
||||
...singleUserTurn({
|
||||
system: args.systemPrompt,
|
||||
user: args.userPrompt,
|
||||
temperature: args.temperature,
|
||||
maxTokens: args.maxTokens,
|
||||
seed: args.seed,
|
||||
timeoutMs: args.timeoutMs,
|
||||
// Every briefing call parses its reply with `JSON.parse`, so opt the
|
||||
// chains with a native JSON mode into it.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
// v1.41 — only a call that names its job carries this.
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// No usable reply: refund the whole reservation rather than bill an
|
||||
|
||||
@@ -249,6 +249,9 @@ async function rerollBriefingParagraph(args: {
|
||||
// v1.25 — honours the per-user response-timeout setting (see caller).
|
||||
timeoutMs: args.effectiveTimeoutMs,
|
||||
stage: "reroll",
|
||||
// v1.41 — the re-roll is a fresh pass at the paragraph, so it may think
|
||||
// like the generation it replaces; the operator's switch still wins.
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
result = fallback.result;
|
||||
providerType = fallback.workingProvider.providerType;
|
||||
@@ -1132,6 +1135,11 @@ export async function generateComprehensiveInsight(
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
stage: "generate",
|
||||
// v1.41 — the briefing is one of the few background jobs that may
|
||||
// reason (medium on the person's own provider, low on the operator's,
|
||||
// never above the operator's cap, nothing when switched off). The JSON
|
||||
// and grounding retries below repair a reply and do not reason.
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
|
||||
@@ -221,6 +221,66 @@ describe("generatePeriodNarrative — descriptive generation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePeriodNarrative — background reasoning (v1.41)", () => {
|
||||
it.each([
|
||||
["week", "period_narrative_week"],
|
||||
["month", "period_narrative_month"],
|
||||
] as const)(
|
||||
"names the %s narrative's own job on the first pass",
|
||||
async (period, job) => {
|
||||
const prisma = makePrisma();
|
||||
const runCompletion = vi.fn(async (_args: { reasoningJob?: string }) => ({
|
||||
kind: "ok" as const,
|
||||
content: "Your weight eased down slightly.",
|
||||
providerType: "openai",
|
||||
model: "m",
|
||||
tokensUsed: 1,
|
||||
}));
|
||||
await generatePeriodNarrative("u1", {
|
||||
period,
|
||||
locale: "en",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
buildContext: async () =>
|
||||
readyContext({ period }) as PeriodNarrativeResult,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runCompletion: runCompletion as any,
|
||||
});
|
||||
expect(runCompletion.mock.calls[0][0].reasoningJob).toBe(job);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not reason on the grounding retry", async () => {
|
||||
const prisma = makePrisma();
|
||||
const runCompletion = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
kind: "ok",
|
||||
content: "Your weight fell by 9.9 kg because you slept more.",
|
||||
providerType: "openai",
|
||||
model: "m",
|
||||
tokensUsed: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: "ok",
|
||||
content: "Your weight eased down slightly.",
|
||||
providerType: "openai",
|
||||
model: "m",
|
||||
tokensUsed: 1,
|
||||
});
|
||||
await generatePeriodNarrative("u1", {
|
||||
period: "week",
|
||||
locale: "en",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
buildContext: async () => readyContext() as PeriodNarrativeResult,
|
||||
runCompletion,
|
||||
});
|
||||
expect(runCompletion).toHaveBeenCalledTimes(2);
|
||||
expect(runCompletion.mock.calls[1][0].reasoningJob).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePeriodNarrative — honesty floor", () => {
|
||||
it("writes no narrative when the context is insufficient", async () => {
|
||||
const prisma = makePrisma();
|
||||
|
||||
@@ -369,6 +369,11 @@ export async function generatePeriodNarrative(
|
||||
// The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress
|
||||
// the JSON-mode opt-in the status cards default into.
|
||||
responseFormat: "text",
|
||||
// v1.41 — a period narrative is one of the few background jobs that may
|
||||
// reason: medium for the month, low for the week, held to the operator's
|
||||
// switch and cap. The grounding retry below repairs and does not reason.
|
||||
reasoningJob:
|
||||
period === "month" ? "period_narrative_month" : "period_narrative_week",
|
||||
});
|
||||
|
||||
// A provider error / timeout is non-fatal — the last good row stays as-is
|
||||
|
||||
@@ -26,6 +26,19 @@ import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import {
|
||||
REASONING_THINKING_BUDGET,
|
||||
type BackgroundReasoningJob,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import { resolveJobReasoning } from "@/lib/ai/reasoning/controls";
|
||||
|
||||
/**
|
||||
* v1.41 — the fallback ceiling for a status-path call that reasons. Thinking
|
||||
* is spent before the first answer token, so the 60 s status budget would cut
|
||||
* a medium-effort narrative off mid-thought. A positive per-record setting
|
||||
* still wins, as everywhere.
|
||||
*/
|
||||
export const REASONING_JOB_TIMEOUT_MS = 90_000;
|
||||
|
||||
/**
|
||||
* Shared provider plumbing for the seven `*-status.ts` generators.
|
||||
@@ -92,6 +105,15 @@ interface RunStatusCompletionArgs {
|
||||
* consent kinds an operator-held chain entry needs.
|
||||
*/
|
||||
capability: AiCapabilityKey;
|
||||
/**
|
||||
* v1.41 — the background job this call is, when it is one of the few that
|
||||
* may reason (`BACKGROUND_REASONING_JOBS`: the period narratives and the
|
||||
* Coach memory passes). Absent for every status card, batch note, derived
|
||||
* score and workout line, which therefore send exactly what they sent
|
||||
* before reasoning existed; `background-reasoning-inventory.test.ts`
|
||||
* freezes which call sites set it. The operator's switch and cap apply.
|
||||
*/
|
||||
reasoningJob?: BackgroundReasoningJob;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,11 +212,23 @@ export async function runStatusCompletion(
|
||||
where: { id: userId },
|
||||
select: { aiResponseTimeoutSeconds: true },
|
||||
});
|
||||
// v1.41 — resolved once per call, after the capability and egress checks
|
||||
// so a refused call never reads the controls. `undefined` (the operator
|
||||
// switched reasoning off, or the job resolved off) is today's wire.
|
||||
const reasoning = args.reasoningJob
|
||||
? await resolveJobReasoning(args.reasoningJob, chain)
|
||||
: undefined;
|
||||
const thinkingTokens =
|
||||
reasoning && reasoning.effort !== "off"
|
||||
? REASONING_THINKING_BUDGET[reasoning.effort]
|
||||
: 0;
|
||||
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
settingsRow?.aiResponseTimeoutSeconds,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
thinkingTokens > 0 ? REASONING_JOB_TIMEOUT_MS : STATUS_PROVIDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// The answer budget. Each client adds the thinking budget on top itself.
|
||||
const maxTokens = args.maxTokens ?? AI_BUDGETS.status.maxTokens;
|
||||
|
||||
// The day's token ledger. Until now this chokepoint — the provider entry for
|
||||
@@ -217,8 +251,12 @@ export async function runStatusCompletion(
|
||||
// or a local model is measured against the generous user-plan ceiling, so
|
||||
// their own hardware/plan is never rationed by the operator's bill.
|
||||
const dateKey = buildDateKey();
|
||||
// Thinking is billed as output, so a reasoning call reserves its thinking
|
||||
// budget too; the reconcile below settles on the reported count.
|
||||
const estimatedTokens =
|
||||
maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4);
|
||||
maxTokens +
|
||||
thinkingTokens +
|
||||
Math.ceil((systemPrompt.length + userPrompt.length) / 4);
|
||||
// v1.38.19 — a background surface: half the day's ceiling when the
|
||||
// operator funds the chain.
|
||||
const jobCap = resolveDailyCapFor("job", chain);
|
||||
@@ -264,19 +302,23 @@ export async function runStatusCompletion(
|
||||
// A background generator: an operator-funded fallback hop is rationed
|
||||
// at the job share, exactly as the reservation above was.
|
||||
surface: "job",
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
|
||||
maxTokens,
|
||||
// v1.18.7 — status/reference output is reproducible: pin the
|
||||
// deterministic seed unless a caller overrides it.
|
||||
seed: args.seed ?? REFERENCE_AI_SEED,
|
||||
// Status cards are JSON by default; the narrative opts out via
|
||||
// `"text"`.
|
||||
responseFormat: args.responseFormat === "text" ? undefined : "json",
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
}),
|
||||
params: {
|
||||
...singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
|
||||
maxTokens,
|
||||
// v1.18.7 — status/reference output is reproducible: pin the
|
||||
// deterministic seed unless a caller overrides it.
|
||||
seed: args.seed ?? REFERENCE_AI_SEED,
|
||||
// Status cards are JSON by default; the narrative opts out via
|
||||
// `"text"`.
|
||||
responseFormat: args.responseFormat === "text" ? undefined : "json",
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
}),
|
||||
// v1.41 — only a named job ever carries this; see `reasoningJob`.
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
},
|
||||
}),
|
||||
effectiveTimeoutMs,
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user