fix(ai): raise max_tokens — inline reasoning was eating the whole budget
MiniMax M2.x reasons inside <think> tags in the normal content field, and that reasoning is billed against max_tokens. At 4096 the heavier batches spent the entire budget thinking, were truncated before writing a single answer, and stripThinking correctly removed the unclosed block — leaving an empty response. The signature was distinctive: batches returned 25/25 or 0/25, never anything partial. 193 of 779 commits went unsummarised with no error anywhere. At 16384 the same 193 completed 8/8 batches with zero failures. The interview's question call had the same bug at 1024, where it would have returned an empty question and silently ended the interview on the first turn. Now 8192. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
226f8576f2
commit
6c41f4c662
4 changed files with 17 additions and 123 deletions
119
runsum.mts
119
runsum.mts
|
|
@ -1,119 +0,0 @@
|
|||
/**
|
||||
* One-off: summarise a project's commits from the command line.
|
||||
*
|
||||
* Imports the same PURE modules the app uses — batch.ts, preamble.ts,
|
||||
* prompt.ts — so the prompts, the batching and the index-matched parsing are
|
||||
* byte-identical to what the "Summarise" button does. Only the surrounding
|
||||
* plumbing (which carries `server-only`) is reimplemented here.
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
import { BATCH_SIZE, buildBatchPrompt, chunk, parseBatchResponse } from "./src/lib/ai/batch";
|
||||
import { PROMPT_VERSION } from "./src/lib/ai/preamble";
|
||||
import { buildSystemPrompt } from "./src/lib/ai/prompt";
|
||||
import { stripThinking } from "./src/lib/ai/think-strip";
|
||||
|
||||
async function main() {
|
||||
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!);
|
||||
pb.autoCancellation(false);
|
||||
await pb
|
||||
.collection("_superusers")
|
||||
.authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!);
|
||||
|
||||
const settings = await pb.collection("settings").getFirstListItem("id != ''");
|
||||
const key: string = settings.ai_api_key ?? "";
|
||||
const model: string = settings.ai_model || "MiniMax-M2";
|
||||
if (!key) throw new Error("No API key configured — paste it in Settings first.");
|
||||
|
||||
const system = buildSystemPrompt(settings.ai_system_prompt_extra ?? "");
|
||||
|
||||
const pending = await pb.collection("commits").getFullList({
|
||||
filter: [
|
||||
"is_merge = false",
|
||||
`(summary = "" || summary_version < ${PROMPT_VERSION})`,
|
||||
"summary_attempts < 3",
|
||||
].join(" && "),
|
||||
sort: "-authored_at",
|
||||
fields: "id,subject,type,scope,summary_attempts",
|
||||
});
|
||||
|
||||
console.log(`provider=${settings.ai_provider} model=${model}`);
|
||||
console.log(`pending commits: ${pending.length}`);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const batches = chunk(
|
||||
pending.map((c) => ({
|
||||
id: c.id,
|
||||
subject: (c.subject as string) || "",
|
||||
type: (c.type as string) || "other",
|
||||
scope: (c.scope as string) || "",
|
||||
})),
|
||||
BATCH_SIZE
|
||||
);
|
||||
|
||||
let summarised = 0;
|
||||
let missing = 0;
|
||||
let inTok = 0;
|
||||
let outTok = 0;
|
||||
|
||||
for (const [index, batch] of batches.entries()) {
|
||||
process.stdout.write(` batch ${index + 1}/${batches.length} … `);
|
||||
|
||||
let text = "";
|
||||
try {
|
||||
const res = await fetch("https://api.minimax.io/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
n: 1,
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: buildBatchPrompt(batch) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(`HTTP ${res.status} — ${(await res.text()).slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
text = stripThinking(json.choices?.[0]?.message?.content ?? "");
|
||||
inTok += json.usage?.prompt_tokens ?? 0;
|
||||
outTok += json.usage?.completion_tokens ?? 0;
|
||||
} catch (error) {
|
||||
console.log(`failed — ${(error as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseBatchResponse(text, batch);
|
||||
|
||||
for (const commit of batch) {
|
||||
const summary = parsed.get(commit.id);
|
||||
if (!summary) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
await pb.collection("commits").update(commit.id, {
|
||||
summary,
|
||||
summary_version: PROMPT_VERSION,
|
||||
summary_attempts: 0,
|
||||
});
|
||||
summarised += 1;
|
||||
}
|
||||
|
||||
console.log(`${parsed.size}/${batch.length}`);
|
||||
}
|
||||
|
||||
console.log(`\nsummarised: ${summarised}`);
|
||||
console.log(`not returned by the model: ${missing}`);
|
||||
console.log(`tokens: ${inTok} in, ${outTok} out`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("FAILED:", e?.message ?? e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -103,7 +103,9 @@ export async function narrateProject(projectId: string): Promise<NarrateResult>
|
|||
{ title: card.title, description: card.description ?? "" },
|
||||
items
|
||||
),
|
||||
maxTokens: 4096,
|
||||
// 16k for the same reason as summarise.ts: inline reasoning is billed
|
||||
// against max_tokens, and a truncated response yields nothing at all.
|
||||
maxTokens: 16384,
|
||||
});
|
||||
|
||||
if (!result.text) {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,14 @@ export async function summariseProject(projectId: string): Promise<SummariseResu
|
|||
system,
|
||||
user: buildBatchPrompt(items),
|
||||
// Roughly 40 tokens per sentence plus headroom for thinking.
|
||||
maxTokens: 4096,
|
||||
// 16k, not 4k. MiniMax M2.x reasons INLINE in <think> tags, and that
|
||||
// reasoning is billed against max_tokens. At 4096 the heavier batches
|
||||
// spent the whole budget thinking, were truncated before writing a
|
||||
// single answer, and stripThinking correctly removed the unclosed
|
||||
// block — leaving an empty response and zero parsed summaries. The
|
||||
// symptom was whole batches returning 0/25 while others returned
|
||||
// 25/25, never anything partial.
|
||||
maxTokens: 16384,
|
||||
});
|
||||
text = result.text;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ export async function nextStep(input: {
|
|||
const result = await provider.complete({
|
||||
system,
|
||||
user: buildQuestionPrompt(ctx.context, turns),
|
||||
maxTokens: 1024,
|
||||
// A question is one sentence, but the reasoning that produces it is
|
||||
// billed against this too — see summarise.ts. At 1024 a reasoning
|
||||
// model returns an empty question and the interview silently ends.
|
||||
maxTokens: 8192,
|
||||
});
|
||||
|
||||
const question = result.text.trim();
|
||||
|
|
@ -113,7 +116,8 @@ export async function nextStep(input: {
|
|||
const result = await provider.complete({
|
||||
system,
|
||||
user: buildBriefPrompt(ctx.context, turns),
|
||||
maxTokens: 4096,
|
||||
// 16k — see the note in summarise.ts about inline reasoning.
|
||||
maxTokens: 16384,
|
||||
});
|
||||
raw = result.text;
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Reference in a new issue