From 6df40c322c29f409ba49eeb8604d2ec35831e8e5 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 01:40:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E6=A0=A1=E6=AD=A3=E5=8C=BA=E9=97=B4=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E6=97=B6=E8=AE=B0=E5=BD=95=E4=BC=98=E5=85=88=E3=80=81=E5=88=86?= =?UTF-8?q?=E6=AD=A7=E5=B9=B6=E5=88=97=EF=BC=88BUG-691=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 医院记录那一分钟落在校正区间外时,交付卡以前只说一句「相差 N 分钟」: 没说默认按哪个时间排盘,采用按钮也仍写中性的「更像这个」,用户看不出 点下去会把之后的排盘换成另一分钟。 产品负责人 2026-09-14 拍板「记录优先,分歧如实呈现」: - 冲突文案补满三层——默认仍按出生记录时间排盘、经历指向另一段时间相差 N 分钟、两条路都可以走。 - 冲突态采用按钮改「改用校正结果」,按钮下按列写明 「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。 - 新增 FORBIDDEN_RECORD_VERDICT_PHRASE 锁住 D4:不得宣布记录不准, 也不得宣布校正结果无效。 只改文案层与采用入口措辞。打分、判据、采用 RPC、置信度与确认门控、 数据库一律未动;不新增入口、不加确认弹窗;记录落在范围内或来源是 「大概时间 / 时间段」时卡片与今天完全一致。Skill 未 bump,只补 references §6.1。 验收:tsc 0 错;lint 0 error;npm test 3335 项 fail 31,失败清单与基线 37e6c519 逐条一致(全部是无 Docker / 无外网的既有环境缺口);next build 通过且 / 仍 ○ Static;首屏 chunks gzip +269 B(+0.019%)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 ++ ...ification-record-conflict-copy-20260916.md | 96 ++++++++ docs/tasks/README.md | 2 +- ...ification-record-conflict-copy-20260916.md | 44 ++++ frontend/DESIGN.md | 2 + frontend/docs/VOICE.md | 2 + frontend/src/app/globals.css | 6 + .../rectification-range-delivery.tsx | 16 +- .../birth-time-provenance.ts | 45 +++- .../lib/rectification-agentic/user-copy.ts | 4 + .../v9/divergence-panel.ts | 30 ++- .../tests/agent-voice-copy-contract.test.ts | 14 +- ...ion-birth-time-provenance-20260914.test.ts | 9 +- ...tion-record-conflict-copy-20260916.test.ts | 210 ++++++++++++++++++ .../references/candidate-comparison.md | 11 + 16 files changed, 505 insertions(+), 6 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-record-conflict-copy-20260916.md create mode 100644 docs/testing/rectification-record-conflict-copy-20260916.md create mode 100644 frontend/tests/rectification-record-conflict-copy-20260916.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cffbbfe2..de182a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 出生记录和校正结果对不上时,说清默认按哪个排盘 + +录入时选了「医院记录」,而校正算出的范围不含记录上那一分钟时,交付卡以前只说一句相差多少分钟。现在会写满三层:默认仍按你的出生记录时间排盘、你说的经历指向另一段时间相差多少、你可以改用校正结果也可以继续用记录。这种情况下卡上的按钮从「更像这个」改成「改用校正结果」,按钮下面写明「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。记录落在范围内、或来源是「家人记得的大概时间 / 只知道时段」时,卡片和以前完全一样。系统不会说你的记录不准,也不会说校正结果不作数。Skill 版本不变(只补 references 规则)。 + ## 2026-09-16 — 这段对话写满之前,思考过程不再占用额度 普通咨询里,模型内部的思考过程不再算进「这段对话已写满」的额度。额度仍按你看得到的正文计算,能问的轮次大约从十几轮提到约五十轮。写满时还是请开一个新对话,提示不变。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7d0fd0d9..62b8f395 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10758,6 +10758,22 @@ - 复发自:无 - 修复版本:待发布 +## BUG-691 | 医院记录与校正区间冲突时只并列差值,不说默认按哪个排盘,用户不知道点「更像这个」会换掉什么 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-16 +- 影响面:`frontend/src/lib/rectification-agentic/birth-time-provenance.ts`、`v9/divergence-panel.ts`、`components/rectification-range-delivery.tsx`、`frontend/docs/VOICE.md`、`frontend/DESIGN.md`、Skill `references/candidate-comparison.md` +- 用户现象:录入选了「医院记录」且记录那一分钟落在目前范围外时,交付卡只有一句「出生记录时间 04:40。目前范围不含这一分钟,相差 8 分钟」。没说默认按哪个时间排盘,也没说还能继续用记录;采用按钮仍写中性的「更像这个」,点下去实际上把之后的排盘从出生记录时间换成了另一分钟,用户看不出自己在换什么。 +- 触发条件:`birth_time_source = hospital_record`,且 `reported_birth_time` 落在 `credible_range` 之外,进入区间交付卡。 +- 根因:BUG-690 定了三档来源标签,但把「记录与范围冲突时站哪边」显式挂给产品负责人未决,所以冲突文案只实现了并列差值的前半句;采用入口本就是为非冲突态设计的「更像这个」,没有随来源分档。 +- 修复:产品负责人 2026-09-14 拍板「记录优先,分歧如实呈现」(依据:`docs/research/cluster_width_2026_09_14.md`,封存 20 例六题后头名簇命中率 0.80 / 0.55 / 0.35 对 ±10 / ±30 / ±60 分钟窗,证据强度撑不起推翻书面记录;但医院记录确实会因事后补记、五分钟整数舍入、家属转述而错,故不得反过来宣布校正结果无效)。`reportedTimeOffsetCopy` 的冲突分支补满三层:默认仍按记录排盘 / 经历指向另一段时间差 N 分钟 / 两条路都可以走。新增 `recordRangeConflict` 作为唯一的冲突判据,经 `RangeDeliveryProjection.record_conflict` 投给交付卡:冲突态按钮写「改用校正结果」,按钮下补一行「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。只改文案与措辞,不改打分、判据、采用 RPC、置信度与确认门控,不动数据库,不新增入口。 +- 验证:`frontend/tests/rectification-record-conflict-copy-20260916.test.ts`(冲突判据只认落在范围外的医院记录;冲突文案含三层且不触 `FORBIDDEN_RECORD_VERDICT_PHRASE`;记录落在范围内的文案逐字不变;冲突态按钮与逐列确认句;`approximate` / `period_only` / 记录在范围内三态仍是「更像这个」且无确认句;投影往返后候选与代表分钟不变)。`rectification-birth-time-provenance-20260914.test.ts` 一条断言按「原值 / 新值 / 原因」三栏改写(该用例的 05:12 对 04:48–05:07 本就是冲突态)。`tsc --noEmit` 0 错,`npm run lint` 0 error,`npm test` 失败数与基线同为 31 条且逐条同名(全部是无 Docker / 无外网的既有环境缺口)。 +- 防复发:记录与校正冲突时默认站在记录一边,但两者必须并列;系统不得宣布任何一方无效——不得写「记录不准 / 记录错了 / 以证据为准」,也不得写「校正结果无效 / 不作数」。该禁令由 `FORBIDDEN_RECORD_VERDICT_PHRASE` 在文案层锁住,并写进 `frontend/docs/VOICE.md` 与 Skill `references/candidate-comparison.md` §6.1。冲突态改的只能是措辞,采用仍是校正采用时间,不是已确认的唯一出生分钟(BUG-587 红线维持)。 +- 相关记录:BUG-690、BUG-587、BUG-621 +- 复发自:无 +- 修复版本:待发布 + ## BUG-692 | 交付邀请语写「这两分钟」却与同段「4 个候选」矛盾;5 条契约断言未跟上 - 状态:resolved diff --git a/docs/tasks/PROGRESS-rectification-record-conflict-copy-20260916.md b/docs/tasks/PROGRESS-rectification-record-conflict-copy-20260916.md new file mode 100644 index 00000000..1a4e67bc --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-record-conflict-copy-20260916.md @@ -0,0 +1,96 @@ +# PROGRESS · 记录与校正区间冲突:记录优先,分歧如实呈现(BUG-691) + +- 任务书:`docs/tasks/TASK-rectification-record-conflict-copy-20260914.md` +- 分支:`codex/rectification-record-conflict-copy-20260916`,worktree `.worktrees/rectification-record-conflict-copy-20260916` +- 实际基线:`origin/staging` @ `37e6c519`(任务书写的基线 `c5fbee56` 已被 staging 超车约 40 个提交) +- 执行日期:2026-09-16 + +## 0. 开工第一件事:事故是否仍复现 + +**仍然复现。** 任务书基线停在 09-14,其后 staging 合入了 BUG-721~733 等大量校正链路改动,所以先逐条核对了 §事故实证: + +- `frontend/src/lib/rectification-agentic/birth-time-provenance.ts` 的 `reportedTimeOffsetCopy` 在 `hospital_record` + 记录落在范围外时,返回的仍然只有前半句: + `出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟` + D2 要求的三层(默认按哪个排盘 / 经历指向另一段时间 / 两条路都可以走)一层都没有。 +- 交付卡 `frontend/src/components/rectification-range-delivery.tsx` 的采用按钮在任何来源下都写 `RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis`(「更像这个」),没有随来源或冲突态分档;按钮附近没有任何说明「换掉的是什么」的文案。 +- `docs/BUG_HISTORY.md` 里 BUG-691 查无此号(690 与 692 之间是真空号),BUG-690 的「修复」一栏还明确写着「医院记录与范围冲突时如何取舍挂给产品负责人」——本单正是那条挂起项的收口。 + +结论:没有被后来的轮次顺带修掉,按任务书执行。 + +## 1. 编号 + +`docs/BUG_HISTORY.md` 当前最大号是 **BUG-733**,但 691 是真空号(690 → 692)。任务书指定 BUG-691,且本单在语义上就是 BUG-690 的 D4 收口,故**填回 691 这个空号**并插在 690 与 692 之间,保持编号连续,不另起 734。 + +## 2. 改了什么 + +只动文案层与采用入口措辞。打分、判据、采用 RPC、置信度与确认门控、数据库一律未动。 + +| 文件 | 改动 | +|---|---| +| `frontend/src/lib/rectification-agentic/birth-time-provenance.ts` | 冲突分支文案补满 D2 三层;新增 `recordRangeConflict()` 作为唯一冲突判据;新增 `SWITCH_TO_RECTIFIED_LABEL`、`switchToRectifiedConfirmCopy()`、`FORBIDDEN_RECORD_VERDICT_PHRASE`(D4 锁) | +| `frontend/src/lib/rectification-agentic/v9/divergence-panel.ts` | `RangeDeliveryProjection` 增 `record_conflict` 字段(build 与 parse 两侧对称),只是把判据结果投给 UI | +| `frontend/src/components/rectification-range-delivery.tsx` | 冲突态按钮改「改用校正结果」;按钮下按列补一行「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」;已采用的列不显示该行 | +| `frontend/src/app/globals.css` | 新增 `.rectification-range-delivery__switch-note`,与既有 `__provenance` 同字号同行高,未改间距与列宽 | +| `frontend/src/lib/rectification-agentic/user-copy.ts` | 新文案登记进 `listUserVisibleCopy()`,纳入机器腔词表与 D4 体检 | +| `frontend/docs/VOICE.md` | 增两行好/坏对照:D4 禁止措辞;冲突态按钮与确认句 | +| `frontend/DESIGN.md` | §11 区间交付卡补「冲突时换措辞,不换形状」一段 | +| `skills/.../references/candidate-comparison.md` | 新增 §6.1,写入 D1–D4 与拍板依据 | + +### 具体文案 + +冲突态来源小字(原来只有第一句的前半): + +> 出生记录时间 05:12,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 5 分钟。你可以改用校正结果,也可以继续用记录 + +冲突态按钮:`更像这个` → `改用校正结果` +按钮下每列各一行:`选它之后,排盘会从出生记录时间 05:12 换成 04:53` + +记录落在范围内时逐字不变:`出生记录时间 05:00,落在目前范围内`,按钮仍是「更像这个」。 + +### 红线自查 + +- 硬红线 3(不新增多余入口):复用同一个采用按钮与同一个 accept RPC,没有加第二个按钮,也没有加确认弹窗。D3 说的「确认处」落在按钮正下方的说明行——这是动手的地方,且能给出精确的 hh:mm → hh:mm。 +- 硬红线 4(BUG-587):冲突态文案与确认句都没有出现「已确认 / 确定就是 / 唯一出生分钟是」,由测试正则锁住;采用仍是「校正采用时间」。 +- D5:`recordRangeConflict` 只读 `source` / `reportedTime` / `range` 三个已有量,不参与任何排序或淘汰;测试里额外断言冲突态与非冲突态的候选列表和代表分钟完全相同。 +- Skill **未 bump**(仍 10.0.26),按任务书 §5 让步顺序 2 只改 references。理由:本单不改 Agent 的工具链或判据,只补一条表达规则;bump 会让存量 Case 绑旧版本,按 BUG-621 教训得额外验历史可打开,收益不抵风险。`docs/testing/` 清单里仍保留一条「历史 Case 能打开」的人工检查。 + +## 3. 验收数字 + +- `./node_modules/.bin/tsc --noEmit`:**0 错**。 +- `npm run lint`:**0 error**(120 warning,全部是既有的 `no-unused-vars`,不在本单范围内,未顺手修)。 +- 定向:`npx tsx --test tests/rectification-record-conflict-copy-20260916.test.ts tests/rectification-birth-time-provenance-20260914.test.ts` → 9 tests / 9 pass / 0 fail。 + `npx tsx --test tests/agent-voice-copy-contract.test.ts` → 18 tests / 18 pass / 0 fail。 +- 全量 `npm test`: + | | 基线 `37e6c519` | 本分支 | + |---|---|---| + | tests | 3329 | 3335 | + | pass | 3283 | 3288 | + | fail | **31** | **31** | + | skipped | 15 | 15 | + + 失败清单逐条 `diff` 后**完全一致**(31 条同名),全部是既有环境缺口:无 Docker 的数据库 / 迁移 / 计费 / RLS 套件,以及无外网的 ephemeris / VedAstro / staging 同步套件。 + + 注:任务书硬红线写「失败数 = 基线 27 条」,那是 09-14 的数字;在实际基线 `37e6c519` 上实测就是 31 条,口径按实测基线走。 +- 新增测试 6 条(`rectification-record-conflict-copy-20260916.test.ts`),测试总数只增不减。 +- `npm run build`:通过,`/` 仍是 `○ (Static)`。 +- 首屏体积:同机各构建一次(基线用 `git stash` 把本轮改动摘掉后重建),`.next/static/chunks/**/*.js` 逐个 gzip 求和 —— 基线 **1,438,383 B**,本分支 **1,438,652 B**,**+269 B / +0.019 %**,在 ±2% 内。(Next 16 Turbopack 的构建输出不再打印 First Load JS 一列,故改用这个可复算的口径。) + +### 改过的既有断言(AGENTS §7.3 三栏说明) + +两条,都是同一个原因——被改的用例本身就处在冲突态: + +| 文件 | 原值 | 新值 | 原因 | +|---|---|---|---| +| `tests/rectification-birth-time-provenance-20260914.test.ts` | `assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis))` | `assert.ok(html.includes(SWITCH_TO_RECTIFIED_LABEL))` + 断言不再出现「更像这个」 | BUG-691 D3。该用例是 05:12 对 04:48–05:07,正是冲突态。非冲突态的「更像这个」由新测试逐态锁住,不是弱化 | +| `tests/agent-voice-copy-contract.test.ts` | `assert.match(visible, /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/)` | 拆成三层断言(默认按记录排盘 / 指向另一段时间差 5 分钟 / 两条路都可以走)+ 按钮与确认句断言 + 全量 `FORBIDDEN_RECORD_VERDICT_PHRASE` 体检 | BUG-691 D2、D3。断言数从 1 条增到 6 条,是收紧不是放松 | + +## 4. 环境缺口 + +- 无 Docker:数据库、迁移、计费、RLS 套件跑不了,失败清单与基线逐条一致(见上表)。 +- 无外网:ephemeris / VedAstro / staging 同步套件同上。 +- 无登录态与 Chrome:真实会话里「有医院记录 + 区间不含该分钟」的走查做不了,已写成 `docs/testing/rectification-record-conflict-copy-20260916.md`。 +- 本轮不动表,未跑 `npm run test:db`。 + +## 5. 交付 + +- 只提交到 `codex/rectification-record-conflict-copy-20260916`,**未推 staging**,验收与合入由 Claude 会话做。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1507c2da..ae4a1998 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -197,7 +197,7 @@ | `TASK-rectification-invite-copy-and-assertions-fix-20260915.md` | `PROGRESS-rectification-invite-copy-fix-20260915.md` | **P0 验收修复单**:BUG-689 的邀请语硬编码「这两分钟」却与同段话的「4 个候选」矛盾;BUG-689/690 各自把 4 条与 1 条契约断言留红(`npm test` 27→32)。修文案按候选数出话、两处断言跟上、并给精度研究 M1b 的 V1/V2 追加「未测」勘误(BUG-692) | 待验收 | `codex/rectification-invite-copy-fix-20260915` | -| `TASK-rectification-record-conflict-copy-20260914.md` | `PROGRESS-rectification-record-conflict-copy-20260914.md` | BUG-690 的 D4 收口:产品 2026-09-14 拍板**记录优先、分歧如实呈现**——默认仍按出生记录时间排盘(行为已成立),校正区间标成「证据倾向」,采用入口在冲突时改成「改用校正结果」;系统不得宣布记录错或校正无效(BUG-691) | 待执行 | `codex/rectification-record-conflict-copy-20260914` | +| `TASK-rectification-record-conflict-copy-20260914.md` | `PROGRESS-rectification-record-conflict-copy-20260916.md` | BUG-690 的 D4 收口:产品 2026-09-14 拍板**记录优先、分歧如实呈现**——默认仍按出生记录时间排盘(行为已成立),校正区间标成「证据倾向」,采用入口在冲突时改成「改用校正结果」;系统不得宣布记录错或校正无效(BUG-691) | 已实现待验收 | `codex/rectification-record-conflict-copy-20260916`。开工先在 `37e6c519` 复验,事故**仍复现**(冲突文案只有前半句、按钮任何来源都写「更像这个」)。BUG-691 填回 690↔692 的真空号。只改文案层:冲突文案补满三层、冲突态按钮改「改用校正结果」+ 逐列写明 hh:mm→hh:mm;打分/判据/采用 RPC 未动,Skill 未 bump(只补 references §6.1)。tsc 0、lint 0 error、全量 3335/fail 31 与基线失败清单逐条一致。**未推 staging** | | `TASK-rectification-birth-time-provenance-20260914.md` | `PROGRESS-rectification-birth-time-provenance-20260914.md` | 与上游流程对照发现的缺口(上游 R2):录入时已问「医院记录 / 家人记得大概 / 只知道时段」并落库 `birth_time_source`,但整条校正链只在放宽窗口时用过一次,决策与文案一律不看它——家人推算的时间和出生证在系统里等同对待,输出还直接称「你的出生时间」。本单把来源标签接进投影并分档措辞(BUG-690) | 待验收 | `codex/rectification-birth-time-provenance-20260914` | diff --git a/docs/testing/rectification-record-conflict-copy-20260916.md b/docs/testing/rectification-record-conflict-copy-20260916.md new file mode 100644 index 00000000..91b627bf --- /dev/null +++ b/docs/testing/rectification-record-conflict-copy-20260916.md @@ -0,0 +1,44 @@ +# 真人验收清单 · 记录与校正区间冲突文案(BUG-691) + +自动化能锁住文案与按钮措辞,但造不出「有医院记录、且校正区间不含那一分钟」的真实会话。以下需要在 staging 用受控账号照做。 + +前置:`https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于本单合入后的 staging 提交。 + +## 1. 造一个冲突案子 + +1. 资料里新建一个虚构出生资料(不得用真人),出生时间填一个整分钟,**来源选「医院记录」**。 +2. 开一次生时校正,正常回答采集与区分题,直到出交付卡。 +3. 看交付卡顶部的范围。**需要范围不含你填的那一分钟**——若恰好含在范围内,换一个把记录时间填在窗口边缘的资料重来(例如资料写 05:12,而范围收在 04:48–05:07)。 + +## 2. 冲突态该看到什么 + +- [ ] 来源小字写满三层,措辞近似:「出生记录时间 05:12,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 5 分钟。你可以改用校正结果,也可以继续用记录」。 +- [ ] 每一列的按钮写「**改用校正结果**」,不是「更像这个」。 +- [ ] 每个按钮下面有一行灰字,写明这一列换的是哪两个钟点:「选它之后,排盘会从出生记录时间 05:12 换成 04:53」。三列各写各的目标分钟,不得三列都写同一个。 +- [ ] 卡片形状没变:仍是至多三列并排、共有性格句只在顶部写一次、底部边界句还在、「查看验证报告」默认收起。没有多出任何按钮,也没有确认弹窗。 + +## 3. 不得出现的说法(D4) + +通读卡片、旁白和采用后的收尾句,以下任何一种都算不通过: + +- [ ] 「你的出生记录错了 / 记录不准 / 记录有误」 +- [ ] 「以证据为准 / 以记录为准 / 应该信……」 +- [ ] 「校正结果无效 / 不作数」 +- [ ] 把采用后的分钟说成「已确认的出生分钟」(BUG-587 红线) + +## 4. 采用路径仍然正常 + +- [ ] 点「改用校正结果」后按钮依次显示「正在采用…」「已采用」,整张卡留在原处不闪。 +- [ ] 已采用的那一列不再显示第 2 节那行灰字。 +- [ ] 采用后的收尾句与卡片同一列对齐,没有 spinner、没有采用状态条。 +- [ ] 刷新页面后历史 Case 能正常打开(本单未 bump Skill 版本,但仍要确认,见 BUG-621 教训)。 + +## 5. 非冲突态回归(必须与今天一致) + +- [ ] 医院记录那一分钟**落在**范围内:小字写「出生记录时间 hh:mm,落在目前范围内」,按钮仍是「更像这个」,按钮下无灰字。 +- [ ] 来源是「家人记得」:小字写「与你填的大概时间 hh:mm 相差 N 分钟」,按钮仍是「更像这个」。 +- [ ] 来源是「只知道时段」:小字写「与你给的时间段 hh:mm 相差 N 分钟」,按钮仍是「更像这个」。 + +## 6. 记录 + +任何一条不通过,写成 `TASK-rectification-record-conflict-copy-fix-<日期>.md`,并在 `docs/BUG_HISTORY.md` 里关联 BUG-691。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index fe92ee81..1328e067 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -548,6 +548,8 @@ Admin 的 antd `` 是独立设计系统,不在此表。 卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。 +**出生记录与范围冲突时换措辞,不换形状。** 医院记录的那一分钟落在目前范围外时(`record_conflict`),来源小字写满三层:默认仍按记录排盘、经历指向另一段时间差 N 分钟、两条路都可以走;每列按钮从「更像这个」改成「改用校正结果」,按钮下方补一行同为 `--type-caption` 的灰字「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」(`.rectification-range-delivery__switch-note`,与来源小字同字号同行高)。不新增按钮、不加确认弹窗、不改列数与间距;记录落在范围内或来源是「大概时间 / 时间段」时,卡片与今天完全一致。已采用的那一列不再显示这行小字。 + 引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。 ## 12. 校正右栏盘面 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index cfd02c0d..396fd425 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -49,6 +49,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 | | 能问的都问完了 | 还剩 2 个候选时:「这两分钟按现有信息分不开。」还剩 4 个时:「这几个候选按现有信息分不开。」候选数未知时只说「按现有信息分不开。」后面接:你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。句首的「这两分钟 / 这几个候选」必须和同段话里的候选数一致,不得硬编码。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 | | 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 | +| 你的出生记录不准,按校正结果来 / 这些经历说明记录写错了 / 校正结果无效 | 出生记录时间 04:40,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 8 分钟。你可以改用校正结果,也可以继续用记录。 | 记录与校正区间冲突时记录优先,但两者必须并列。不得宣布任何一方无效——不说记录不准,也不说校正结果不作数。 | +| (冲突时按钮仍写)更像这个 | 按钮写「改用校正结果」,按钮下面一行写「选它之后,排盘会从出生记录时间 04:40 换成 04:48」。 | 换掉的是什么要在动手的地方说清楚。不新增入口,仍是同一个采用按钮;采用仍不是确认。 | | 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集只有输入框,没有「没有」「记不清」芯片,也不挂在输入框上方。范围小字是状态句,不可点。 | 想跳过可以打字「没有」或「记不清」。 | | 只报一件后立刻按家人/学业/财务轮着问,题干带「2020 年前后」 | 记下后问「还有吗?比如……」(只列还没提过的具体事)。答「没有了」后才从用户说过的年份追问,例如「2020 年毕业后第一份工作大概哪年开始?」 | 先等用户说完;年份只来自用户,不用生日推。 | | 点选后旁白写「04:31–04:39 这段领先,05:00–05:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 36ed4f1d..93da5c90 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3338,6 +3338,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-caption); line-height: 1.5; } +.rectification-range-delivery__switch-note { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-caption); + line-height: 1.5; +} .rectification-range-delivery__boundary { margin: 0; color: var(--color-ink-secondary); diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index 04449f1a..33ae0bb4 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -10,6 +10,10 @@ import { rangeDeliveryShowsOpenCollectInvite, } from "@/lib/rectification-agentic/user-copy"; import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel"; +import { + SWITCH_TO_RECTIFIED_LABEL, + switchToRectifiedConfirmCopy, +} from "@/lib/rectification-agentic/birth-time-provenance"; import { RectificationVerificationReport } from "@/components/rectification-verification-report"; function columnHasTraits(column: RangeDeliveryProjection["columns"][number]): boolean { @@ -48,6 +52,9 @@ export function RectificationRangeDelivery({ hint: delivery?.narrow_hint, columns, }); + // BUG-691: while the birth record disagrees with the range the adopt entry says what + // it actually does — swap the charting clock — instead of the neutral 「更像这个」. + const recordConflict = delivery?.record_conflict ?? null; const caption = showOpenCollectInvite || closedInvite ? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint) : delivery?.narrow_hint ?? null; @@ -129,8 +136,15 @@ export function RectificationRangeDelivery({ ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted - : RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis} + : recordConflict + ? SWITCH_TO_RECTIFIED_LABEL + : RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis} + {recordConflict && !adopted ? ( +

+ {switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)} +

+ ) : null} ); })} diff --git a/frontend/src/lib/rectification-agentic/birth-time-provenance.ts b/frontend/src/lib/rectification-agentic/birth-time-provenance.ts index 9f48bd5c..7bb92b58 100644 --- a/frontend/src/lib/rectification-agentic/birth-time-provenance.ts +++ b/frontend/src/lib/rectification-agentic/birth-time-provenance.ts @@ -24,6 +24,17 @@ export const REPORTED_TIME_LABEL: Readonly> = { /** Approximate / period_only copy must not match this. Hospital uses 出生记录时间. */ export const FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE = /你的出生时间/; +/** + * BUG-691 / D4: when the birth record and the rectified range disagree the product + * states both sides and the gap. It must never declare either side invalid — not the + * record ("记录不准"), and not the rectification ("校正结果无效"). + */ +export const FORBIDDEN_RECORD_VERDICT_PHRASE = + /记录不准|记录不对|记录错了|记录有误|记录写错|以记录为准|以证据为准|校正结果无效|校正结果不作数|应该信/; + +/** Adopt entry wording while the record and the range disagree (BUG-691 / D3). */ +export const SWITCH_TO_RECTIFIED_LABEL = "改用校正结果"; + export function reportedTimeLabel(source: string | null | undefined): string { return REPORTED_TIME_LABEL[normalizeBirthTimeSource(source)]; } @@ -104,10 +115,42 @@ export function reportedTimeOffsetCopy(input: { ) : offset; const gap = Number.isFinite(edge) ? edge : offset; - return `出生记录时间 ${reported}。目前范围不含这一分钟,相差 ${gap} 分钟`; + // BUG-691 / D2: record first, disagreement stated in full. Three beats — + // what we chart by now, where the evidence points, and that the choice is the user's. + return `出生记录时间 ${reported},默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 ${gap} 分钟。你可以改用校正结果,也可以继续用记录`; } if (source === "period_only") { return `与你给的时间段参照 ${reported} 相差 ${offset} 分钟`; } return `与你填的大概时间 ${reported} 相差 ${offset} 分钟`; } + +/** + * The declared birth record sits outside the current range (BUG-691). + * Only `hospital_record` can conflict: the other two sources are already stated as + * approximate, so there is nothing to overrule. Returns null when there is no conflict. + */ +export function recordRangeConflict(input: { + source?: string | null; + reportedTime?: string | null; + range?: readonly [string, string] | null; +}): Readonly<{ reportedTime: string; gapMinutes: number }> | null { + if (normalizeBirthTimeSource(input.source) !== "hospital_record") return null; + const reported = (input.reportedTime ?? "").trim().slice(0, 5); + if (!CLOCK.test(reported)) return null; + const range = input.range ?? null; + if (!range) return null; + if (clockInRange(reported, range)) return null; + const toStart = minutesBetweenClocks(reported, range[0]); + const toEnd = minutesBetweenClocks(reported, range[1]); + if (toStart == null || toEnd == null) return null; + return { reportedTime: reported, gapMinutes: Math.min(toStart, toEnd) }; +} + +/** + * Said again at the point of action (BUG-691 / D3), so the reader sees exactly which + * clock replaces which. Adoption stays a 校正采用时间, never a confirmed minute. + */ +export function switchToRectifiedConfirmCopy(reportedTime: string, targetTime: string): string { + return `选它之后,排盘会从出生记录时间 ${reportedTime} 换成 ${targetTime}`; +} diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 5102988a..0261a8b2 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -12,6 +12,8 @@ import { REPORTED_TIME_LABEL, boardDeclaredTimeLine, reportedTimeOffsetCopy, + SWITCH_TO_RECTIFIED_LABEL, + switchToRectifiedConfirmCopy, } from "./birth-time-provenance.ts"; export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。"; @@ -685,6 +687,8 @@ export function listUserVisibleCopy(): string[] { range: ["04:48", "05:07"], representativeTime: "04:53", }) ?? "", + SWITCH_TO_RECTIFIED_LABEL, + switchToRectifiedConfirmCopy("05:12", "04:53"), ]; return values; } diff --git a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts index 6be89f64..003e8d26 100644 --- a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts +++ b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts @@ -18,7 +18,7 @@ import { rangeDeliveryWindowLine, sharedTraitLine, } from "../user-copy.ts"; -import { reportedTimeOffsetCopy } from "../birth-time-provenance.ts"; +import { recordRangeConflict, reportedTimeOffsetCopy } from "../birth-time-provenance.ts"; import { previousInferenceFromReceipt } from "./inference-adapter.ts"; import { rangeNarrowHint, @@ -70,6 +70,11 @@ export type RangeDeliveryColumn = Readonly<{ window_line: string; }>; +export type RangeDeliveryRecordConflict = Readonly<{ + reported_time: string; + gap_minutes: number; +}>; + export type RangeDeliveryProjection = Readonly<{ range: readonly [string, string] | null; representative_time: string | null; @@ -86,6 +91,8 @@ export type RangeDeliveryProjection = Readonly<{ tie_break_available: boolean; tie_break_note: string | null; provenance_line: string | null; + /** Set only when a hospital record sits outside the range (BUG-691). */ + record_conflict: RangeDeliveryRecordConflict | null; }>; export type PublicCandidateClock = Readonly<{ @@ -438,9 +445,29 @@ export function buildRangeDelivery(input: { range: normalizedRange, representativeTime, }), + record_conflict: recordConflictProjection(recordRangeConflict({ + source: input.birthTimeSource, + reportedTime: input.reportedBirthTime, + range: normalizedRange, + })), }; } +function recordConflictProjection( + conflict: Readonly<{ reportedTime: string; gapMinutes: number }> | null, +): RangeDeliveryRecordConflict | null { + return conflict ? { reported_time: conflict.reportedTime, gap_minutes: conflict.gapMinutes } : null; +} + +function recordConflictFromUnknown(value: unknown): RangeDeliveryRecordConflict | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + const reported = clock(row.reported_time ?? row.reportedTime); + const gap = row.gap_minutes ?? row.gapMinutes; + if (!reported || typeof gap !== "number" || !Number.isFinite(gap)) return null; + return { reported_time: reported, gap_minutes: gap }; +} + function publicCandidatesFromUnknown(value: unknown): PublicCandidateClock[] { if (!Array.isArray(value)) return []; const out: PublicCandidateClock[] = []; @@ -688,5 +715,6 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu : typeof row.provenanceLine === "string" && row.provenanceLine.trim() ? row.provenanceLine.trim() : null, + record_conflict: recordConflictFromUnknown(row.record_conflict ?? row.recordConflict), }; } diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index f25a216f..fe977423 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -20,6 +20,7 @@ import { } from "../src/lib/rectification-agentic/user-copy.ts"; import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts"; +import { FORBIDDEN_RECORD_VERDICT_PHRASE } from "../src/lib/rectification-agentic/birth-time-provenance.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts"; @@ -381,7 +382,18 @@ test("declared-time copy splits hospital records from family estimates", () => { assert.match(visible, /你填的大概时间/); assert.match(visible, /你给的时间段/); assert.match(visible, /与你填的大概时间 05:00 相差 7 分钟/); - assert.match(visible, /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/); + // 原值: /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/ + // 新值: 三层冲突文案(默认按记录排盘 / 指向另一段时间差 5 分钟 / 两条路都可以走)+ 采用入口措辞 + // 原因: BUG-691 决策 D2、D3——冲突时只并列差值不够,必须说清默认按哪个排盘、改用会换掉什么 + assert.match(visible, /出生记录时间 05:12,默认仍按这个时间排盘/); + assert.match(visible, /指向另一段时间,相差 5 分钟/); + assert.match(visible, /你可以改用校正结果,也可以继续用记录/); + assert.match(visible, /改用校正结果/); + assert.match(visible, /选它之后,排盘会从出生记录时间 05:12 换成 04:53/); + // D4: 并列不等于站队,任何一方都不得被宣布无效。 + for (const item of listUserVisibleCopy()) { + assert.doesNotMatch(item, FORBIDDEN_RECORD_VERDICT_PHRASE, item); + } const approximate = listUserVisibleCopy().filter((item) => item.includes("大概时间") || item.includes("时间段")); for (const item of approximate) { assert.doesNotMatch(item, /你的出生时间/); diff --git a/frontend/tests/rectification-birth-time-provenance-20260914.test.ts b/frontend/tests/rectification-birth-time-provenance-20260914.test.ts index cb87d245..3ece004a 100644 --- a/frontend/tests/rectification-birth-time-provenance-20260914.test.ts +++ b/frontend/tests/rectification-birth-time-provenance-20260914.test.ts @@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE, + SWITCH_TO_RECTIFIED_LABEL, boardDeclaredTimeLine, normalizeBirthTimeSource, reportedTimeOffsetCopy, @@ -179,5 +180,11 @@ test("hospital_record copy names the record and states the offset without pickin })); assert.match(html, /出生记录时间 05:12/); assert.match(html, /相差 5 分钟/); - assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis)); + // 原值: assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis)) + // 新值: 记录落在范围外时按钮写 SWITCH_TO_RECTIFIED_LABEL(「改用校正结果」) + // 原因: BUG-691 决策 D3——记录与范围冲突时采用入口要说明自己换掉的是什么; + // 05:12 对 04:48–05:07 正是冲突态。非冲突态仍走「更像这个」, + // 由 rectification-record-conflict-copy-20260916.test.ts 逐态锁住。 + assert.ok(html.includes(SWITCH_TO_RECTIFIED_LABEL)); + assert.equal(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false); }); diff --git a/frontend/tests/rectification-record-conflict-copy-20260916.test.ts b/frontend/tests/rectification-record-conflict-copy-20260916.test.ts new file mode 100644 index 00000000..c504a32e --- /dev/null +++ b/frontend/tests/rectification-record-conflict-copy-20260916.test.ts @@ -0,0 +1,210 @@ +/** + * BUG-691 · 记录与校正区间冲突时:记录优先,分歧如实呈现。 + * + * 产品拍板(2026-09-14):默认仍按出生记录时间排盘,但校正结果必须并列呈现, + * 系统不得宣布任何一方无效。本文件锁的是文案层与采用入口措辞,不涉及打分与采用逻辑。 + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + FORBIDDEN_RECORD_VERDICT_PHRASE, + SWITCH_TO_RECTIFIED_LABEL, + recordRangeConflict, + reportedTimeOffsetCopy, + switchToRectifiedConfirmCopy, +} from "../src/lib/rectification-agentic/birth-time-provenance.ts"; +import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; +import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; +import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; + +const RANGE = ["04:48", "05:07"] as const; +/** Outside RANGE by 5 minutes — the conflict case. */ +const RECORD_OUTSIDE = "05:12"; +/** Inside RANGE — must keep the pre-BUG-691 wording. */ +const RECORD_INSIDE = "05:00"; + +function cand(time: string, probability: number): InferenceCandidate { + return { + id: time, + time, + cluster_range: [RANGE[0], RANGE[1]], + prior_score: probability * 100, + posterior_score: probability * 100, + probability, + status: "active", + rank: 1, + strong_conflict_count: 0, + }; +} + +function inference(): InferenceState { + return { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: "set", + revision: 1, + phase: "discrimination", + result_status: "credible_range", + range_start: RANGE[0], + range_end: RANGE[1], + candidates: [cand("04:53", 0.4), cand("04:50", 0.35)], + events: [], + probes: [], + answered_probes: [], + rounds: [], + entropy: 1, + representative_time: "04:53", + credible_range: [RANGE[0], RANGE[1]], + }; +} + +function delivery(reportedBirthTime: string, birthTimeSource: string) { + return buildRangeDelivery({ + inference: inference(), + publicCandidates: [ + { candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "04:53" }, + { candidateId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", time: "04:50" }, + ], + credibleRange: [RANGE[0], RANGE[1]], + representativeTime: "04:53", + reportedBirthTime, + birthTimeSource, + }); +} + +function deliveryResult(built: ReturnType): RectificationCandidateResult { + return { + resultId: "result-record-conflict", + candidates: built.columns.map((column, index) => ({ + candidateId: column.candidate_id, + rank: index + 1, + time: column.time, + relativeSupport: column.probability_percent, + tiedMinuteCount: 1, + })), + overallConfidence: "medium", + selectionAllowed: true, + canAdopt: true, + confirmationAllowed: false, + decisionReceipt: null, + representativeTime: built.representative_time, + selectedTime: null, + selectionKind: null, + houseTable: null, + houseTablesByTime: {}, + natalRecast: null, + techniqueAudit: [], + windowTransitions: [], + eventDashaLedger: [], + dashaAgreement: null, + lagnaContrast: null, + nakshatraBoundary: null, + precisionStage: null, + oosBlindPrompts: [], + confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"], + validated: false, + completionStatus: null, + sessionOutcome: "adopt_representative", + credibleRange: built.range, + rangeDelivery: built, + verificationReportMarkdown: built.verification_markdown, + }; +} + +function html(reportedBirthTime: string, birthTimeSource: string): string { + return renderToStaticMarkup(createElement(RectificationRangeDelivery, { + result: deliveryResult(delivery(reportedBirthTime, birthTimeSource)), + acceptingCandidateId: null, + readonly: false, + onAccept: () => undefined, + })); +} + +test("conflict is only a hospital record that falls outside the range", () => { + assert.deepEqual( + recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: RANGE }), + { reportedTime: RECORD_OUTSIDE, gapMinutes: 5 }, + ); + // Inside the range there is nothing to reconcile. + assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_INSIDE, range: RANGE }), null); + // The other two sources are already declared approximate, so they cannot conflict. + assert.equal(recordRangeConflict({ source: "approximate", reportedTime: RECORD_OUTSIDE, range: RANGE }), null); + assert.equal(recordRangeConflict({ source: "period_only", reportedTime: RECORD_OUTSIDE, range: RANGE }), null); + // Missing or unusable inputs are not a conflict. + assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: null }), null); + assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "", range: RANGE }), null); + assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "25:99", range: RANGE }), null); +}); + +test("conflict copy states record-first default, the divergence, and the user's choice", () => { + const copy = reportedTimeOffsetCopy({ + source: "hospital_record", + reportedTime: RECORD_OUTSIDE, + range: RANGE, + representativeTime: "04:53", + }) ?? ""; + // D2 ①: the chart still uses the record until the user says otherwise. + assert.match(copy, /出生记录时间 05:12/); + assert.match(copy, /默认仍按这个时间排盘/); + // D2 ②: the evidence points somewhere else, and the gap is named. + assert.match(copy, /指向另一段时间/); + assert.match(copy, /相差 5 分钟/); + // D2 ③: both doors stay open. + assert.match(copy, /改用校正结果/); + assert.match(copy, /继续用记录/); + // D4: neither side may be declared invalid. + assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE); + // BUG-587: adoption is never described as a confirmed minute. + assert.doesNotMatch(copy, /已确认|确定就是|唯一出生分钟是/); +}); + +test("a record inside the range keeps the pre-BUG-691 wording", () => { + const copy = reportedTimeOffsetCopy({ + source: "hospital_record", + reportedTime: RECORD_INSIDE, + range: RANGE, + representativeTime: "04:53", + }) ?? ""; + assert.equal(copy, "出生记录时间 05:00,落在目前范围内"); + assert.doesNotMatch(copy, /改用校正结果/); + assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE); +}); + +test("conflict swaps the adopt entry wording and says which clock replaces which", () => { + const conflict = html(RECORD_OUTSIDE, "hospital_record"); + assert.ok(conflict.includes(SWITCH_TO_RECTIFIED_LABEL)); + assert.equal(conflict.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false); + // The confirmation line is per column, so each button names its own target minute. + assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:53"))); + assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:50"))); + assert.doesNotMatch(conflict, FORBIDDEN_RECORD_VERDICT_PHRASE); +}); + +test("non-conflict states keep 更像这个 and show no switch note", () => { + for (const [reported, source] of [ + [RECORD_INSIDE, "hospital_record"], + [RECORD_OUTSIDE, "approximate"], + [RECORD_OUTSIDE, "period_only"], + ] as const) { + const markup = html(reported, source); + assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`); + assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`); + assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`); + } +}); + +test("record_conflict survives the projection round trip and stays adopt-logic free", () => { + const built = delivery(RECORD_OUTSIDE, "hospital_record"); + assert.deepEqual(built.record_conflict, { reported_time: RECORD_OUTSIDE, gap_minutes: 5 }); + assert.equal(delivery(RECORD_INSIDE, "hospital_record").record_conflict, null); + // Adoption itself is untouched: the same candidates stay selectable either way. + assert.deepEqual( + built.columns.map((column) => column.candidate_id), + delivery(RECORD_INSIDE, "hospital_record").columns.map((column) => column.candidate_id), + ); + assert.equal(built.representative_time, delivery(RECORD_INSIDE, "hospital_record").representative_time); +}); diff --git a/skills/jyotish-birth-time-rectification/references/candidate-comparison.md b/skills/jyotish-birth-time-rectification/references/candidate-comparison.md index 28e0b708..ec2ac42e 100644 --- a/skills/jyotish-birth-time-rectification/references/candidate-comparison.md +++ b/skills/jyotish-birth-time-rectification/references/candidate-comparison.md @@ -65,6 +65,17 @@ 与填报时间比较时:`hospital_record` 可写「出生记录时间 HH:MM」并如实给出与目前范围的差值,不给「以记录为准 / 以证据为准」的倾向;其余来源只写「与你填的大概时间相差 N 分钟」。 +### 6.1 记录与目前范围冲突(`hospital_record` 落在范围外) + +产品负责人 2026-09-14 拍板:**记录优先,分歧如实呈现。** 依据两条:封存 20 例上六题后头名簇命中率是 0.80 / 0.55 / 0.35(±10 / ±30 / ±60 分钟窗,见 `docs/research/cluster_width_2026_09_14.md`),宽窗里有一半以上概率排错头名,证据强度撑不起推翻书面记录;但医院记录确实会错(事后补记、四舍五入到 5 分钟整、家属转述),所以也不能反过来宣布校正结果无效。 + +- **D1 默认仍按出生记录时间排盘。** 这是既有行为——采用是用户主动动作,不采用就继续用填报时间。本节只要求把它说出来,不改行为。 +- **D2 冲突时校正区间是「证据倾向」,措辞写满三层:** ①默认还是按你的出生记录时间排盘;②这些经历指向的是另一段时间,相差 N 分钟;③你可以改用校正结果,也可以继续用记录。 +- **D3 采用入口改措辞:** 不写「采用」,写「改用校正结果」,并在动手的地方再说一次「之后的排盘会从出生记录时间 HH:MM 换成 HH:MM」。仍是同一个采用按钮,不新增入口、不加确认弹窗。 +- **D4 不得宣布任何一方无效。** 禁止「你的出生记录错了 / 记录不准 / 以证据为准」,也禁止「校正结果无效 / 不作数」。只陈述差值与各自依据。 + +记录落在目前范围内时不适用本节:仍写「出生记录时间 HH:MM,落在目前范围内」,采用入口措辞不变。采用在冲突态下仍然只是校正采用时间,不是已确认的唯一出生分钟。 + ## 7. 保存边界 - accepted 写入 `active_birth_time`,保留 `reported_birth_time` 原填报,不写兼容 `birth_time`。