diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3cbebb..709bfe8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-01 — 交付时盘型结论不再连说两遍 + +- 修复:答完最后一道题出交付卡时,「D1 上升…用了 N 道选择题」连出两条。上一版修好收尾话的保存后带出的重复,现在已说过就不再补写(BUG-1153)。Skill 版本不变;不改数据库结构。 + ## 2026-10-01 — 盘型口径的验证报告不再按分钟排名 - 交付卡「查看验证报告」在盘型口径下去掉「当前候选基本并列」「本命上升(该分钟)」和「排名 / 时间 / 相对支持」表,保留事件吻合、大运对照和技法审计;已有校正打开报告即生效(BUG-1151)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 53518667..114ad780 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md b/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md index e4f406e0..33d80a7a 100644 --- a/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md +++ b/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md @@ -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 道,属设计,已向产品提出改进选项。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 59e7224f..c987cd16 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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`,真机欠 | — | diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 6917c079..00b6e8a0 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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 }[] }, diff --git a/frontend/tests/rectification-post-adopt-card-20261001.test.ts b/frontend/tests/rectification-post-adopt-card-20261001.test.ts index f2b73d41..a05a4b58 100644 --- a/frontend/tests/rectification-post-adopt-card-20261001.test.ts +++ b/frontend/tests/rectification-post-adopt-card-20261001.test.ts @@ -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"); +});