feat(rectification): server-written lines in the same warm voice (BUG-1258)
Independent Staging Quality Gate / validate (push) Successful in 13m22s
Independent Staging Quality Gate / publish (push) Successful in 3m42s

About 30 fixed rectification lines lose the receipt tone: no 「已记录。」
openings (收到 when the range holds, 好 when it moves), 麻烦 instead of 请,
时间 instead of 候选 in what the user reads, and no 「新建对话」 after
adoption. Numbers, clocks, ranges and the honesty boundaries are unchanged.
CHOICE_ACK_RE also recognises the new acknowledgements, so a model sentence
that repeats one is still dropped; the delivery guard matches the delivery
sentence by containment and does not depend on the opening.

Full suite 4973 / fail 24, identical to 61d6f248; build keeps / Static.

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-07 11:39:34 +08:00
co-authored by Claude Opus 5.5
parent 61d6f248e4
commit 6ec8efd5d2
28 changed files with 199 additions and 78 deletions
@@ -653,11 +653,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
placeholder={resultNotice
? "当前结果仅供查看,暂不能选择或采用。"
: readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
? "这次校正已经结束,只能查看;想再校正一次,重新开始就好。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
? "回答上面的问题就好…"
: !snapshotFailure && questionGap === "collect_waiting"
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
: questionGap === "delivered"
@@ -188,7 +188,7 @@ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): stri
/** BUG-1146: the adopt message that carries the first post-adopt check (VOICE 采用后一行). */
export function postAdoptCheckLead(savedTime: string | null | undefined): string {
const clock = /^\d{2}:\d{2}/.exec(savedTime?.trim() ?? "")?.[0];
return clock ? `已采用 ${clock}。再核对一件过去的事。` : "再核对一件过去的事。";
return clock ? `已采用 ${clock}。我们再核对一件过去的事。` : "再核对一件过去的事。";
}
export function rangeDeliveryCollectClosed(candidateCount?: number): string {
@@ -202,27 +202,27 @@ export const RECTIFICATION_USER_COPY = {
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
classifierUnavailableReply: "这边没接上,把刚才那句再发一次就行。",
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
classifierUnavailableReply: "刚才没接上,麻烦把那句再发一次。",
declaredWindowLockedReply: "找的范围是开始时按你的资料定的,中途不改。想换个时间段,先在资料里改出生时间,再重新开始一次校正就好。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
adoptCue: "你说的这些经历我都对照过了,下面是这次的结果。",
hostNarrationFallback: "我先按现在知道的,接着往下缩小范围。",
collectHandoff: "接下来我们继续。",
evidenceNotRecorded: "这件我还没记上。请再说一次大概年月和发生的事。",
collectDeclinedAck: "记下了,这条按没有发生过记。",
collectSkippedAck: "记下了,这题先放着,后面换个问法再问一次。",
evidenceNotRecorded: "这件我还没记上,麻烦再说一次大概年月和发生了什么。",
collectDeclinedAck: "好,记下了:这件事没有发生过。",
collectSkippedAck: "好,这题先放一放,后面换个问法再问你。",
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
insufficientEventsGate: "再来一件记得大概年月的事就能开始筛。",
insufficientDomainsGate: "再来一件不是同一类的、记得大概年月的事就能开始筛。",
uncertaintyStop: "前面几道题大多说不好,这很正常;再问下去也分不出来,我们先停在这里。",
tiedFirstStop: "这几个时间打成了平手,再问也分不出来了。",
probePoolExhaustedStop: "能帮着区分的问题都问完了,我先按现在知道的给你结果。",
postAdoptVerifyDone: "核对就到这里。之后的对话都按你采用的时间来看;哪天觉得对不上,随时可以改。",
insufficientEventsGate: "再说一件记得大概年月的事,就能开始比了。",
insufficientDomainsGate: "再说一件不同类的、记得大概年月的事,就能开始比了。",
lowDateQualityGate: "两件事的日期还没对清。",
noCandidatesGate: "当前还排不出可比较的候选时间。",
noCandidatesGate: "现在还排不出可以比较的时间。",
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
rescoreSkipped: "这次没有重新比较,稍后再比一次。",
compareFailedRetry: "这次比较没跑成,你说下一句的时候我会再试一次。",
rescoreSkipped: "这次没有重新比较,等下会再比一次。",
/** BUG-1178: an event dated after today is not recorded or scored. */
futureDatedEvent: "有一件事的日期在今天之后,这件先不算;如果年份打错了,直接告诉我正确的年份就行。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
@@ -354,8 +354,8 @@ export function postAdoptVerifyDoneCopy(
if (hadVerifyQuestions) return RECTIFICATION_USER_COPY.postAdoptVerifyDone;
const clock = time?.trim().slice(0, 5) ?? "";
return clock
? `已采用 ${clock},新建对话即按这个时间排盘。`
: "已采用当前时间,新建对话即按这个时间排盘。";
? `已采用 ${clock},之后的对话都按这个时间来看。`
: "已采用当前时间,之后的对话都按这个时间来看。";
}
export function withCompareFailedRetryNotice(body: string): string {
@@ -21,11 +21,11 @@ export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的过去的事了,之后的对话都按这个时间来看;觉得对不上,随时可以改。」,不得写「会拿……核对」。
只能使用输入事实里出现的时间和年份;输入里没有的数字一律不要写。
不要写「相对支持度」「概率」「置信度」。两个时间分不开时说「这两个时间按现有信息分不开」,不要念分数。
只有 stop_facts 里真有「不再问」时才可以那么说。
不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
不要出现「确认」「精确」这两个词,不得再提问,不要写「你说的这些经历我都对照过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
export type AdoptNarrationOutcome =
| "agent"
@@ -235,7 +235,7 @@ export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
if (facts.post_adopt_verification.length === 0) {
return "采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。";
return "采用后没有还能核对的过去的事了,之后的对话都按这个时间来看;觉得对不上,随时可以改。";
}
const labels = facts.post_adopt_verification.map((item) => (
item.year_label ? `${item.year_label}${item.domain}` : item.domain
@@ -111,31 +111,31 @@ export function composeChoiceNarration(input: {
segmentProduct?: boolean;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
return `好,这次校正先到这里。下面是目前最可信的时间范围,和可以先拿来用的那个时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.optionId === "skip_probe") {
return "已记录。这题先不计分,换一件事问。";
return "好,这题先不算分,我们换一件事。";
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
return "好,这题先不算分,我们换一件事。";
}
if (!input.scoring) {
return "已记录你的选择。这是盘外核对,不会改候选分数。";
return "收到。这一题只是帮着核对,不会改前面的比较结果。";
}
if (input.appliedInference) {
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
if (range && range !== "范围没变") {
return `已记录,${range}。`;
return `好,${range}。`;
}
// BUG-1086: an interior cluster left the range while the hull stayed put
// (or the range split into segments). That is a change, never 「范围没变」.
const excluded = explainExcludedRanges(input.excludedRanges ?? []);
if (excluded) return `已记录,${excluded}。`;
if (excluded) return `好,${excluded}。`;
const movement = input.segmentProduct ? "" : explainScoreMovement(input.deltasByCluster ?? []);
if (movement) return `已记录,范围没变;${movement}。`;
return "已记录,范围没变。";
if (movement) return `收到,这一题没有让范围变窄;${movement}。`;
return "收到,这一题没有让范围变窄。";
}
return "已记录你的选择。";
return "收到。";
}
export function shouldContinueAfterStructuredChoice(
@@ -72,7 +72,7 @@ export function deliveryCarrierNarration(input: {
/**
* True when the latest assistant text carries the stored delivery sentence.
* The answered choice persists 「已记录,…」 + a blank line + that sentence,
* The answered choice persists 「收到,…」 (「已记录,…」 before BUG-1258) + a blank line + that sentence,
* so this is a containment check, not equality (BUG-1241 fix sheet).
* Whitespace is ignored. A needle shorter than 8 characters does not match.
*/
@@ -107,7 +107,7 @@ export function readoptOffer(
export function postAdoptCheckVerdict(input: PostAdoptVerdictInput): string | null {
const clock = input.acceptedTime?.trim().slice(0, 5) ?? "";
if (!clock || !input.answerClass) return null;
if (input.answerClass === "unsure") return "这件事记不清,不算。";
if (input.answerClass === "unsure") return "这件记不清也没关系,不算进去。";
if (!input.probe) return null;
const candidate = candidateForAdoptedMinute(clock, input.candidates);
const outcome = input.probe.expected_outcomes.find((row) => row.answer_class === input.answerClass) ?? null;
@@ -123,7 +123,7 @@ export function postAdoptCheckVerdict(input: PostAdoptVerdictInput): string | nu
return fits
? `这件事和 ${clock} 对得上。`
: clashes
? `这件事和 ${clock} 对不上;如果别的经历也对不上,可以回到上面的卡片改选。`
? `这件事和 ${clock} 对不上。要是别的经历也对不上,可以回到上面的卡片换一个时间。`
: `这件事分不出 ${clock} 和别的时间。`;
}
const fit = fits
@@ -165,7 +165,7 @@ const USER_COPY: Readonly<Record<string, string>> = {
provider_error: "上游模型连接失败,状态已记录。",
run_failed: "上游模型连接失败,状态已记录。",
empty_stream: "本轮没有生成可展示的回复,状态已记录。",
evidence_not_written: "这件我还没记上。请再说一次大概年月和发生的事。",
evidence_not_written: "这件我还没记上,麻烦再说一次大概年月和发生了什么。",
};
export function finishReasonFromErrorCode(errorCode: string | null | undefined): RectificationFinishReason {
@@ -139,7 +139,9 @@ export function overlayChoicePromptFromSpoken(
return clipped;
}
const CHOICE_ACK_RE = /已记录你的选择|已记下你刚才的选择|候选比较也随之更新|请看下方选项/;
// The server's own choice acknowledgements, old and new wording (BUG-1258):
// a model sentence that repeats one is dropped, the server already wrote it.
const CHOICE_ACK_RE = /已记录你的选择|已记下你刚才的选择|候选比较也随之更新|请看下方选项|这一题没有让范围变窄|这题先不算分|这一题只是帮着核对/;
export const CHOICE_CARD_CONTINUATION_ACK = "接下来看下面这一问。";
@@ -224,7 +224,7 @@ export async function runRectificationStructuredChoice(
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
: "收到。";
setMessages((current) => {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
@@ -252,7 +252,7 @@ export async function runRectificationStructuredChoice(
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
: "收到。";
onMessagesChange?.([
{ role: "assistant", text: narration },
]);