fix(web): keep rectification interview planning out of the spoken answer
Thinking is disabled, so Chinese process talk after tools arrives as text-delta. Widen the leak-net so tool-label echoes and internal field names stay out of answer.delta, and keep the user-facing follow-up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+17
-1
@@ -5415,7 +5415,7 @@
|
||||
- 修复:保持 `thinking: disabled` 与 16384 正文预算。对已加载 Case 的 `text-delta` 按段落拆成过程自述与口语结论:自述进 `thinking.delta`,结论进 `answer.delta` 并作为落盘正文。界面与咨询对齐:折叠「思考」(13px 次要色,有正文后默认收起)和完整「回复」。
|
||||
- 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/chat-stream-layout.test.ts`;`tsc --noEmit` 通过。口语/思考拆分的 `emit` 类型必须与 `RunV9AgentTurnInput.emit` 同为 `Promise<void> | void`,否则 Docker `next build` 会在 publish 阶段失败。
|
||||
- 防复发:纠正组答不得在不拆开正文预算的情况下重开 provider thinking 来充当思考 UI。`answer.delta` 不得承载 skill/schema/batch 过程自述。思考与回复必须分层渲染,不得共用同一段正文样式。
|
||||
- 相关记录:BUG-345、BUG-354、BUG-356
|
||||
- 相关记录:BUG-345、BUG-354、BUG-356、BUG-373
|
||||
- 复发自:BUG-345(关 thinking 后自述改走正文)、BUG-354(纠正与咨询一并关闭 provider thinking)
|
||||
- 修复版本:`f03a2706`
|
||||
|
||||
@@ -5531,6 +5531,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:未修复
|
||||
|
||||
## BUG-373 | 生时纠正把中文过程自述当成回答,工具完成标签和 occupation_note 漏进正文
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-25
|
||||
- 最近更新:2026-08-25
|
||||
- 影响面:`POST /api/rectification/agent`、`splitRectificationSpokenAndThinking`、已落盘 Turn 水合
|
||||
- 用户现象:工具进度(读取校正记录、整理证据、比较候选)之后,回复气泡里先出现大段自我规划:方法覆盖、occupation_note、open_question、还不能出牌、本轮对照了哪些分盘,最后才是对用户的一句追问。看起来像思考过程被当成推理结果。
|
||||
- 触发条件:provider thinking 关闭;模型在工具完成后的最终 `text-delta` 里先写中文过程自述,再写对用户的问题。
|
||||
- 根因:BUG-368 维持 `thinking: disabled`。漏检网只认旧的 `datePrecision` / `用户提到` / `让我调用 batch` 句式。新的规划句复制了公开工具完成标签和服务器字段名,整段被当成口语并落盘。末尾「本轮对照了…」与界面已有的技法句重复。
|
||||
- 修复:漏检网补上工具完成标签回声、occupation_note / method_followup_plan / open_question / not_separated 等内部字段,以及方法覆盖、不可分宽度、让我继续访谈等规划句。混有过程句和对用户提问时只保留口语。不改已哈希 Skill `10.0.11`。
|
||||
- 验证:`frontend/tests/rectification-spoken-answer.test.ts` 锁定规划段进思考、入职追问留在口语;`frontend/tests/rectification-v9-stream.test.ts` 锁定最终 `text-delta` 只把口语发成 `answer.delta`。
|
||||
- 防复发:禁止把工具完成标签、内部字段名或「本轮对照了」写进 `answer.delta`。thinking 关闭时漏检网必须覆盖中文规划句,不能只认英文 / batch / datePrecision。对用户说话的追问不得被一起丢掉。
|
||||
- 相关记录:BUG-357、BUG-359、BUG-368、BUG-360
|
||||
- 复发自:BUG-357(关 thinking 后用正则从正文切思考;句式换了就漏)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-372 | 生时纠正同一轮重复工具调用把已成功写入打成失败
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -6,20 +6,40 @@
|
||||
* - drops process-only `text-delta` leaks so they cannot complete as the reply
|
||||
*/
|
||||
|
||||
import {
|
||||
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
|
||||
RECTIFICATION_TOOL_DONE_LABELS,
|
||||
} from "../../rectification-activity-labels.ts";
|
||||
|
||||
const CJK_RE = /[\u4e00-\u9fff]/;
|
||||
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label)\b/;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch|需要用)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo/;
|
||||
const THIRD_PERSON_USER_RE = /用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
|
||||
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast|deferred_followup)\b/;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用)|我(?:决定|倾向|batch|需要用|需要继续|继续收集|继续访谈|自然地|用自然语言)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|还不能出牌|不得出牌|本轮对照了|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup/;
|
||||
const THIRD_PERSON_USER_RE = /^用户|用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
|
||||
|
||||
const ACTIVITY_ECHO_LABELS = [
|
||||
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
|
||||
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
|
||||
];
|
||||
|
||||
export type SplitSpokenAndThinking = Readonly<{
|
||||
thinking: string;
|
||||
spoken: string;
|
||||
}>;
|
||||
|
||||
function isActivityEcho(text: string): boolean {
|
||||
const trimmed = text.trim().replace(/[。.…]+$/u, "");
|
||||
if (!trimmed) return false;
|
||||
return ACTIVITY_ECHO_LABELS.some((label) => {
|
||||
const bare = label.replace(/[。.…]+$/u, "");
|
||||
return trimmed === bare || trimmed === `正在${bare}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function isRectificationProcessNarration(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true;
|
||||
if (isActivityEcho(trimmed)) return true;
|
||||
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
|
||||
if (PROCESS_ZH_RE.test(trimmed)) return true;
|
||||
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
|
||||
@@ -31,17 +51,31 @@ export function nextStableChannelDelta(published: string, next: string): string
|
||||
return next.slice(published.length);
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
const parts = text.split(/(?<=[。!?])\s*/u).map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
function splitUnits(text: string): string[] {
|
||||
const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length > 1) return lines.flatMap(splitSentences);
|
||||
return splitSentences(text);
|
||||
}
|
||||
|
||||
function isMixed(units: readonly string[]): boolean {
|
||||
return units.length > 1
|
||||
&& units.some(isRectificationProcessNarration)
|
||||
&& units.some((unit) => !isRectificationProcessNarration(unit));
|
||||
}
|
||||
|
||||
function splitParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.flatMap((block) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return [];
|
||||
const lines = trimmed.split(/\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length <= 1) return [trimmed];
|
||||
const hasProcess = lines.some(isRectificationProcessNarration);
|
||||
const hasSpoken = lines.some((line) => !isRectificationProcessNarration(line));
|
||||
return hasProcess && hasSpoken ? lines : [trimmed];
|
||||
const units = splitUnits(trimmed);
|
||||
return isMixed(units) ? units : [trimmed];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,30 @@ test("finalizing does not promote process-only self-talk into the spoken answer"
|
||||
});
|
||||
});
|
||||
|
||||
test("Chinese interview planning after tools stays out of the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"读取校正记录",
|
||||
"整理多条事件证据",
|
||||
"比较候选时间",
|
||||
"职业类型已经记录,并重新计算了候选。虽然职业领域现在有信息了,但账户里仍然只有 3 件带评分日期的事件,还不足以拉开候选范围区间(当前不可分宽度 8 分钟)。",
|
||||
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分),但还需要更多带日期的经历来区分候选。当前还应继续收集事件。",
|
||||
"我用自然语言再问一件带大概年份的经历。根据 method_followup_plan,职业已经覆盖。当前 open_question 为空,next_action 为空。",
|
||||
"候选区分尚未充分(not_separated),应继续收集。",
|
||||
"让我继续访谈,问一件能帮助区分候选的职业前事。",
|
||||
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
|
||||
].join("\n\n");
|
||||
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**又是什么时候离开的?只要个大概年份就行。";
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
|
||||
assert.equal(split.spoken, spoken);
|
||||
assert.match(split.thinking, /occupation_note/);
|
||||
assert.match(split.thinking, /读取校正记录/);
|
||||
assert.match(split.thinking, /本轮对照了/);
|
||||
assert.doesNotMatch(split.thinking, /你大概是在哪一年入职/);
|
||||
assert.equal(isRectificationProcessNarration("读取校正记录"), true);
|
||||
assert.equal(isRectificationProcessNarration(spoken), false);
|
||||
});
|
||||
|
||||
test("leaked process text on the answer channel is not mixed into native thinking", () => {
|
||||
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
|
||||
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
|
||||
|
||||
@@ -1301,6 +1301,43 @@ test("does not publish intermediate tool-step text as answer.delta", async () =>
|
||||
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
|
||||
});
|
||||
|
||||
test("Chinese interview planning in the terminal text-delta is not the spoken answer", async () => {
|
||||
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**只要个大概年份就行。";
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
|
||||
chunk("text-delta", {
|
||||
text: [
|
||||
"读取校正记录",
|
||||
"比较候选时间",
|
||||
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分)。",
|
||||
"让我继续访谈,问一件能帮助区分候选的职业前事。",
|
||||
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
|
||||
spoken,
|
||||
].join("\n\n"),
|
||||
}),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, spoken);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: spoken }],
|
||||
);
|
||||
const publicText = JSON.stringify(emitted);
|
||||
assert.doesNotMatch(publicText, /occupation_note/);
|
||||
assert.doesNotMatch(publicText, /让我继续访谈/);
|
||||
assert.doesNotMatch(publicText, /本轮对照了/);
|
||||
});
|
||||
|
||||
test("does not reset the whole attempt after duplicate_focus", async () => {
|
||||
let buildCount = 0;
|
||||
const accounting = fakeAccounting({
|
||||
|
||||
Reference in New Issue
Block a user