From 6987875b84534d578aa627728898474006edcbaa Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 2 Oct 2026 07:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(rectification):=20post-adopt=20verdict=20s?= =?UTF-8?q?ays=20whether=20the=20adopted=20time=20still=20fits=20and=20off?= =?UTF-8?q?ers=20=E6=94=B9=E7=94=A8=20HH:MM;=20renumber=20BUG-1170?= =?UTF-8?q?=E2=86=921172=20(BUG-1173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- CHANGELOG.md | 10 ++- docs/BUG_HISTORY.md | 21 ++++- ...-rectification-post-adopt-card-20261001.md | 6 +- docs/tasks/README.md | 2 +- .../rectification-post-adopt-card-20261001.md | 1 + frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 2 +- .../rectification-segment-delivery.tsx | 13 ++- .../rectification-agentic/v9/answer-choice.ts | 3 +- .../v9/case-dossier-response.ts | 7 ++ .../v9/divergence-panel.ts | 9 ++ .../v9/post-adopt-verdict.ts | 89 ++++++++++++++++--- .../rectification-agentic/v9/turn-question.ts | 3 +- .../database-segment-d7-boundaries.test.ts | 29 ++++++ .../tests/rectification-answer-choice.test.ts | 2 +- ...cation-post-adopt-verdict-20261002.test.ts | 58 ++++++++++-- ...ectification-readopt-card-20261002.test.ts | 48 ++++++++++ 17 files changed, 272 insertions(+), 33 deletions(-) create mode 100644 frontend/tests/rectification-readopt-card-20261002.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf79723..13ca3991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # 印度占星 Skill 更新日志 -<<<<<<< ours +## 2026-10-02 — 采用后核对题让盘型结论变了:说清采用的时间还在不在,需要时给「改用」 + +- 一件事对不上但盘型没变:回复说「一件事还不足以改变盘型结论,先按 HH:MM 排盘」,不再劝改选。 +- 盘型变了、采用的时间仍在其中:说「不用改」,卡片保持「已采用」。 +- 盘型变了、采用的时间不在了:说清哪张盘对不上、新的推荐时间是几点,交付卡挂到这条回复下面,按钮是「改用 HH:MM」;不自动改,原填报资料保留(BUG-1173)。 +- 上一条更新的编号由 BUG-1170 改为 BUG-1172(与他处预留撞号)。Skill 版本不变;不改数据库结构。 ## 2026-10-02 — 普通对话不再预设处境;受冲时请你确认;你纠正时先认下 - (同日第二次调整)盘上受冲多时,回答只说「这条线有压力或距离的迹象」并给出现实里可能的几种情况,不再替你断定父母在不在身边、有几段感情、身体好不好,也不写「底子不差」「关系稳」这类反向安慰;最后请你说一句实际情况,全篇只问一次。婚姻写「感情这条线上有波折的迹象」。事业先讲一生的事业形态,三层机会不再用「面试 / 职位」。你纠正时只对上真正对得上的几条,分不出的会直说「盘本身分不出这一点」。 @@ -20,9 +25,8 @@ ## 2026-10-02 — 采用后答完核对题,会说这件事和采用的时间对不对得上 -- 采用后答核对题,回复先说结果:「这件事和 04:55 对得上」「对不上;如果别的经历也对不上,可以回到上面的卡片改选」或「分不出」;盘型口径下再说「盘型结论不变」或给出变化后的各盘上升(BUG-1170)。 +- 采用后答核对题,回复先说结果:「这件事和 04:55 对得上」「对不上;如果别的经历也对不上,可以回到上面的卡片改选」或「分不出」;盘型口径下再说「盘型结论不变」或给出变化后的各盘上升(BUG-1172)。 - 题数规则不变(至多 2 道)。Skill 版本不变;不改数据库结构。 ->>>>>>> theirs ## 2026-10-01 — 普通对话的数据卡看得到「谁照谁、谁和谁同宫、燃烧」,瑜伽按领域分 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9c8e1c13..c609d843 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15605,7 +15605,6 @@ - 复发自:无 - 修复版本:缓解随 `codex/consult-card-affliction-data-20261001`;其余无 -<<<<<<< ours ## BUG-1160 | 普通对话没有「先看受冲、再看旺弱」的读法,模型默认「旺 = 好」 - 状态:resolved(文本层;2026-10-02 产品定年运 / 时运字数口径后全部领域生效) @@ -15745,7 +15744,9 @@ - 决策日志(2026-10-02):产品 2026-10-02 决定(名人回测第一轮对照组误报 1→15;Claude 按卡片确定性计数核对,在场与缺席的父亲受冲信号数相同,盘分不出):事业清单去求职口吻、加「一生事业形态」首行(见 BUG-1162)。第二轮回测 16 / 18 → 12 / 18,仍未过 ≤ 3。剩余 12 份多是模型按 AL / A10 / 10 宫主落宫读形态时确实读不出公众型(乔布斯 A10 落 12 宫、奥巴马 10 宫主落 6 宫、Bush / Zidane 10 宫主落 1 宫),事业形态与盘面信号的对应需要占星口径决定,不是措辞问题。状态维持 investigating。提交 `066558a6`。 - 修复版本:`codex/consult-no-presupposition-backtest-20261001`(已部署 staging `f361365a`,2026-10-02 health 核对、/api/account 401;部分) -## BUG-1170 | 采用后答完核对题只说「前事核对到这里」,不说对不对得上 +## BUG-1172 | 采用后答完核对题只说「前事核对到这里」,不说对不对得上 + +- 编号说明:首次推送(`42ff8291`)时记为 BUG-1170,与普通对话任务书 `TASK-consult-no-presupposition-and-backtest-20261001.md` 预留的 BUG-1170/1171 撞号,2026-10-02 改为 BUG-1172。 - 状态:resolved(2026-10-02 Claude 直接执行;产品 10-01 选 A) - 首次发现 / 最近更新:2026-10-01 / 2026-10-02 @@ -15761,4 +15762,18 @@ - 复发自:无 - 修复版本:`codex/rectification-post-adopt-verify-20261001`。 ->>>>>>> theirs +## BUG-1173 | 采用后核对题让盘型结论变了,回复不说采用的时间还在不在、也不给改选入口;一件对不上就劝改选 + +- 状态:resolved(2026-10-02 Claude 直接执行;产品 10-02 选 A;真库测试) +- 首次发现 / 最近更新:2026-10-02 / 2026-10-02 +- 影响面:`v9/post-adopt-verdict.ts`(`adoptedMinuteLeavesChart`、`readoptOffer`)、`v9/divergence-panel.ts`(交付卡新字段 `readopt_minute`)、`v9/case-dossier-response.ts` + `v9/turn-question.ts::attachOfferResultToTurns`(卡片挂载位置)、`components/rectification-segment-delivery.tsx`(按钮)。 +- 用户现象(产品提问「假如盘型有变你怎么做」):BUG-1172 只说「盘型结论有变化:…」,不说采用的时间是否还在新结论里;交付卡在核对题上方,要往回翻;若服务端采用分钟只是在同一盘型内挪了一下,卡片按钮会重新亮起「改用 D1 + D9 + D10 盘解读」,与「不用改」矛盾。真库重放另见:答「对不上」而盘型没变时,回复仍说「可以回到上面的卡片改选」,改选只会落回同一分钟。 +- 根因:采用后没有一个「是否该提示改用」的服务端判断,回复、卡片按钮、卡片位置各自推断。 +- 决策记录:产品 10-02 选 A——分情况说清楚,改不改由用户点;不自动改;原填报资料保留。 +- 修复:`readoptOffer(采用时间, 新摘要, 窗口分钟)` 只在「采用分钟在某张非『分不开』目标盘上的上升 ≠ 新结论」且「服务端有不同的新采用分钟」时给出新分钟;回复、卡片按钮、卡片位置都读它。回复四种:没变+对不上→「一件事还不足以改变盘型结论,先按 HH:MM 排盘。」;变了但仍在→「…你采用的 HH:MM 仍在其中,不用改。」;变了且离开、有新分钟→「…你采用的 HH:MM 的 D9 上升是X,和新的结论不一致;新的推荐时间是 HH:MM,可以点下面的「改用 HH:MM」。」;离开但无新分钟→「…先按 HH:MM 排盘。」。有新分钟时交付卡挂到这条回复下面,按钮为「改用 HH:MM」并注明「现在按 HH:MM 排盘;改用后按 HH:MM 排盘,原填报资料保留。」;其余情况已采用的卡保持「已采用」。改用走既有分段采用 RPC,不改数据库。 +- 验证:`frontend/tests/rectification-post-adopt-verdict-20261002.test.ts`(四种回复、`readoptOffer` 五种输入、卡片挂载只在改用时移到最新回复;原「盘型结论有变化」用例按三栏改写);`frontend/tests/rectification-readopt-card-20261002.test.ts`(离开→「改用 09:15」与当前分钟说明;仍在→「已采用」);`frontend/tests/database-segment-d7-boundaries.test.ts` 新增真库段:首次分段采用后只改后验(先验仍等于引擎原分),服务端采用分钟 2→8,同一代表候选再次采用成功、资料排盘时间与 provenance 跟到新分钟(PG17 替身通过)。真库重放 12 例中答「对不上」均未改变盘型(单题 ±2 分),盘型变化在实际中少见。 +- 防复发:采用后的「是否提示改用」只由 `readoptOffer` 决定;回复、按钮、卡片位置不得各自判断。 +- 相关记录:BUG-1172、BUG-120(改选)、BUG-1115~1117。 +- 复发自:无 +- 修复版本:`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 5fd2431e..31b8c217 100644 --- a/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md +++ b/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md @@ -64,6 +64,10 @@ Python 快速门:本单不改 `scripts/**`、`tests/**`,未跑。`test:db` 真机:交付时「D1 上升…用了 5 道选择题」连出两条。gate 收尾轮次在 BUG-1149 后首次真的落库,与答题回复里的同一段重复。修:写前比对最近一条助手消息。真机另报两点:①采用消息里题干「出现两次」——服务端与单测均确认正文已剥题干,粘贴里多出的一遍来自选项卡给读屏用的隐藏标题(sr-only legend)被鼠标选中复制,屏幕上只显示一次;②答一道核对题就结束——按现行规则:采用后核对题至多 2 道,且只取采用前没问过、不在用户已报年份/领域、未拒答的探针,该案只剩 1 道,属设计,已向产品提出改进选项。 -## 第五轮:核对题答完说结果(BUG-1170,产品选 A) +## 第五轮:核对题答完说结果(BUG-1172,产品选 A) 新增 `v9/post-adopt-verdict.ts`;`persistApplied` 已采用且所答为核对题时,在收尾句或下一题前加结果句。真库重放:「这件事和 15:26 对得上。盘型结论不变。」+ 收尾句。题数规则不变。 + +## 第六轮:盘型变化时说清并给「改用」(BUG-1173,产品 10-02 选 A);BUG-1170→1172 改号 + +`readoptOffer` 一处判断,回复 / 卡片按钮 / 卡片位置共用。真库重放:答「对不上」12 例盘型均未变,原回复「可以回到上面的卡片改选」误导,改为「一件事还不足以改变盘型结论」。真库 D7 测试新增:只改后验使服务端采用分钟 2→8,同一代表候选再采用成功、资料排盘时间跟到新分钟。第一次写夹具时改了先验,被 RPC 以 `segment_state_inconsistent` 拒(先验必须等于引擎原分),属夹具错误。改号原因:普通对话任务书已预留 1170/1171,我上一轮只核对到 1169。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 271ee714..e8b55fcd 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);BUG-1153 交付正文连出两条(BUG-1149 引入的回归);BUG-1170 核对题答完说对不对得上(产品选 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 引入的回归);BUG-1172 核对题答完说对不对得上(产品选 A);BUG-1173 盘型变化时说清并给「改用」(产品选 A);真机清单 `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/docs/testing/rectification-post-adopt-card-20261001.md b/docs/testing/rectification-post-adopt-card-20261001.md index 67a7d759..635718b7 100644 --- a/docs/testing/rectification-post-adopt-card-20261001.md +++ b/docs/testing/rectification-post-adopt-card-20261001.md @@ -11,6 +11,7 @@ - 如果有核对题,题目下有**四个按钮**,可以点。 4. **刷新**:刷新页面,核对题还在、四个按钮还在、仍挂在那条采用消息下面。 5. **答核对题**:点一个按钮,不出现「这次没提交上」;回复先说「这件事和 HH:MM 对得上 / 对不上 / 分不出」,再说「盘型结论不变」或给出变化,然后进入下一题或看到「前事核对到这里…」。 +5c. **答「没有」类选项**:若盘型没变,回复说「一件事还不足以改变盘型结论,先按 HH:MM 排盘」,不出现「回到上面的卡片改选」。(盘型真的变化很少见,见到「改用 HH:MM」按钮时点一下,排盘时间应换成新时间,卡片变「已采用」。) 5a. **选中时**:点「更像这个」后,上面的旁白和卡片不应消失;如果仍消失一下,请录屏发给 Claude。 5b. **验证报告**:交付卡点「查看验证报告」,里面没有「当前候选基本并列」「本命上升(该分钟)」和「排名 / 时间 / 相对支持」表。 6. **旧校正会话**:从会话列表打开一个以前的校正会话,能打开、不报错。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7c04e4c7..f1be14f4 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -4,7 +4,7 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J ## 校正按盘型交付(2026-09-30,在途,BUG-1115~1117) -复用范围卡布局:主行逐张写目标盘的上升与可信档位,分钟范围降为次行。完整扫描缺失时明确不能证明全窗一致;超过 61 分钟的 D9/D10 不作解读推荐。采用按钮沿用旧 owner/result/candidate 与门控,不另造候选:采用服务端头段交集/优先级/clamp 后的实际日期和分钟。已采用状态只认服务端核验的 result、候选锚点、date/time 与 segment provenance 身份,不能仅凭钟点相同点亮;只读与锁门仍禁用。来源偏移按实际采用日历日期计算,原填报资料保留,采用不等于确认。不增加 Home 状态、等待动画或新输入框。当前自动化/真机验收未闭环,不视作上线完成。折叠的「查看验证报告」同样不出分钟排名:无并列/代表分钟行、无「本命上升(该分钟)」、无候选排名表(BUG-1151)。 +复用范围卡布局:主行逐张写目标盘的上升与可信档位,分钟范围降为次行。完整扫描缺失时明确不能证明全窗一致;超过 61 分钟的 D9/D10 不作解读推荐。采用按钮沿用旧 owner/result/candidate 与门控,不另造候选:采用服务端头段交集/优先级/clamp 后的实际日期和分钟。已采用状态只认服务端核验的 result、候选锚点、date/time 与 segment provenance 身份,不能仅凭钟点相同点亮;只读与锁门仍禁用。来源偏移按实际采用日历日期计算,原填报资料保留,采用不等于确认。不增加 Home 状态、等待动画或新输入框。当前自动化/真机验收未闭环,不视作上线完成。折叠的「查看验证报告」同样不出分钟排名:无并列/代表分钟行、无「本命上升(该分钟)」、无候选排名表(BUG-1151)。采用后核对题让服务端采用分钟离开了已采用分钟所在的盘型时(`readopt_minute`),交付卡挂到说明这件事的回复下面,按钮为「改用 HH:MM」,下方一行 switch-note「现在按 HH:MM 排盘;改用后按 HH:MM 排盘,原填报资料保留。」;未离开时已采用的卡保持「已采用」,不因采用分钟在同一盘型内挪动而重新亮起(BUG-1173)。不新增按钮或组件。 ### 新产品缺扫描与部分扫描(T4 子组,在途) diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 1e84894b..e8c4c2f5 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -276,7 +276,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。 | 旁白不得念内部计分词。卡上的数字按上一行的 5 个百分点规则显示,旁白不念百分比。 | | 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 | | 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 | -| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。答完核对题先说结果(BUG-1170):「这件事和 04:53 对得上。」/「这件事和 04:53 对不上;如果别的经历也对不上,可以回到上面的卡片改选。」/「这件事分不出 04:53 和别的时间。」/「这件事记不清,不算。」;盘型口径再接「盘型结论不变。」或「盘型结论有变化:…」。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | +| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。答完核对题先说结果(BUG-1172):「这件事和 04:53 对得上。」/「这件事和 04:53 对不上;如果别的经历也对不上,可以回到上面的卡片改选。」/「这件事分不出 04:53 和别的时间。」/「这件事记不清,不算。」;盘型口径再接「盘型结论不变。」或「盘型结论有变化:…」。盘型口径下一件对不上但盘型没变:「这件事和 04:53 对不上。一件事还不足以改变盘型结论,先按 04:53 排盘。」不劝改选;变了时接「你采用的 04:53 仍在其中,不用改。」或「你采用的 04:53 的 D9 上升是天秤,和新的结论不一致;新的推荐时间是 05:02,可以点下面的「改用 05:02」。」(BUG-1173)。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | | (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 选择题处理失败,请稍后重试。 / 这道题已经过期,请回答当前问题(点选没提交上,下面又画出同一张卡) | 这次没提交上,请再点一次。 | 点选没到服务器时撤回已选,只留一张可点的卡,错误行就这一句。不说「稍后」,不把服务端状态码翻成话。 | diff --git a/frontend/src/components/rectification-segment-delivery.tsx b/frontend/src/components/rectification-segment-delivery.tsx index 7266103b..4c7f8696 100644 --- a/frontend/src/components/rectification-segment-delivery.tsx +++ b/frontend/src/components/rectification-segment-delivery.tsx @@ -23,8 +23,13 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se }>) { const adoption = summary?.adoption_minute; const candidateId = delivery.representative_candidate_id; - const adopted = Boolean(adoption?.date && selection?.contract === "segment-v1" && selection.resultId === resultId + const exactAdoption = Boolean(adoption?.date && selection?.contract === "segment-v1" && selection.resultId === resultId && selection.candidateId === candidateId && selection.date === adoption.date && selection.time === adoption.time); + // BUG-1173: a post-adopt answer can move the server's adoption minute. Only when the adopted + // minute left the verdict (server `readopt_minute`) does the button come back, as 「改用 HH:MM」. + const readopt = delivery.readopt_minute ?? null; + const priorAdoption = selection?.contract === "segment-v1" && selection.resultId === resultId ? selection : null; + const adopted = exactAdoption || Boolean(priorAdoption && !readopt); const charts = summary?.charts.filter((chart) => chart.tier !== "blocked") ?? []; const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true; const reportBlocked = !summary || blocked; @@ -53,8 +58,12 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se + {readopt && priorAdoption && !adopted ? ( +

现在按 {priorAdoption.time} 排盘;改用后按 {readopt} 排盘,原填报资料保留。

+ ) : null} {delivery.record_conflict && !adopted ? (

选它之后,排盘会从出生记录时间 {delivery.record_conflict.reported_time} 换成 {adoption.date ? `${adoption.date} ` : ""}{adoption.time}。 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 753c591c..32bd627b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -2416,7 +2416,7 @@ ${nextInterview.hostNarration}`; hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration; } - // BUG-1170: an answered post-adopt check says what it meant for the adopted time. + // BUG-1172: an answered post-adopt check says what it meant for the adopted time. const answeredIntent = input.dossier.conversationSummary.activeFocus?.intent; if (accepted && !skipThisProbe && input.userStopped !== true && (answeredIntent === "reverse_verify" || answeredIntent === "out_of_sample_check")) { @@ -2432,6 +2432,7 @@ ${nextInterview.hostNarration}`; candidates: after?.candidates ?? [], segmentBefore: segmentProduct ? before?.segment_summary ?? null : null, segmentAfter: segmentProduct ? after?.segment_summary ?? null : null, + segmentMinutes: segmentProduct ? after?.segment_minutes ?? before?.segment_minutes ?? null : null, }); if (verdict) hostNarration = hostNarration ? `${verdict}\n\n${hostNarration}` : verdict; } diff --git a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts index ead4b01a..5458b72f 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts @@ -23,6 +23,7 @@ import { latestResultToolProjection } from "@/mastra/rectification-v9-tools"; import { normalizeBirthTimeSource } from "@/lib/rectification-agentic/birth-time-provenance"; import { parseCaseSegmentChecks } from "./segment-checks.ts"; import { productDeliveryForDomain } from "./product-delivery.ts"; +import { readoptOffer } from "./post-adopt-verdict.ts"; export async function dossierResponseWithIdentity( ...args: Parameters @@ -58,10 +59,15 @@ export function dossierResponse( const overlaid = dossier.latestResult ? overlayPublicDecision(dossier.latestResult, decision) : fields; + const readoptState = dossier.case.rectificationDomain === null + ? null : previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const turns = attachOfferResultToTurns(turnsWithQuestions, { resultId: dossier.latestResult?.resultId ?? null, canAdopt: !identity.read_only && overlaid.can_adopt === true, acceptedTime: dossier.case.acceptedTime, + // BUG-1173: a post-adopt answer moved the verdict off the adopted minute — the card (with + // 「改用 HH:MM」) hangs under the reply that says so, not above the checks. + readopt: Boolean(readoptOffer(dossier.case.acceptedTime, readoptState?.segment_summary, readoptState?.segment_minutes)), }); const segmentChecks = parseCaseSegmentChecks(dossier.case.segmentChecks, dossier.case.rectificationDomain); const consistencySummary = segmentChecks?.delivered_turn_id && segmentChecks.summary?.no_rectification_needed @@ -168,6 +174,7 @@ function publicLatestResult( evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, accepted: Boolean(dossier.case.acceptedTime), + acceptedTime: dossier.case.acceptedTime, tieBreakAvailable: tieBreakPersonalityAvailable(tieBreakGateInput({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, diff --git a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts index 229a7cb9..eacbcdbb 100644 --- a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts +++ b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts @@ -3,6 +3,7 @@ * Traits come only from D9 / D10 type tables and nakshatra_boundary options. */ +import { readoptOffer } from "./post-adopt-verdict.ts"; import { rankActive } from "../core/convergence-evaluator.ts"; import { parseSegmentSummary } from "../core/segment-summary.ts"; import { parseProductDelivery, type ProductDelivery } from "./product-delivery.ts"; @@ -82,6 +83,8 @@ export type RangeDeliveryRecordConflict = Readonly<{ export type RangeDeliveryProjection = Readonly<{ product_delivery?: ProductDelivery | null; segment_summary?: import("../core/segment-summary.ts").SegmentSummary | null; + /** BUG-1173: after adoption, the minute the card offers instead (null: the adopted one still fits). */ + readopt_minute?: string | null; window_minutes?: number | null; range: readonly [string, string] | null; representative_time: string | null; @@ -369,6 +372,7 @@ export function buildRangeDelivery(input: { reportedBirthTime?: string | null; birthTimeSource?: string | null; declaredBirthDate?: string | null; + acceptedTime?: string | null; }): RangeDeliveryProjection { const inference = input.inference; const windowMinutes = input.windowMinutes ?? deliveryWindowMinutes(inference); @@ -438,6 +442,8 @@ export function buildRangeDelivery(input: { return { product_delivery: input.productDelivery ?? null, segment_summary: input.productDelivery === null ? null : inference?.segment_summary ?? null, + readopt_minute: input.productDelivery === null ? null + : readoptOffer(input.acceptedTime, inference?.segment_summary, inference?.segment_minutes), window_minutes: windowMinutes, range: normalizedRange, representative_time: representativeTime, @@ -562,6 +568,7 @@ export function rangeDeliveryForSnapshot(snapshot: { reported_birth_time?: string | null; birthTimeSource?: string | null; birth_time_source?: string | null; + acceptedTime?: string | null; } | null | undefined): RangeDeliveryProjection { const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null; const inference = previousInferenceFromReceipt(receipt); @@ -572,6 +579,7 @@ export function rangeDeliveryForSnapshot(snapshot: { ); const refinement = refinementFromDecisionReceipt(receipt); return buildRangeDelivery({ + acceptedTime: snapshot?.acceptedTime ?? null, productDelivery: snapshot?.productDelivery === undefined ? parseProductDelivery(receipt?.product_delivery) ?? undefined : snapshot.productDelivery, inference, windowMinutes: deliveryWindowMinutes(snapshot?.candidateRange) ?? deliveryWindowFromReceipt(receipt), @@ -727,6 +735,7 @@ export function parseRangeDelivery(value: unknown, persistedProductDelivery?: Pr return { product_delivery: productDelivery, segment_summary: segmentSummary, + readopt_minute: segmentSummary ? clock(row.readopt_minute) : null, window_minutes: typeof row.window_minutes === "number" && Number.isInteger(row.window_minutes) && row.window_minutes > 0 ? row.window_minutes : null, range, representative_time: representativeTime, diff --git a/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts b/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts index 2e024d1f..e70787ac 100644 --- a/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts +++ b/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts @@ -1,5 +1,5 @@ /** - * What a post-adopt check said about the adopted time (BUG-1170, product 10-01 option A). + * What a post-adopt check said about the adopted time (BUG-1172, product 10-01 option A). * * The closing line used to be only 「前事核对到这里」: the reader answered and learned nothing. * The verdict reads the answered probe's own expected outcomes for the candidate that holds the @@ -7,7 +7,7 @@ * moved with this answer. Server facts only; no model text. */ import type { AnswerClass, ConflictProbe, InferenceCandidate } from "../core/types.ts"; -import { chartSummaryCopy, type SegmentSummary } from "../core/segment-summary.ts"; +import { chartSummaryCopy, SIGN_LABELS, type SegmentMinute, type SegmentSummary } from "../core/segment-summary.ts"; export type PostAdoptVerdictInput = Readonly<{ acceptedTime: string | null | undefined; @@ -16,6 +16,8 @@ export type PostAdoptVerdictInput = Readonly<{ candidates: readonly InferenceCandidate[]; segmentBefore?: SegmentSummary | null; segmentAfter?: SegmentSummary | null; + /** Window minutes with each target chart's ascendant; locates the adopted minute's signs. */ + segmentMinutes?: readonly SegmentMinute[] | null; }>; function minutes(clock: string): number | null { @@ -57,6 +59,51 @@ function chartsMoved(before: SegmentSummary, after: SegmentSummary): boolean { }); } +/** + * BUG-1173 (product 10-01 option A): when the verdict moved, does the adopted minute still sit in + * every target chart's leading ascendant? Returns the first chart it leaves, or null when it fits. + */ +export function adoptedMinuteLeavesChart( + clock: string, + after: SegmentSummary, + segmentMinutes: readonly SegmentMinute[] | null | undefined, +): Readonly<{ chart: string; sign: number }> | null { + const minute = segmentMinutes?.find((row) => row.time === clock); + if (!minute) return null; + for (const chart of after.charts) { + if (chart.sign === null || chart.tier === "blocked" || chart.tier === "indistinct") continue; + const own = minute.signs[chart.chart]; + if (typeof own === "number" && own !== chart.sign) return { chart: chart.chart, sign: own }; + } + return null; +} + +/** The re-offer: a different server adoption minute after the answer than the adopted one. */ +export function readoptMinute( + acceptedTime: string | null | undefined, + after: SegmentSummary | null | undefined, +): string | null { + const clock = acceptedTime?.trim().slice(0, 5) ?? ""; + const next = after?.adoption_minute?.time?.slice(0, 5) ?? ""; + return clock && next && next !== clock ? next : null; +} + +/** + * One server answer for 「should the reader be offered a re-adopt」: the adopted minute left a + * target chart's leading ascendant and the server has a different adoption minute. The reply, + * the card button and where the card hangs all read this, so they cannot disagree. + */ +export function readoptOffer( + acceptedTime: string | null | undefined, + after: SegmentSummary | null | undefined, + segmentMinutes: readonly SegmentMinute[] | null | undefined, +): string | null { + const clock = acceptedTime?.trim().slice(0, 5) ?? ""; + if (!clock || !after) return null; + const next = readoptMinute(clock, after); + return next && adoptedMinuteLeavesChart(clock, after, segmentMinutes) ? next : null; +} + export function postAdoptCheckVerdict(input: PostAdoptVerdictInput): string | null { const clock = input.acceptedTime?.trim().slice(0, 5) ?? ""; if (!clock || !input.answerClass) return null; @@ -67,15 +114,35 @@ export function postAdoptCheckVerdict(input: PostAdoptVerdictInput): string | nu const ids = candidate ? new Set([candidate.id, candidate.time]) : new Set(); const supports = Boolean(outcome?.supports.some((id) => ids.has(id))); const conflicts = Boolean(outcome?.conflicts.some((id) => ids.has(id))); - const fit = supports && !conflicts - ? `这件事和 ${clock} 对得上。` - : conflicts && !supports - ? `这件事和 ${clock} 对不上;如果别的经历也对不上,可以回到上面的卡片改选。` - : `这件事分不出 ${clock} 和别的时间。`; + const fits = supports && !conflicts; + const clashes = conflicts && !supports; const before = input.segmentBefore ?? null; const after = input.segmentAfter ?? null; - if (!before || !after) return fit; - return chartsMoved(before, after) - ? `${fit}盘型结论有变化:${chartSummaryCopy(after)}` - : `${fit}盘型结论不变。`; + if (!before || !after) { + // Minute product: no chart verdict to lean on; the card is where a re-pick happens. + return fits + ? `这件事和 ${clock} 对得上。` + : clashes + ? `这件事和 ${clock} 对不上;如果别的经历也对不上,可以回到上面的卡片改选。` + : `这件事分不出 ${clock} 和别的时间。`; + } + const fit = fits + ? `这件事和 ${clock} 对得上。` + : clashes + ? `这件事和 ${clock} 对不上。` + : `这件事分不出 ${clock} 和别的时间。`; + if (!chartsMoved(before, after)) { + // One answer moves scores a little; 「改选」 here would land on the same minute. + return clashes + ? `${fit}一件事还不足以改变盘型结论,先按 ${clock} 排盘。` + : `${fit}盘型结论不变。`; + } + const moved = `${fit}盘型结论有变化:${chartSummaryCopy(after)}`; + const leaves = adoptedMinuteLeavesChart(clock, after, input.segmentMinutes); + const next = readoptOffer(clock, after, input.segmentMinutes); + if (!leaves) return `${moved}你采用的 ${clock} 仍在其中,不用改。`; + const where = `你采用的 ${clock} 的 ${leaves.chart} 上升是${SIGN_LABELS[leaves.sign] ?? "别的星座"},和新的结论不一致`; + return next + ? `${moved}${where};新的推荐时间是 ${next},可以点下面的「改用 ${next}」。` + : `${moved}${where},但现在还推荐不出单一时间,先按 ${clock} 排盘。`; } diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index df48de74..0cb2d182 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -302,6 +302,7 @@ export function attachOfferResultToTurns { const resultId = input.resultId ?? null; @@ -313,7 +314,7 @@ export function attachOfferResultToTurns ( turn.role === "assistant" && turn.question?.kind === "reverse_verify" )); - const searchUntil = firstVerify >= 0 ? firstVerify : turns.length; + const searchUntil = firstVerify >= 0 && input.readopt !== true ? firstVerify : turns.length; let ownerIndex = -1; for (let index = searchUntil - 1; index >= 0; index -= 1) { if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) { diff --git a/frontend/tests/database-segment-d7-boundaries.test.ts b/frontend/tests/database-segment-d7-boundaries.test.ts index 71cf1a1b..adba74b9 100644 --- a/frontend/tests/database-segment-d7-boundaries.test.ts +++ b/frontend/tests/database-segment-d7-boundaries.test.ts @@ -155,6 +155,35 @@ test("D7 PostgreSQL recomputes priority, clamp and gap tie and rolls back stale assert.ok((await row.accept(mutation === "owner" ? randomUUID() : row.userId)).error, mutation); assert.equal(row.stateSnapshot(), before, `${mutation} rejection leaves all four tables unchanged`); } + // BUG-1173: a post-adopt answer can move the server adoption minute; 「改用 HH:MM」 posts the + // same segment accept for the new representative and the profile follows to the new minute. + const moved = await seed("empty-d1"); + const first = await moved.accept(); + assert.equal(first.error, null, JSON.stringify(first.error)); + const firstMinute = moved.inference.segment_summary!.adoption_minute!; + // An answer moves raw *posterior* only; the prior stays the persisted engine score (the RPC + // checks it). Posterior 12/11/10 → 5/30/5: the middle span leads, the server minute moves + // 2 → 8, while the representative candidate (what the card posts) stays the same. + const posterior = [5, 30, 5]; + const rescoredCandidates = moved.inference.candidates.map((candidate, index) => ({ ...candidate, raw_posterior_score: posterior[index]! })); + const movedState = { ...structuredClone(moved.inference), candidates: rescoredCandidates, + segment_summary: buildSegmentSummary({ targets: moved.targets, minutes: moved.minutes, windowMinutes: 21, scanComplete: true, + deliveredOffsets: (moved.inference.credible_intervals ?? []).map(part => [part.start_offset_minutes, part.end_offset_minutes] as const), + candidates: rescoredCandidates.map(candidate => ({ time: candidate.time, score: candidate.raw_posterior_score, + cluster_times: candidate.cluster_times, eliminated: candidate.raw_eliminated === true })) }) }; + const nextMinute = movedState.segment_summary!.adoption_minute!; + assert.notEqual(nextMinute.offset, firstMinute.offset, "fixture moves the adoption minute"); + fixture.psql(`update public.agentic_rectification_results set decision_receipt=jsonb_set(jsonb_set(decision_receipt,'{inference_state}','${JSON.stringify(movedState)}'),'{representative_time}','"${movedState.representative_time}"') where id='${moved.result.result_id}'`); + const nextAnchor = moved.result.candidates.find(candidate => candidate.time === movedState.representative_time)!; + const second = await service.rpc("accept_agentic_rectification_segment_for_case_v1", { + p_user_id: moved.userId, p_case_id: moved.caseId, p_result_id: moved.result.result_id, + p_candidate_id: nextAnchor.candidate_id, p_request_id: randomUUID(), + }); + assert.equal(second.error, null, JSON.stringify(second.error)); + assert.equal((second.data as Record).saved_time, nextMinute.time); + const after = JSON.parse(moved.stateSnapshot()) as { profile: { active_birth_time: string; active_birth_provenance: { candidate_time: string } } }; + assert.equal(after.profile.active_birth_time.slice(0, 5), nextMinute.time); + assert.equal(after.profile.active_birth_provenance.candidate_time, nextMinute.time); } finally { await closeLocalPostgresDataPool(url); fixture.stop(); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 94e21eb3..8963137d 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -1795,7 +1795,7 @@ function lastVerifyDossier() { })); } -test("answering the last post-adopt verify question says what it meant for the adopted time (BUG-1170)", async () => { +test("answering the last post-adopt verify question says what it meant for the adopted time (BUG-1172)", async () => { const dossier = withAcceptedTime(lastVerifyDossier()); const accounting = persistChoiceAccounting(dossier, nextFocusHandler()); const applied = await applyRectificationChoice(accounting.client, { diff --git a/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts b/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts index 03d87640..73ee8a64 100644 --- a/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts +++ b/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { candidateForAdoptedMinute, postAdoptCheckVerdict } from "../src/lib/rectification-agentic/v9/post-adopt-verdict.ts"; +import { candidateForAdoptedMinute, postAdoptCheckVerdict, readoptOffer } from "../src/lib/rectification-agentic/v9/post-adopt-verdict.ts"; +import { attachOfferResultToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts"; -// BUG-1170 (product 10-01 option A): after a post-adopt check the reply says whether the event +// BUG-1172 (product 10-01 option A): after a post-adopt check the reply says whether the event // fits the adopted time, and whether the chart verdicts moved — not only 「前事核对到这里」. const candidate = (time: string, range: [string, string], clusterTimes?: string[]) => ({ id: time, time, cluster_range: range, ...(clusterTimes ? { cluster_times: clusterTimes } : {}), @@ -21,8 +22,9 @@ const probe = { { answer_class: "weak_yes", supports: [], conflicts: [] }, ], } as never; -const summary = (tier: string, sign: number): SegmentSummary => ({ - targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false, adoption_minute: null, +const summary = (tier: string, sign: number, adoptionTime: string | null = null): SegmentSummary => ({ + targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false, + adoption_minute: adoptionTime ? { offset: 0, time: adoptionTime, date: "1997-08-08", signs: {} } : null, charts: [ { chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false }, { chart: "D9", sign, tier, alternatives: [], tied: false }, @@ -38,6 +40,7 @@ test("the adopted minute maps to the candidate whose cluster holds it, else the test("verdict: fits, does not fit, cannot tell, and unsure", () => { const base = { acceptedTime: "04:55:00", probe, candidates: CANDIDATES }; assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "yes" }), "这件事和 04:55 对得上。"); + // Minute product (no chart summary): the card is where a re-pick happens. assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "no" }), "这件事和 04:55 对不上;如果别的经历也对不上,可以回到上面的卡片改选。"); assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "weak_yes" }), "这件事分不出 04:55 和别的时间。"); @@ -51,7 +54,48 @@ test("segment product says whether the chart verdicts moved", () => { const base = { acceptedTime: "04:55", probe, candidates: CANDIDATES, answerClass: "yes" as const }; assert.equal(postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("credible", 7) }), "这件事和 04:55 对得上。盘型结论不变。"); - const moved = postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("tentative", 6) }); - assert.match(moved!, /^这件事和 04:55 对得上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天秤(倾向,备选也要看)。$/); - for (const text of [moved!]) assert.doesNotMatch(text, /确认|精确|相对支持|领先|落后/); + // BUG-1173 — 原值: 「…盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天秤(倾向,备选也要看)。」 / 新值: 同一句 + // 再接「你采用的 04:55 仍在其中,不用改。」 / 原因: 产品 10-01 选 A,变化时要说清采用的时间还在不在新结论里。 + const minutes = [ + { offset: 7, time: "04:55", signs: { D1: 3, D9: 6 } }, + { offset: 14, time: "05:02", signs: { D1: 3, D9: 7 } }, + ]; + const moved = postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("tentative", 6), segmentMinutes: minutes }); + assert.equal(moved, "这件事和 04:55 对得上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天秤(倾向,备选也要看)。你采用的 04:55 仍在其中,不用改。"); + const left = postAdoptCheckVerdict({ ...base, answerClass: "no", segmentBefore: summary("credible", 6), segmentAfter: summary("credible", 7, "05:02"), segmentMinutes: minutes }); + assert.equal(left, "这件事和 04:55 对不上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。你采用的 04:55 的 D9 上升是天秤,和新的结论不一致;新的推荐时间是 05:02,可以点下面的「改用 05:02」。"); + const noMinute = postAdoptCheckVerdict({ ...base, answerClass: "no", segmentBefore: summary("credible", 6), segmentAfter: summary("credible", 7), segmentMinutes: minutes }); + assert.equal(noMinute, "这件事和 04:55 对不上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。你采用的 04:55 的 D9 上升是天秤,和新的结论不一致,但现在还推荐不出单一时间,先按 04:55 排盘。"); + for (const text of [moved!, left!, noMinute!]) assert.doesNotMatch(text, /确认|精确|相对支持|领先|落后/); }); + +test("a clash that does not move the verdict says so instead of inviting a re-pick", () => { + const base = { acceptedTime: "04:55", probe, candidates: CANDIDATES, answerClass: "no" as const }; + assert.equal(postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("credible", 7) }), + "这件事和 04:55 对不上。一件事还不足以改变盘型结论,先按 04:55 排盘。"); +}); + +test("re-offer only when the adopted minute left the verdict and a new minute exists", () => { + const minutes = [ + { offset: 7, time: "04:55", signs: { D1: 3, D9: 6 } }, + { offset: 14, time: "05:02", signs: { D1: 3, D9: 7 } }, + ]; + assert.equal(readoptOffer("04:55:00", summary("credible", 7, "05:02"), minutes), "05:02"); + assert.equal(readoptOffer("04:55", summary("credible", 6, "05:02"), minutes), null, "still fits: no re-offer"); + assert.equal(readoptOffer("04:55", summary("credible", 7, null), minutes), null, "no server minute: no re-offer"); + assert.equal(readoptOffer("04:55", summary("indistinct", 7, "05:02"), minutes), null, "an indistinct chart does not count"); + assert.equal(readoptOffer(null, summary("credible", 7, "05:02"), minutes), null); +}); + +test("the card hangs under the latest reply only when a re-adopt is offered", () => { + const turns = [ + { id: "delivery", role: "assistant", question: null }, + { id: "adopt", role: "assistant", question: { kind: "reverse_verify", status: "answered", answer_option: "C" } }, + { id: "verdict", role: "assistant", question: null }, + ] as never[]; + const owner = (readopt: boolean) => attachOfferResultToTurns(turns, { resultId: "r1", canAdopt: true, acceptedTime: "04:55", readopt }) + .find((turn) => (turn as { offer_result_id: string | null }).offer_result_id) as { id: string } | undefined; + assert.equal(owner(false)?.id, "delivery"); + assert.equal(owner(true)?.id, "verdict"); +}); + diff --git a/frontend/tests/rectification-readopt-card-20261002.test.ts b/frontend/tests/rectification-readopt-card-20261002.test.ts new file mode 100644 index 00000000..4760c561 --- /dev/null +++ b/frontend/tests/rectification-readopt-card-20261002.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { RectificationSegmentDelivery } from "../src/components/rectification-segment-delivery.tsx"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; + +// BUG-1173 (product 10-01 option A): after adoption the card stays 「已采用」 while the adopted +// minute fits the verdict, and offers 「改用 HH:MM」 only when the verdict moved off it. +// Fictional complete scan; D9 changes ascendant at offset 10. +const minutes = Array.from({ length: 21 }, (_, offset) => ({ offset, date: "2000-06-14", + time: `09:${String(offset).padStart(2, "0")}`, signs: { D1: 1, D9: offset < 10 ? 2 : 3 } })); +const state = buildInferenceState({ range_start: "09:00", range_end: "09:20", + candidates: [{ id: "09:15", time: "09:15", relative_support: 70, raw_score: 30, cluster_times: ["09:15"] }, + { id: "09:03", time: "09:03", relative_support: 30, raw_score: 5, cluster_times: ["09:03"] }], + events: [], probes: [], segment_targets: ["D1", "D9"], segment_minutes: minutes, segment_scan_complete: true }); +const publicCandidates = [{ candidateId: "cand-1", time: "09:15" }, { candidateId: "cand-2", time: "09:03" }] as never; + +function render(acceptedTime: string) { + const delivery = buildRangeDelivery({ inference: state, publicCandidates, acceptedTime }); + const adoption = state.segment_summary!.adoption_minute!; + const html = renderToStaticMarkup(createElement(RectificationSegmentDelivery, { + summary: state.segment_summary!, delivery, rangeLabel: null, resultId: "result-1", + selection: { resultId: "result-1", candidateId: delivery.representative_candidate_id ?? "cand-1", date: "2000-06-14", time: acceptedTime, contract: "segment-v1" }, + busy: false, accepting: false, onAccept: () => {}, markdown: null, + })); + return { delivery, adoption, html }; +} + +test("adopted minute left the verdict: 「改用 HH:MM」 with the current minute named", () => { + const { delivery, adoption, html } = render("09:03"); + assert.ok(state.segment_summary?.adoption_minute, "fixture yields a server adoption minute"); + assert.notEqual(adoption.time, "09:03"); + assert.equal(delivery.readopt_minute, adoption.time); + assert.match(html, new RegExp(`改用 ${adoption.time}`)); + assert.match(html, /现在按 09:03 排盘/); + assert.doesNotMatch(html, /aria-pressed="true"/); +}); + +test("adopted minute still fits after the server minute moved: stays 「已采用」", () => { + const { delivery, adoption, html } = render("09:12"); + assert.notEqual(adoption.time, "09:12"); + assert.equal(delivery.readopt_minute, null); + assert.match(html, /已采用/); + assert.doesNotMatch(html, /改用 09:/); +});