fix(rectification): delivery gate turn skips text the last reply already said (BUG-1153)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 8m58s

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 22:34:35 +08:00
co-authored by Claude Opus 5.5
parent d58df1b48b
commit 279650a684
6 changed files with 75 additions and 3 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-01 — 交付时盘型结论不再连说两遍
- 修复:答完最后一道题出交付卡时,「D1 上升…用了 N 道选择题」连出两条。上一版修好收尾话的保存后带出的重复,现在已说过就不再补写(BUG-1153)。Skill 版本不变;不改数据库结构。
## 2026-10-01 — 盘型口径的验证报告不再按分钟排名
- 交付卡「查看验证报告」在盘型口径下去掉「当前候选基本并列」「本命上升(该分钟)」和「排名 / 时间 / 相对支持」表,保留事件吻合、大运对照和技法审计;已有校正打开报告即生效(BUG-1151)。
+15
View File
@@ -15483,3 +15483,18 @@
- 复发自:无
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
## BUG-1153 | 交付时同一段盘型结论连出两条消息
- 状态:resolved(2026-10-01 Claude 直接执行;回归测试)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`v9/answer-choice.ts::persistExhaustionGateTurn`(`turn-exit.ts`、`agent-run-finish.ts`、`ensureNonTerminalTurnExit` 三处调用)。
- 用户现象:答完最后一道选择题出交付卡时,「已记录,范围收到 …」下面是一段「D1 上升… 用了 5 道选择题」,紧接着又是一条一模一样的「D1 上升… 用了 5 道选择题」。
- 触发条件:答题回复本身已带交付正文,随后的收尾补写(无轮次时的 gate 轮次)又写同一段。
- 根因:**本人 BUG-1149 引入的回归**。gate 轮次请求号此前不是 uuid,真库一直写不进去,重复被这个错误掩盖了;BUG-1149 修好请求号后它开始真的落库,与答题回复里的同一段重复。我在 BUG-1149 时判断「无轮次才补写,所以不会重复」,没有核对答题回复已经带了交付正文。
- 修复:`persistExhaustionGateTurn` 写之前读最近一条助手消息,若已包含同一段(去空白后包含)就不写,只保留交付标记;新增 `narrationAlreadyInLastAssistant`。
- 验证:`frontend/tests/rectification-post-adopt-card-20261001.test.ts`「delivery gate turn is not written when the last reply already says it」(已说过→不写;没说过→写一次;两种都打交付标记;去掉判断时实际写 1 次,红)。
- 防复发:补写类轮次一律先查最近一条助手消息;让「原本写不进去」的路径恢复时,要检查它与相邻消息是否重复。
- 相关记录:BUG-596(交付轮连写两条)、BUG-1149。
- 复发自:BUG-596(同一症状:交付连出两条;当时的 60 秒守卫只管无用户消息的二次运行,不管补写轮次)。
- 修复版本:`codex/rectification-post-adopt-verify-20261001`。
@@ -59,3 +59,7 @@ Python 快速门:本单不改 `scripts/**`、`tests/**`,未跑。`test:db`
## 第三轮:验证报告去分钟排名(BUG-1151,产品选 A)
`buildSkillVerificationPacket` 加 `segmentProduct`;盘型口径不出并列/代表分钟行、「本命上升(该分钟)」、候选窗排名表。golden 测试加断言,去开关 15 条红。
## 第四轮:交付正文重复(BUG-1153,我在 BUG-1149 引入)
真机:交付时「D1 上升…用了 5 道选择题」连出两条。gate 收尾轮次在 BUG-1149 后首次真的落库,与答题回复里的同一段重复。修:写前比对最近一条助手消息。真机另报两点:①采用消息里题干「出现两次」——服务端与单测均确认正文已剥题干,粘贴里多出的一遍来自选项卡给读屏用的隐藏标题(sr-only legend)被鼠标选中复制,屏幕上只显示一次;②答一道核对题就结束——按现行规则:采用后核对题至多 2 道,且只取采用前没问过、不在用户已报年份/领域、未拒答的探针,该案只剩 1 道,属设计,已向产品提出改进选项。
+1 -1
View File
@@ -265,7 +265,7 @@
| `TASK-rectification-chart-tier-caveats-20261001.md` | — | **报告与聊天标注判断不了的分盘**:采用后读 `active_birth_provenance`(segment-v1)→ 结果 `segment_summary` 档位,blocked/indistinct 的 D9/D10 在聊天 answer_policy 与一句说明、报告章节顶部提示 + 主题降级;旧资料逐字节不变;不改库、不改 consult/route.ts | 待领取 | BUG-1138~1139 |
| `TASK-rectification-angle-timing-research-20261001.md` | `PROGRESS-rectification-angle-timing-research-20261001.md` | **角度类时间技法研究(离线)**:行运土木罗压本命上升 / 天顶度数、次限推运角、太阳弧角(1° ≈ 4 分钟出生时间),77 例 v5;乱序日期对照 ≥ 50 次必做、留一法、不得挤出真值;显著才叠加到六题后验比头段命中与区间宽度 | 已实现待验收(0/9 预登记检验显著,BUG-1141 closed_by_design;分支 codex/rectification-angle-timing-research-20261001,未推送) | BUG-1141 |
| (直接执行,无任务书) | `PROGRESS-rectification-segment-order-20261001.md` | **健康区分题写入失败(BUG-1143)+ 按盘型挑题默认开启(BUG-1144)**:区分类焦点把 `health_pressure` 原样写入、违反表约束、写入被吞后提前交付(BUG-672 同族);修后 77 例真库回放平均问题数 ±10 2.8→4.6、±30 3.6→5.4,D10 头段 ±30 39→52;按段选题 ≤61 分钟默认开,±30 D9 46→51、D10 52→55,真值段 76/76 | 已验收(Claude 10-01 直接执行) | 分支 codex/rectification-segment-order-20261001 |
| (直接执行,无任务书) | `PROGRESS-rectification-post-adopt-card-20261001.md` | **采用后核对题无按钮(BUG-1146)+ 盘型口径回复念分钟排名(BUG-1147)**:keep 分支丢探针身份致 GET 空卡(BUG-498 复发);采用接口不写轮次、只读运行被 60 秒交付窗口挡掉,题挂到上一题回复下;改为采用接口写「已采用 HH:MM。再核对一件过去的事。」轮次并挂题。盘型口径下去掉「领先 / 落后」、交付旁白不再说「分不开」、边界句只说一次 | 已验收(Claude 10-01 直接执行);真机回报后补修 BUG-1149(请求号非 uuid 被真库拒写)、BUG-1150(核对题作答 stale_probe),本机真库重放通过;BUG-1151 盘型报告去分钟排名(产品选 A);真机清单 `docs/testing/rectification-post-adopt-card-20261001.md` | 分支 codex/rectification-post-adopt-verify-20261001 |
| (直接执行,无任务书) | `PROGRESS-rectification-post-adopt-card-20261001.md` | **采用后核对题无按钮(BUG-1146)+ 盘型口径回复念分钟排名(BUG-1147)**:keep 分支丢探针身份致 GET 空卡(BUG-498 复发);采用接口不写轮次、只读运行被 60 秒交付窗口挡掉,题挂到上一题回复下;改为采用接口写「已采用 HH:MM。再核对一件过去的事。」轮次并挂题。盘型口径下去掉「领先 / 落后」、交付旁白不再说「分不开」、边界句只说一次 | 已验收(Claude 10-01 直接执行);真机回报后补修 BUG-1149(请求号非 uuid 被真库拒写)、BUG-1150(核对题作答 stale_probe),本机真库重放通过;BUG-1151 盘型报告去分钟排名(产品选 A);BUG-1153 交付正文连出两条(BUG-1149 引入的回归);真机清单 `docs/testing/rectification-post-adopt-card-20261001.md` | 分支 codex/rectification-post-adopt-verify-20261001 |
| `TASK-rectification-typed-event-scoring-research-20260929.md` | `PROGRESS-rectification-typed-event-research-20260929.md` | **打字经历按选择题规则计分(离线研究,不上线)**:计分通道不对称 + 已入账年份挡题;R0 学业质量题措辞 / 年精度显示成 1 月(冻结文件,需重新冻结) | 已验收关单(Claude 10-01:三类技法 9 项检验 0 显著,真值平均排位 0.49~0.59;Claude 补正对照——埋入信号排位 0.077、挪年 0.502——流程有效;BUG-1141 closed_by_design) | BUG-1088、1089 |
| `TASK-report-reader-polish-20260929.md` | `PROGRESS-report-reader-polish-20260929.md` | **报告页打磨**:生成入口挪进页面主体(删标题栏按钮)、详情页到底部按钮(懒渲染一次到底)、导出按钮带文字、目录一级/二级分层、去掉「字段」与 RL/NL 缩写表头、状态列同义重复去重、报告表格淡底色、分块导出显示真实文件大小 | Claude 直接执行并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +7 B、快速门 Python 1000 passed),已部署 staging `d7772011`,真机欠 | BUG-1092~1094 |
| `TASK-report-english-edition-20260929.md` | `PROGRESS-report-english-edition-20260929.md` | **报告中英两版**:同一次引擎计算渲染 zh/en 两遍(不用模型翻译),瑜伽库 477 条补英文、模板与前端表头英文化;中文逐字节不变、英文零汉字、两版数字序列一致;阅读页 `?lang=en` 切换,导出当前语言 + 「问 AI 建议导出英文版」提示;旧报告不补英文 | Claude 直接执行(含两个 fork 子代理)并自验(tsc 0、lint 0 error、全量 fail 与基线同 24 条、`/` Static、gzip +0.06%、Python 定向全绿),已部署 staging `d7772011`,真机欠 | — |
@@ -209,6 +209,11 @@ export async function persistExhaustionGateTurn(input: {
if (input.resultId) {
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
}
// BUG-1153: the answered choice's own reply often already carries this delivery text
// (「已记录,…」 + the same sentences). Once BUG-1149 let the gate turn persist, the same
// narration showed twice. The gate only fills a gap; it never repeats the last reply.
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (narrationAlreadyInLastAssistant(input.hostNarration, dossier)) return;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -1288,6 +1293,24 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
export function narrationAlreadyInLastAssistant(
narration: string,
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
): boolean {
const squash = (value: string) => value.replace(/\s+/g, "");
const needle = squash(narration);
if (needle.length < 8) return false;
const turns = dossier.turns ?? [];
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn?.role !== "assistant") continue;
const text = typeof turn.text === "string" ? squash(turn.text) : "";
if (!text) continue;
return text.includes(needle);
}
return false;
}
function stemAlreadyInLastAssistant(
stem: string,
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
@@ -4,12 +4,12 @@ import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { expectedAnswerSchemaFor, persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, narrationAlreadyInLastAssistant, persistExhaustionGateTurn, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { alreadyDelivered, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { deliveryTurnNarration, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
import { fakeAccounting } from "./rectification-v9-test-support.ts";
import { CASE_DOSSIER_RPC, dossierFixture, fakeAccounting } from "./rectification-v9-test-support.ts";
// Real device 2026-10-01 (BUG-1146): after 采用 the first post-adopt check persisted, but the
// case GET rebuilt no card, so the stem showed without buttons and refresh did not help. Fixtures
@@ -226,3 +226,29 @@ test("a scoring post-adopt check carries the state's probe id", () => {
const bare = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, null, followup);
assert.equal(bare?.probe_id, "probe:career.2018.dasha_activation", "no state: the named key, as before");
});
// BUG-1153: once the gate turn could persist (BUG-1149), the delivery text the answered choice
// already said appeared a second time as its own message.
const DELIVERY = "D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。分钟范围 04:48–05:07。用了 5 道选择题。";
test("delivery gate turn is not written when the last reply already says it", async () => {
const turns = (text: string) => [
{ id: "t-user", role: "user", text: "A", status: "completed", created_at: "2026-10-01T00:00:00Z" },
{ id: "t-ack", role: "assistant", text, status: "completed", created_at: "2026-10-01T00:00:01Z" },
];
for (const [label, last, writes] of [
["already said", `已记录,范围收到 04:48–05:07。\n\n${DELIVERY}`, 0],
["not said", "已记录,范围收到 04:48–05:07。", 1],
] as const) {
resetDeliveryTurnGuardForTests();
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture({ turns: turns(last) }),
append_agentic_rectification_turn: () => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false }),
});
await persistExhaustionGateTurn({ accounting: accounting.client, userId: USER, caseId: CASE, hostNarration: DELIVERY, resultId: RESULT });
assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, writes, label);
assert.equal(alreadyDelivered({ caseId: CASE, resultId: RESULT, hasUserMessage: false }), true, `${label}: still marked delivered`);
}
resetDeliveryTurnGuardForTests();
assert.equal(narrationAlreadyInLastAssistant(DELIVERY, { turns: [{ role: "assistant", text: DELIVERY.replace(/;/g, "; ") }] }), true, "whitespace does not defeat the check");
assert.equal(narrationAlreadyInLastAssistant("短", { turns: [{ role: "assistant", text: "短" }] }), false, "too short to compare");
});