feat(rectification): the server writes the evidence recap — 「记下了 N 件事」 with the ledger list collapsed under it (BUG-1136)

- Evidence turns whose batch accepted items: the body is the server recap
  built from those items (two or fewer listed inline, more as a count);
  the model no longer restates them (both prompt sets updated).
- The case snapshot carries each assistant turn's recorded evidence from
  the ledger (rejected/superseded excluded); the message shows it in a
  collapsed <details> list when more than two.
- Chinese date labels read straight into the event phrase (2016年入学);
  ISO labels keep their space.
- Assertions updated with original/new/reason notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 10:40:29 +08:00
co-authored by Claude Opus 5.5
parent 408b22749e
commit 9d302e515c
21 changed files with 244 additions and 27 deletions
@@ -170,8 +170,10 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s
// 新:开场点六类、证据轮一句复述
// 原因:BUG-604 / BUG-606
assert.match(agent, /OPENING_COLLECT_DOMAINS\.join/);
assert.match(agent, /证据轮正文只写一句复述/);
assert.match(agent, /记下了:2016 年 9 月入学、2020 年 6 月毕业/);
// 原值: /证据轮正文只写一句复述/。新值: /证据轮的复述由服务器写/。原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)——复述改由服务器按入账条目写「记下了 N 件事」,模型不再逐条复述。
assert.match(agent, /证据轮的复述由服务器写/);
// 原值: /记下了:2016 年 9 月入学、2020 年 6 月毕业/ 作为「好」例。新值: 同句作为「坏」例(逐条复述是服务器的事)。原因: BUG-1136 D2。
assert.match(agent, /坏:「记下了:2016 年 9 月入学、2020 年 6 月毕业、2021 年换工作……」(逐条复述是服务器的事)/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.doesNotMatch(agent, /每轮正文 2-4 句/);
assert.doesNotMatch(agent, /不要举大学、工作、搬家的例子/);
@@ -443,7 +443,10 @@ test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect
"utf8",
);
assert.match(prompt, /case\.accepted_time 非空时,正文第一句要说明已按该时间采用/);
assert.match(prompt, /必须先用一句话承接用户本轮给出的事实(年份\+事件)/);
// 原值: /必须先用一句话承接用户本轮给出的事实(年份\+事件)/(模型写承接句)。
// 新值: /证据轮的复述由服务器写:「记下了 N 件事」/(承接由服务器按入账条目写)。
// 原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)——复述改由服务器写,模型不再逐条复述;采用后首句说明规则不变。
assert.match(prompt, /证据轮的复述由服务器写:「记下了 N 件事」/);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.match(voice, /不要预告选项/);
});
@@ -891,7 +891,8 @@ test("rectification Agent output stays natural and keeps tool execution silent",
// 旧:正文只做承接(2-4 句)→ 新:正文只做承接,证据轮一句复述
// 原因:BUG-606 每轮只留一句话,不再给 2-4 句额度
assert.match(agent, /正文只做承接,不提问、不复述题干、不预告选项/);
assert.match(agent, /证据轮正文只写一句复述/);
// 原值: /证据轮正文只写一句复述/。新值: /证据轮的复述由服务器写/。原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)。
assert.match(agent, /证据轮的复述由服务器写/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -214,7 +214,8 @@ test("BUG-1055: when the whitelist leaves no model body the batch recap stands i
}],
flipOn: "rectification-record-evidence-batch",
});
assert.equal(persisted, `记下了:2016 年 9 月 去北京工作。${RANGE_SENTENCE}`);
// 原值: 中文日期标签与事件之间有空格(2016 年 9 月 去北京工作)。新值: 2016 年 9 月去北京工作。原因: BUG-1136——复述每轮由服务器写,中文日期标签直接接事件;ISO 标签保留空格。
assert.equal(persisted, `记下了:2016 年 9 月去北京工作。${RANGE_SENTENCE}`);
});
test("BUG-1055: 这次没有重新比较 survives a multi-sentence body and replaces the range sentence", async () => {
@@ -23,7 +23,8 @@ test("host fallback recap uses only batch return lines", () => {
{ display_date_label: "2020年6月", event_phrase: "毕业" },
],
}),
"记下了:2016年9月 入学、2020年6月 毕业。",
// 原值: "记下了:2016年9月 入学、2020年6月 毕业。"。新值: 中文日期标签与事件之间不留空格。原因: BUG-1136——复述每轮都由服务器写,「2016年9月 入学」读着别扭;ISO 标签(2016-09 入学)保留空格。
"记下了:2016年9月入学、2020年6月毕业。",
);
assert.equal(
composeHostFallbackNarration({
@@ -49,7 +50,8 @@ test("host fallback recap uses only batch return lines", () => {
{ outcome: "rejected", display_date_label: "1999年", event_phrase: "不该出现" },
],
}),
"记下了:2018年7月 入职。",
// 原值: "记下了:2018年7月 入职。"。新值/原因同上(BUG-1136)。
"记下了:2018年7月入职。",
);
});
@@ -77,3 +77,60 @@ test("a settled consultation reply keeps its 「已完成 N 步」 receipt (BUG-
}));
assert.match(html, /已完成 1 步/);
});
import { evidenceTurnRecap } from "../src/lib/rectification-agentic/user-copy.ts";
import { recordedEvidenceByTurn } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { messagesFromTurns } from "../src/lib/rectification-chat-messages.ts";
test("the server recap lists two or fewer items and counts more (BUG-1136)", () => {
assert.equal(evidenceTurnRecap([]), null);
assert.equal(evidenceTurnRecap(["2016年 入学"]), "记下了:2016年 入学。");
assert.equal(evidenceTurnRecap(["2016年 入学", "2020年 毕业"]), "记下了:2016年 入学、2020年 毕业。");
assert.equal(evidenceTurnRecap(Array.from({ length: 12 }, (_, i) => `${2000 + i}年 事${i}`)), "记下了 12 件事。");
});
test("recorded items come from the ledger by turn, without rejected or superseded rows (BUG-1136)", () => {
const row = (sourceTurnId: string, summary: string, status = "confirmed", datePrecision = "year", occurredFrom = "2016-01-01") => ({
sourceTurnId, status, datePrecision, occurredFrom, occurredTo: null, summary,
});
const byTurn = recordedEvidenceByTurn([
row("t2", "上大学"),
row("t2", "毕业", "pending_confirmation", "month", "2020-06-01"),
row("t2", "被拒的", "rejected"),
row("t2", "被替换的", "superseded"),
row("t4", "换工作", "confirmed", "day", "2021-03-05"),
]);
assert.deepEqual(byTurn.get("t2"), ["2016年上大学", "2020-06 毕业"]);
assert.deepEqual(byTurn.get("t4"), ["2021-03-05 换工作"]);
});
function settledEntry(message: RenderMessage): string {
return renderToString(createElement(RectificationMessageEntry, {
message, busy: false, readonly: false, currentQuestionFocusId: null, interactive: true,
liveChoiceCard: null, choiceNonce: 0, savedTime: null, copied: false, feedback: undefined, actionsRef,
}));
}
test("twelve recorded items: one body line and the twelve items collapsed under it (BUG-1136)", () => {
const items = Array.from({ length: 12 }, (_, i) => `${2000 + i}年 虚构事件${i + 1}`);
const [message] = messagesFromTurns([
{ id: "t2", role: "assistant", text: "记下了 12 件事。", status: "completed", recorded_evidence: items },
]);
assert.deepEqual(message!.recordedEvidence, items);
const html = settledEntry(message!);
assert.match(html, /记下了 12 件事。/);
assert.match(html, /<details class="rectification-recorded-evidence">/);
assert.match(html, /看记下的 12 件/);
assert.equal(html.split("<li").length - 1, 12);
for (const item of items) assert.ok(html.includes(item), item);
assert.doesNotMatch(html, /<details[^>]* open/);
});
test("two recorded items stay inline in the body with no list (BUG-1136)", () => {
const [message] = messagesFromTurns([
{ id: "t2", role: "assistant", text: "记下了:2016年 入学、2020年 毕业。", status: "completed", recorded_evidence: ["2016年 入学", "2020年 毕业"] },
]);
const html = settledEntry(message!);
assert.match(html, /记下了:2016年 入学、2020年 毕业。/);
assert.doesNotMatch(html, /rectification-recorded-evidence/);
});
@@ -271,7 +271,8 @@ test("empty body after a completed batch still uses BUG-633 host fallback", asyn
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2018年3月 欠债。");
// 原值: 中文日期标签与事件之间有空格(记下了:2018年3月 欠债。)。新值: 记下了:2018年3月欠债。。原因: BUG-1136——复述每轮由服务器写,中文日期标签直接接事件;ISO 标签保留空格。
assert.equal(result.answerText, "记下了:2018年3月欠债。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
+41 -3
View File
@@ -80,7 +80,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
// 旧:每轮正文 2-4 句 → 新:证据轮正文只写一句复述 → BUG-606 决策 3
assert.match(prompt, /证据轮正文只写一句复述/);
// 原值: /证据轮正文只写一句复述/。新值: /证据轮的复述由服务器写/。原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)。
assert.match(prompt, /证据轮的复述由服务器写/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /职业题只问平时做什么/);
assert.match(prompt, /焦点 domain 是 career 不是 occupation/);
@@ -944,11 +945,14 @@ test("evidence persist keeps the first sentence when the model writes three", as
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
// 原值: /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/(保留模型第一句)。
// 新值: 等于服务器按本轮入账条目写的复述「记下了:2016 年 9 月入学、2020 年 6 月毕业。」(整句相等,不再只匹配开头)。
// 原因: BUG-1136(TASK-rectification-message-cleanup-20261001 D2)——批次有入账条目时复述是服务器的,两件以内直接列出。
assert.equal(result.answerText, "记下了:2016 年 9 月入学、2020 年 6 月毕业。");
assert.doesNotMatch(result.answerText, /很有帮助|很有价值|很有分量|特别有用/);
assert.doesNotMatch(result.answerText, /接下来我们继续/);
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.match(String(finalize?.args.p_assistant_message ?? ""), /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.equal(String(finalize?.args.p_assistant_message ?? ""), "记下了:2016 年 9 月入学、2020 年 6 月毕业。");
assert.doesNotMatch(String(finalize?.args.p_assistant_message ?? ""), /很有帮助|特别有用/);
});
@@ -1061,3 +1065,37 @@ test("delivery persist keeps three sentences when the model writes four", async
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(String(finalize?.args.p_assistant_message ?? "").split(/(?<=。)/).filter(Boolean).length, 3);
});
test("an evidence turn that records twelve items is written 「记下了 12 件事。」 by the server (BUG-1136)", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({ intent: "collect_method_evidence" }),
}),
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const recaps = Array.from({ length: 12 }, (_, index) => ({ display_date_label: `${2000 + index}年`, event_phrase: `虚构事件${index + 1}` }));
const { options } = runOptions({
action: "evidence",
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-record-evidence-batch", result: { accepted_recaps: recaps } }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: `记下了:${recaps.map((r) => `${r.display_date_label} ${r.event_phrase}`).join("、")}。` }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了 12 件事。");
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(String(finalize?.args.p_assistant_message ?? ""), "记下了 12 件事。");
});
@@ -896,7 +896,8 @@ test("host fallback after a completed batch recaps the returned events", async (
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2016年9月 入学、2020年6月 毕业。");
// 原值: 中文日期标签与事件之间有空格(记下了:2016年9月 入学、2020年6月 毕业。)。新值: 记下了:2016年9月入学、2020年6月毕业。。原因: BUG-1136——复述每轮由服务器写,中文日期标签直接接事件;ISO 标签保留空格。
assert.equal(result.answerText, "记下了:2016年9月入学、2020年6月毕业。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
@@ -965,7 +966,8 @@ test("length finish after a completed batch with no text uses the host fallback"
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2018年7月 入职。");
// 原值: 中文日期标签与事件之间有空格(记下了:2018年7月 入职。)。新值: 记下了:2018年7月入职。。原因: BUG-1136——复述每轮由服务器写,中文日期标签直接接事件;ISO 标签保留空格。
assert.equal(result.answerText, "记下了:2018年7月入职。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });