Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
153 lines
7.0 KiB
TypeScript
153 lines
7.0 KiB
TypeScript
/**
|
|
* BUG-1057 (TASK-rectification-grounding-20260927 T3, red line 3): the
|
|
* turn-decision read-case is the model's only conversation memory (the model
|
|
* messages carry no history). With 9 candidates it used to exceed 6 KB and
|
|
* clear `recent_turns` and `relevant_evidence_summary` first. Now candidates
|
|
* carry time / score / status / cluster_range only, the duplicate
|
|
* `candidate_summary.candidates` is gone, and over budget the candidate detail
|
|
* goes before the conversation.
|
|
*
|
|
* Fixture: a real local engine response for a public AA chart (9 candidates).
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
CASE_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
import {
|
|
AA_EVIDENCE_ROWS,
|
|
AA_RANGE,
|
|
buildGoldenLatest,
|
|
setFocusHandler,
|
|
stubGoldenFetch,
|
|
} from "./rectification-grounding-support.ts";
|
|
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
|
import {
|
|
TURN_DECISION_CANDIDATE_FIELDS,
|
|
TURN_DECISION_MAX_BYTES,
|
|
enforceTurnDecisionBudget,
|
|
turnDecisionByteLength,
|
|
} from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
|
|
const SIX_TURNS = [
|
|
["user", "2000 年拿了一个很重要的表演奖,那一年整个人的事业一下子起来了,很多人开始找我合作。"],
|
|
["assistant", "记下了:2000 年获奖。"],
|
|
["user", "2013 年做了一次预防性的大手术,前后休养了大半年,那年身体状态很差。"],
|
|
["assistant", "记下了:2013 年做手术。"],
|
|
["user", "2014 年 8 月结婚,是在法国办的婚礼,家里人都去了,算是那几年最大的一件事。"],
|
|
["assistant", "记下了:2014 年 8 月结婚。"],
|
|
].map(([role, text], index) => ({
|
|
id: `77777777-7777-4777-8777-77777777777${index}`,
|
|
role,
|
|
text,
|
|
status: "completed",
|
|
created_at: `2026-08-12T10:0${index}:00.000Z`,
|
|
completed_at: `2026-08-12T10:0${index}:05.000Z`,
|
|
}));
|
|
|
|
async function readCaseFor(turns: unknown[]) {
|
|
const { latest, compute } = await buildGoldenLatest();
|
|
const restore = stubGoldenFetch();
|
|
try {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({
|
|
evidence: AA_EVIDENCE_ROWS,
|
|
latestResult: latest,
|
|
turns: turns as never,
|
|
candidateRange: AA_RANGE as never,
|
|
}),
|
|
get_agentic_rectification_case_compute: () => compute,
|
|
set_agentic_rectification_conversation_focus: setFocusHandler,
|
|
}, { fallback: () => null });
|
|
const tools = createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
userMessage: "2014 年结婚",
|
|
accounting: accounting.client as never,
|
|
}) as unknown as Record<string, { execute(input: unknown): Promise<Record<string, unknown>> }>;
|
|
const readCase = await tools["rectification-read-case"].execute({ caseId: CASE_ID });
|
|
return { readCase, candidateCount: latest.candidates.length };
|
|
} finally {
|
|
restore();
|
|
}
|
|
}
|
|
|
|
test("red line 3: 9 candidates and six turns keep recent_turns and relevant_evidence_summary", async () => {
|
|
const { readCase, candidateCount } = await readCaseFor(SIX_TURNS);
|
|
assert.equal(candidateCount, 9);
|
|
assert.ok(turnDecisionByteLength(readCase) <= TURN_DECISION_MAX_BYTES);
|
|
assert.equal(readCase.truncated, undefined);
|
|
const turns = readCase.recent_turns as Array<{ role: string; text: string }>;
|
|
assert.equal(turns.length, 6);
|
|
assert.match(turns[4].text, /2014 年 8 月结婚/);
|
|
const evidence = readCase.relevant_evidence_summary as unknown[];
|
|
assert.equal(evidence.length, 3);
|
|
const inference = readCase.inference as { candidates: Array<Record<string, unknown>> };
|
|
assert.equal(inference.candidates.length, 9);
|
|
for (const candidate of inference.candidates) {
|
|
assert.deepEqual(Object.keys(candidate), [...TURN_DECISION_CANDIDATE_FIELDS]);
|
|
assert.match(String(candidate.time), /^\d{2}:\d{2}$/);
|
|
}
|
|
const summary = readCase.candidate_summary as Record<string, unknown>;
|
|
assert.equal("candidates" in summary, false);
|
|
const text = JSON.stringify(readCase);
|
|
assert.doesNotMatch(text, /cluster_intervals|window_offset_minutes|posterior_score|candidate_date/);
|
|
console.log(JSON.stringify({ scope: "BUG-1057 read-case size", bytes: turnDecisionByteLength(readCase) }));
|
|
});
|
|
|
|
test("red line 3: over budget, candidate detail goes before recent turns and evidence", () => {
|
|
const clock = (index: number) => `${String(4 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`;
|
|
const candidates = Array.from({ length: 200 }, (_, index) => ({
|
|
time: clock(index),
|
|
score: index === 7 ? 0.5 : 0.01,
|
|
status: index % 3 === 0 ? "eliminated" : "active",
|
|
cluster_range: [clock(index), clock(index)],
|
|
}));
|
|
const turns = Array.from({ length: 6 }, (_, index) => ({ role: index % 2 ? "assistant" : "user", text: `第 ${index} 句话`.repeat(10) }));
|
|
const evidence = Array.from({ length: 6 }, (_, index) => ({ domain: "career", summary: `事件 ${index}`.repeat(8) }));
|
|
const payload = {
|
|
projection: "turn_decision",
|
|
inference: { credible_range: ["05:00", "05:39"], candidates },
|
|
recent_turns: turns,
|
|
relevant_evidence_summary: evidence,
|
|
};
|
|
assert.ok(turnDecisionByteLength(payload) > TURN_DECISION_MAX_BYTES);
|
|
const trimmed = enforceTurnDecisionBudget(payload);
|
|
assert.ok(turnDecisionByteLength(trimmed) <= TURN_DECISION_MAX_BYTES);
|
|
assert.deepEqual(trimmed.recent_turns, turns);
|
|
assert.deepEqual(trimmed.relevant_evidence_summary, evidence);
|
|
assert.equal(trimmed.truncated, undefined);
|
|
const kept = (trimmed.inference as { candidates: Array<Record<string, unknown>>; candidates_omitted?: number });
|
|
assert.ok(kept.candidates.every((candidate) => !("cluster_range" in candidate)));
|
|
assert.ok(kept.candidates.length < 200);
|
|
assert.equal(kept.candidates[0]?.time, "04:07");
|
|
assert.ok(kept.candidates.every((candidate) => candidate.status === "active"));
|
|
assert.equal(kept.candidates_omitted, 200 - kept.candidates.length);
|
|
});
|
|
|
|
test("red line 3: first budget step only drops cluster ranges; every candidate and turn stays", () => {
|
|
const clock = (index: number) => `${String(4 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`;
|
|
const candidates = Array.from({ length: 90 }, (_, index) => ({
|
|
time: clock(index),
|
|
score: 0.01,
|
|
status: "active",
|
|
cluster_range: [clock(index), clock(index)],
|
|
}));
|
|
const turns = [{ role: "user", text: "2014 年 8 月结婚。" }];
|
|
const payload = { inference: { candidates }, recent_turns: turns, relevant_evidence_summary: [] };
|
|
assert.ok(turnDecisionByteLength(payload) > TURN_DECISION_MAX_BYTES);
|
|
const trimmed = enforceTurnDecisionBudget(payload);
|
|
const kept = trimmed.inference as { candidates: Array<Record<string, unknown>>; candidates_omitted?: number };
|
|
assert.equal(kept.candidates.length, 90);
|
|
assert.equal(kept.candidates_omitted, undefined);
|
|
assert.ok(kept.candidates.every((candidate) => !("cluster_range" in candidate)));
|
|
assert.deepEqual(trimmed.recent_turns, turns);
|
|
});
|