From 42ff82916e4720bb5340f4ca70e8aa62b4cbc1f1 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 2 Oct 2026 00:04:17 +0800 Subject: [PATCH] feat(rectification): an answered post-adopt check says whether it fits the adopted time (BUG-1170) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- CHANGELOG.md | 5 ++ docs/BUG_HISTORY.md | 17 ++++ ...-rectification-post-adopt-card-20261001.md | 4 + docs/tasks/README.md | 2 +- .../rectification-post-adopt-card-20261001.md | 2 +- frontend/docs/VOICE.md | 2 +- .../rectification-agentic/v9/answer-choice.ts | 21 +++++ .../v9/post-adopt-verdict.ts | 81 +++++++++++++++++++ .../tests/rectification-answer-choice.test.ts | 24 ++++++ ...cation-post-adopt-verdict-20261002.test.ts | 57 +++++++++++++ 10 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts create mode 100644 frontend/tests/rectification-post-adopt-verdict-20261002.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 47acf89f..8809f48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-02 — 采用后答完核对题,会说这件事和采用的时间对不对得上 + +- 采用后答核对题,回复先说结果:「这件事和 04:55 对得上」「对不上;如果别的经历也对不上,可以回到上面的卡片改选」或「分不出」;盘型口径下再说「盘型结论不变」或给出变化后的各盘上升(BUG-1170)。 +- 题数规则不变(至多 2 道)。Skill 版本不变;不改数据库结构。 + ## 2026-10-01 — 普通对话的数据卡看得到「谁照谁、谁和谁同宫、燃烧」,瑜伽按领域分 - 数据卡每颗星带上被哪些星照到、和谁同宫、是否燃烧;各领域选中的宫带上被哪些星照到(BUG-1154、BUG-1155)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 69001a9f..24cd6c50 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15604,3 +15604,20 @@ - 相关记录:BUG-1157 - 复发自:无 - 修复版本:缓解随 `codex/consult-card-affliction-data-20261001`;其余无 + +## BUG-1170 | 采用后答完核对题只说「前事核对到这里」,不说对不对得上 + +- 状态:resolved(2026-10-02 Claude 直接执行;产品 10-01 选 A) +- 首次发现 / 最近更新:2026-10-01 / 2026-10-02 +- 影响面:`v9/answer-choice.ts::persistApplied`(已采用且所答焦点是 reverse_verify / out_of_sample_check)、新增 `v9/post-adopt-verdict.ts`。 +- 用户现象:采用后答了「2016 年 9 月那次升学」核对题,回复只有「前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。」用户不知道答案说明了什么,只问一道就结束。 +- 触发条件:已采用,答一道核对题(跳过不算)。 +- 根因:收尾文案只看「还有没有下一道」,不看刚答的这道对采用时间是支持还是冲突。只问一道是规则所致(采用后核对至多 2 道,只取采用前没问过、不在用户已报年份/领域、未拒答的探针;该案只剩 1 道),不是本条要改的。 +- 决策记录:产品 10-01 选 A——收尾先说结果,题数规则不变。 +- 修复:`postAdoptCheckVerdict` 用该探针按所答类别的 supports / conflicts,对照「采用分钟所在候选」(簇含该分钟,否则最近):支持→「这件事和 HH:MM 对得上。」;冲突→「这件事和 HH:MM 对不上;如果别的经历也对不上,可以回到上面的卡片改选。」;都不涉及→「这件事分不出 HH:MM 和别的时间。」;记不清→「这件事记不清,不算。」盘型口径再比对答题前后的分盘摘要:没变→「盘型结论不变。」,变了→「盘型结论有变化:<各目标盘上升与档位>」。结果句放在收尾句或下一道题之前。只用服务端事实,不写「确认 / 精确」。 +- 验证:`frontend/tests/rectification-post-adopt-verdict-20261002.test.ts`(候选映射、四种结果、盘型变/不变);`frontend/tests/rectification-answer-choice.test.ts` 新增作答最后一道核对题用例(去掉接线即红);本机 PG17 真库重放虚构案例:回复为「这件事和 15:26 对得上。盘型结论不变。」+ 收尾句。 +- 防复发:采用后作答的回复必须带结果句;跳过保持原收尾句(既有用例锁定)。 +- 相关记录:BUG-536~538、BUG-1146、BUG-1150。 +- 复发自:无 +- 修复版本:`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 33d80a7a..5fd2431e 100644 --- a/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md +++ b/docs/tasks/PROGRESS-rectification-post-adopt-card-20261001.md @@ -63,3 +63,7 @@ Python 快速门:本单不改 `scripts/**`、`tests/**`,未跑。`test:db` ## 第四轮:交付正文重复(BUG-1153,我在 BUG-1149 引入) 真机:交付时「D1 上升…用了 5 道选择题」连出两条。gate 收尾轮次在 BUG-1149 后首次真的落库,与答题回复里的同一段重复。修:写前比对最近一条助手消息。真机另报两点:①采用消息里题干「出现两次」——服务端与单测均确认正文已剥题干,粘贴里多出的一遍来自选项卡给读屏用的隐藏标题(sr-only legend)被鼠标选中复制,屏幕上只显示一次;②答一道核对题就结束——按现行规则:采用后核对题至多 2 道,且只取采用前没问过、不在用户已报年份/领域、未拒答的探针,该案只剩 1 道,属设计,已向产品提出改进选项。 + +## 第五轮:核对题答完说结果(BUG-1170,产品选 A) + +新增 `v9/post-adopt-verdict.ts`;`persistApplied` 已采用且所答为核对题时,在收尾句或下一题前加结果句。真库重放:「这件事和 15:26 对得上。盘型结论不变。」+ 收尾句。题数规则不变。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5c59dd42..fa9a363f 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 引入的回归);真机清单 `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-1170 核对题答完说对不对得上(产品选 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 c9a99787..67a7d759 100644 --- a/docs/testing/rectification-post-adopt-card-20261001.md +++ b/docs/testing/rectification-post-adopt-card-20261001.md @@ -10,7 +10,7 @@ - 出现一条新的助手消息「已采用 HH:MM。再核对一件过去的事。」,不是挂在上一题「已记录…」下面; - 如果有核对题,题目下有**四个按钮**,可以点。 4. **刷新**:刷新页面,核对题还在、四个按钮还在、仍挂在那条采用消息下面。 -5. **答核对题**:点一个按钮,不出现「这次没提交上」,能进入下一题或看到「前事核对到这里…」。 +5. **答核对题**:点一个按钮,不出现「这次没提交上」;回复先说「这件事和 HH:MM 对得上 / 对不上 / 分不出」,再说「盘型结论不变」或给出变化,然后进入下一题或看到「前事核对到这里…」。 5a. **选中时**:点「更像这个」后,上面的旁白和卡片不应消失;如果仍消失一下,请录屏发给 Claude。 5b. **验证报告**:交付卡点「查看验证报告」,里面没有「当前候选基本并列」「本命上升(该分钟)」和「排名 / 时间 / 相对支持」表。 6. **旧校正会话**:从会话列表打开一个以前的校正会话,能打开、不报错。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 1da224f7..ba989307 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -275,7 +275,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。 | 旁白不得念内部计分词。卡上的数字按上一行的 5 个百分点规则显示,旁白不念百分比。 | | 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 | | 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 | -| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | +| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。答完核对题先说结果(BUG-1170):「这件事和 04:53 对得上。」/「这件事和 04:53 对不上;如果别的经历也对不上,可以回到上面的卡片改选。」/「这件事分不出 04:53 和别的时间。」/「这件事记不清,不算。」;盘型口径再接「盘型结论不变。」或「盘型结论有变化:…」。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | | (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 选择题处理失败,请稍后重试。 / 这道题已经过期,请回答当前问题(点选没提交上,下面又画出同一张卡) | 这次没提交上,请再点一次。 | 点选没到服务器时撤回已选,只留一张可点的卡,错误行就这一句。不说「稍后」,不把服务端状态码翻成话。 | diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 00b6e8a0..753c591c 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -7,6 +7,7 @@ */ import { createHash } from "node:crypto"; +import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts"; import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { productDeliveryForDomain } from "./product-delivery.ts"; import { nextProbe } from "../core/build-state"; @@ -2415,6 +2416,26 @@ ${nextInterview.hostNarration}`; hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration; } + // BUG-1170: 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")) { + const before = inferenceForPersistedAnswer(input.dossier.latestResult?.decisionReceipt ?? null); + const after = input.decisionState ?? before; + const segmentProduct = input.dossier.case.rectificationDomain !== null && input.dossier.case.rectificationDomain !== undefined; + const verdict = postAdoptCheckVerdict({ + acceptedTime: input.dossier.case.acceptedTime, + probe: input.probeId + ? (after?.probes.find((probe) => probe.id === input.probeId) ?? before?.probes.find((probe) => probe.id === input.probeId) ?? null) + : null, + answerClass: input.answerClass, + candidates: after?.candidates ?? [], + segmentBefore: segmentProduct ? before?.segment_summary ?? null : null, + segmentAfter: segmentProduct ? after?.segment_summary ?? null : null, + }); + if (verdict) hostNarration = hostNarration ? `${verdict}\n\n${hostNarration}` : verdict; + } + const choiceStem = nextChoiceReady ? renderableChoiceStem(nextFocus) : null; if (choiceStem) { const body = skipThisProbe diff --git a/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts b/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts new file mode 100644 index 00000000..2e024d1f --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/post-adopt-verdict.ts @@ -0,0 +1,81 @@ +/** + * What a post-adopt check said about the adopted time (BUG-1170, 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 + * adopted minute, and — in the segment product — whether any target chart's ascendant or tier + * 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"; + +export type PostAdoptVerdictInput = Readonly<{ + acceptedTime: string | null | undefined; + probe: ConflictProbe | null | undefined; + answerClass: AnswerClass | null | undefined; + candidates: readonly InferenceCandidate[]; + segmentBefore?: SegmentSummary | null; + segmentAfter?: SegmentSummary | null; +}>; + +function minutes(clock: string): number | null { + const match = /^(\d{2}):(\d{2})/.exec(clock); + return match ? Number(match[1]) * 60 + Number(match[2]) : null; +} + +function inRange(clock: number, range: readonly [string, string]): boolean { + const start = minutes(range[0]); + const end = minutes(range[1]); + if (start === null || end === null) return false; + return start <= end ? clock >= start && clock <= end : clock >= start || clock <= end; +} + +/** The candidate that stands for the adopted minute: its cluster holds it, else the nearest. */ +export function candidateForAdoptedMinute( + acceptedTime: string, + candidates: readonly InferenceCandidate[], +): InferenceCandidate | null { + const clock = acceptedTime.slice(0, 5); + const at = minutes(clock); + if (at === null || candidates.length === 0) return null; + const holding = candidates.find((candidate) => candidate.time === clock || candidate.cluster_times?.includes(clock)) + ?? candidates.find((candidate) => inRange(at, candidate.cluster_range)); + if (holding) return holding; + const distance = (candidate: InferenceCandidate) => { + const value = minutes(candidate.time); + if (value === null) return Number.POSITIVE_INFINITY; + const raw = Math.abs(value - at); + return Math.min(raw, 1440 - raw); + }; + return [...candidates].sort((left, right) => distance(left) - distance(right))[0] ?? null; +} + +function chartsMoved(before: SegmentSummary, after: SegmentSummary): boolean { + return after.charts.some((chart) => { + const prior = before.charts.find((row) => row.chart === chart.chart); + return !prior || prior.sign !== chart.sign || prior.tier !== chart.tier; + }); +} + +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.probe) return null; + const candidate = candidateForAdoptedMinute(clock, input.candidates); + const outcome = input.probe.expected_outcomes.find((row) => row.answer_class === input.answerClass) ?? null; + 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 before = input.segmentBefore ?? null; + const after = input.segmentAfter ?? null; + if (!before || !after) return fit; + return chartsMoved(before, after) + ? `${fit}盘型结论有变化:${chartSummaryCopy(after)}` + : `${fit}盘型结论不变。`; +} diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index e397fde0..94e21eb3 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -1795,6 +1795,30 @@ function lastVerifyDossier() { })); } +test("answering the last post-adopt verify question says what it meant for the adopted time (BUG-1170)", async () => { + const dossier = withAcceptedTime(lastVerifyDossier()); + const accounting = persistChoiceAccounting(dossier, nextFocusHandler()); + const applied = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + probeId: EDUCATION_2014_PROBE.id, + optionId: "A", + expectedRevision: ( + dossier.latest_result as { + decision_receipt: { inference_state: { revision: number } }; + } + ).decision_receipt.inference_state.revision, + }); + assert.match(applied.narration, /^这件事(和 05:07 (对得上|对不上|分不出)|记不清)/); + assert.ok(applied.narration.endsWith(RECTIFICATION_USER_COPY.postAdoptVerifyDone), applied.narration); + assert.doesNotMatch(applied.narration, /确认|精确|领先|落后/); +}); + test("skipping the last post-adopt verify question closes with start_consultation", async () => { const dossier = withAcceptedTime(lastVerifyDossier()); const accounting = persistChoiceAccounting(dossier, nextFocusHandler()); diff --git a/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts b/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts new file mode 100644 index 00000000..03d87640 --- /dev/null +++ b/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts @@ -0,0 +1,57 @@ +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 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 +// 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 } : {}), + prior_score: 10, posterior_score: 10, probability: 0.5, status: "active", rank: 1, strong_conflict_count: 0, +}) as never; +const CANDIDATES = [candidate("04:53", ["04:48", "04:57"], ["04:53", "04:55"]), candidate("05:03", ["05:00", "05:07"])]; +const probe = { + id: "probe:education.2016.09.known_event_quality:x", semantic_key: "education.2016.09.known_event_quality", + candidate_split_hash: "x", domain: "education", year: 2016, question: "2016", candidate_ids: ["04:53", "05:03"], + information_gain: 0.4, source: "known_event_quality", + expected_outcomes: [ + { answer_class: "yes", supports: ["04:53"], conflicts: ["05:03"] }, + { answer_class: "no", supports: ["05:03"], conflicts: ["04:53"] }, + { 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, + charts: [ + { chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false }, + { chart: "D9", sign, tier, alternatives: [], tied: false }, + ], +}) as never; + +test("the adopted minute maps to the candidate whose cluster holds it, else the nearest", () => { + assert.equal(candidateForAdoptedMinute("04:55:00", CANDIDATES)?.time, "04:53"); + assert.equal(candidateForAdoptedMinute("05:01", CANDIDATES)?.time, "05:03"); + assert.equal(candidateForAdoptedMinute("05:30", CANDIDATES)?.time, "05:03", "outside every cluster: nearest"); +}); + +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 对得上。"); + assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "no" }), + "这件事和 04:55 对不上;如果别的经历也对不上,可以回到上面的卡片改选。"); + assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "weak_yes" }), "这件事分不出 04:55 和别的时间。"); + assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "unsure" }), "这件事记不清,不算。"); + assert.equal(postAdoptCheckVerdict({ ...base, answerClass: null }), null); + assert.equal(postAdoptCheckVerdict({ ...base, acceptedTime: null, answerClass: "yes" }), null); + assert.equal(postAdoptCheckVerdict({ ...base, probe: null, answerClass: "yes" }), null, "no probe, no claim"); +}); + +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, /确认|精确|相对支持|领先|落后/); +});