From 039b0a26086ddb496c5a13e6e66bdfe32e43e97b Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 15 Sep 2026 09:43:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20say=20=E8=BF=99=E4=B8=A4?= =?UTF-8?q?=E5=88=86=E9=92=9F=20only=20when=20two=20candidates=20remain=20?= =?UTF-8?q?(BUG-692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Count-aware closed-pool invite, catch the five stale contract assertions, and mark M1b V1/V2 as not_measured. --- CHANGELOG.md | 4 ++ docs/BUG_HISTORY.md | 16 ++++++ docs/research/precision_gate_2026_09_14.md | 10 ++-- ...on_minute_resolution_closure_2026_09_14.md | 1 + ...-rectification-invite-copy-fix-20260915.md | 37 +++++++++++++ docs/tasks/README.md | 2 +- .../rectification-invite-copy-fix-20260915.md | 9 ++++ ...tification-open-collect-invite-20260914.md | 2 +- frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 2 +- .../rectification-range-delivery.tsx | 10 ++-- .../lib/rectification-agentic/user-copy.ts | 43 ++++++++++++--- .../rectification-agentic/v9/answer-choice.ts | 2 +- .../v9/collection-question-pool.ts | 6 +-- .../rectification-decision-authority.test.ts | 5 ++ ...ification-exhaustion-exit-20260906.test.ts | 49 ++++++++--------- ...ation-open-collect-invite-20260914.test.ts | 52 ++++++++++++++++++- 17 files changed, 201 insertions(+), 51 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-invite-copy-fix-20260915.md create mode 100644 docs/testing/rectification-invite-copy-fix-20260915.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3b80f5..626f180f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 并列邀请按候选数说话,不再把 4 个候选叫成「这两分钟」 + +生时校正里,固定采集线问完后的补充邀请会按还剩几个候选来写:剩 2 个说「这两分钟」,多于 2 个说「这几个候选」,数不清楚时不说这半句。不再出现同一段话里「4 个候选」和「这两分钟」打架。Skill 版本不变。 + ## 2026-09-14 — 报上来的时间会标明是记录还是大概记得 生时校正里,医院记录称作「出生记录时间」,家人记得称作「你填的大概时间」,只知道时段称作「你给的时间段」,不再把后两类叫成「你的出生时间」。和目前范围比较时只说相差几分钟;有医院记录却落在范围外时,两者并列,不替你选边。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 053d059d..6b907ee1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10757,3 +10757,19 @@ - 相关记录:BUG-587、BUG-621、BUG-689 - 复发自:无 - 修复版本:待发布 + +## BUG-692 | 交付邀请语写「这两分钟」却与同段「4 个候选」矛盾;5 条契约断言未跟上 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`rangeDeliveryCollectClosed`、`rangeNarrowHint`、`exitAppendCalls`、`publicDecisionFields`、精度研究 M1b 判定 +- 用户现象:范围卡旁白先写「还剩 04:47–04:53 里 4 个候选」,紧接着写「这两分钟按现有信息分不开」。同时 `npm test` 从基线 27 条涨到 32 条:4 条 exhaustion-exit 因短语表不含新交付文案判成 0 条出口载体,1 条 decision-authority 深比较缺 `birth_time_source`。 +- 触发条件:固定七条采集线关闭后交付;剩余候选数 ≠ 2。或在含 BUG-689/690 的树上跑全量 `npm test`。 +- 根因:BUG-689 把 tie-break 的「这两分钟」硬编码进任意候选数的邀请语;BUG-689/690 改了用户可见文案和公开字段,但没同步两处锁契约的测试。staging 当时部署仍是 `cf972f40`,未影响真实用户。 +- 修复:邀请语按候选数出话(2 → 「这两分钟」;>2 → 「这几个候选」;未知 → 不说这半句)。`exitAppendCalls()` 加入「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」,保持 `assert.equal(gates.length, 1)`,不加「只微调排序」。公开字段深比较补 `birth_time_source`。M1b 的 V1/V2 从 `no_benefit` 勘误为 `not_measured`。 +- 验证:`frontend/tests/rectification-open-collect-invite-20260914.test.ts`、`rectification-exhaustion-exit-20260906.test.ts`、`rectification-decision-authority.test.ts`。 +- 防复发:文案里出现的数字必须来自同一份数据,不得硬编码;改用户可见文案或公开字段时,必须同步跑 `npm test` 并把失败数与基线比对。 +- 相关记录:BUG-689、BUG-690、BUG-688、BUG-687、BUG-587 +- 复发自:BUG-689(邀请语复用两候选句)、BUG-690(公开字段未进深比较) +- 修复版本:待发布 diff --git a/docs/research/precision_gate_2026_09_14.md b/docs/research/precision_gate_2026_09_14.md index 78f1c1f4..0f2e0af5 100644 --- a/docs/research/precision_gate_2026_09_14.md +++ b/docs/research/precision_gate_2026_09_14.md @@ -8,7 +8,7 @@ ## 结论 -**无收益,不立实现单。** 线上 `MIN_BOUNDARY_DAYS=45` / 刷新 30 与等权分盘保持不动。真机卡住的 ±10 窗上,放宽闸门能把宽度从 15 收到 11,但头名从 0.80 掉到 0.75;宽窗上宽度反而变大。V1/V2 与基线相同,V3(D60)在 ±10 把头名降到 0.70。唯一看起来像过门的格子是 G1+V3 在 ±10(命中仍 0.80、宽度 15→11),宽窗上不成立,也没有对该组合做 ±7 记错,不够立实现单。 +**无收益,不立实现单。** 线上 `MIN_BOUNDARY_DAYS=45` / 刷新 30 与等权分盘保持不动。真机卡住的 ±10 窗上,放宽闸门能把宽度从 15 收到 11,但头名从 0.80 掉到 0.75;宽窗上宽度反而变大。V1/V2 与基线相同(**勘误:标为未测,见 M1b**),V3(D60)在 ±10 把头名降到 0.70。唯一看起来像过门的格子是 G1+V3 在 ±10(命中仍 0.80、宽度 15→11),宽窗上不成立,也没有对该组合做 ±7 记错,不够立实现单。 判定口径:命中不降 **且** 宽度下降才算有收益;只多出题而宽度变宽不算。±7 天记错挤出真值的档位一律不推荐。本网格覆盖率始终 20/20,M2 挤出全是 0。 @@ -18,8 +18,8 @@ | G2 | no_benefit | ±10:no_benefit squeezed7=0;±30:no_benefit squeezed7=0;±60:no_benefit squeezed7=0 | | G3 | no_benefit | ±10:no_benefit squeezed7=0;±30:no_benefit squeezed7=0;±60:no_benefit squeezed7=0 | | G4 | no_benefit | ±10:no_benefit squeezed7=0;±30:no_benefit squeezed7=0;±60:no_benefit squeezed7=0 | -| V1 | no_benefit | ±10:no_benefit;±30:no_benefit;±60:no_benefit | -| V2 | no_benefit | ±10:no_benefit;±30:no_benefit;±60:no_benefit | +| V1 | not_measured | 原判定 `no_benefit`;见 M1b 勘误 | +| V2 | not_measured | 原判定 `no_benefit`;见 M1b 勘误 | | V3 | no_benefit | ±10:no_benefit;±30:no_benefit;±60:no_benefit | 过运与三大外部引擎未参与本单(纯出题闸门 / 分盘配权,不解释运势)。 @@ -171,6 +171,10 @@ V0 生产等权;V1 `min(window/varga_minutes, 1)` 再除以 `2n`;V2 只计 | G3+V3 | 0.4 | 1 | 64 | 0 | 6.2 | | G4+V3 | 0.4 | 1 | 64 | 0 | 6.25 | +### 勘误(2026-09-15,BUG-692) + +总表原判定 V1 / V2 = `no_benefit`。上节已写明「V0 采集误走了生产计分、没有打上分盘标签」,且 V1 与 V2 在 ±10 / ±30 / ±60 上的头名命中、覆盖、宽度、并列、挤出与 V0 **完全相同**。V3 在 ±10 把头名从 0.80 降到 0.70,说明配权机制能改数字,因此 V1/V2 与 V0 相同更像是权重没真正打上,而不是「测过且无收益」。**V1/V2 的 `no_benefit` 作废,改标 `not_measured`。** V3 结论保留。上面数字表未改。重跑与否另立项。 + ## M2 · 答错容忍度(日精度事件日期偏移) 组 A 的日精度事件按例、按事件独立随机偏移(种子 20260914,排除 0)。±7 天出现真值挤出的档位不得推荐。 diff --git a/docs/research/rectification_minute_resolution_closure_2026_09_14.md b/docs/research/rectification_minute_resolution_closure_2026_09_14.md index 6a29222e..ca2e0c0d 100644 --- a/docs/research/rectification_minute_resolution_closure_2026_09_14.md +++ b/docs/research/rectification_minute_resolution_closure_2026_09_14.md @@ -78,6 +78,7 @@ | `docs/research/minute_resolution_2026_09_14.md` | 第一轮:R1–R5 打分尺度测量(**文末有勘误,务必读**) | | `docs/research/cluster_width_2026_09_14.md` | 第二轮:宽度口径校正、簇结构画像、W1–W3 | | `docs/research/holdout_v4_build_2026_09_14.md` | v4 封存基准的构建与标注协议 | +| `docs/research/precision_gate_2026_09_14.md` | 出题闸门分档与 M1b 分盘配权。**勘误:M1b 的 V1/V2 标为未测**(与 V0 数字完全相同,且 V0 采集未打分盘标签);V3 结论保留 | | `scripts/research/minute_resolution_sweep.py` / `cluster_width_probe.py` | 两轮的测量脚本(离线,未接生产) | | `tests/test_minute_resolution_research.py` / `test_cluster_width_research.py` | 两轮脚本的回归测试 | diff --git a/docs/tasks/PROGRESS-rectification-invite-copy-fix-20260915.md b/docs/tasks/PROGRESS-rectification-invite-copy-fix-20260915.md new file mode 100644 index 00000000..d06fd9fd --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-invite-copy-fix-20260915.md @@ -0,0 +1,37 @@ +# 进度 · 邀请文案按候选数出话 + 5 条断言跟上(2026-09-15) + +## 范围 + +- 分支:`codex/rectification-invite-copy-fix-20260915`(基于 `origin/staging` @ `81f8183c`) +- 任务单:`docs/tasks/TASK-rectification-invite-copy-and-assertions-fix-20260915.md` +- BUG-692 resolved(复发自 BUG-689 / BUG-690) +- 未改打分、判据、置信度、确认门、数据库、Skill 版本;未回退 BUG-688 的「恰好一条」与「不含只微调排序」 + +## 完成 + +- T2:`exitAppendCalls()` 短语表加入「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」。`assert.equal(gates.length, 1)` 仍在。公开字段深比较补 `birth_time_source`,保持 `deepEqual`。 +- T1:`rangeDeliveryCollectClosed(candidateCount)`。2 → 「这两分钟」;>2 → 「这几个候选」;未知 → 只说「按现有信息分不开」。`rangeNarrowHint` 两处空池返回点传入 `candidateCount`。交付卡从 hint 抽出同一句邀请,避免再硬编码。 +- T3:`precision_gate_2026_09_14.md` M1b 追加勘误,总表 V1/V2 改 `not_measured`(原 `no_benefit` 留在勘误里)。闭包索引注明 V1/V2 未测。V3 保留。 +- 未走让步。 + +## 断言三栏(既有断言) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `exitAppendCalls()` 短语表 | `能问的都问完了` 等旧收口 | 加上「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」 | BUG-689 交付邀请就是出口载体;BUG-692 跟上。不加「只微调排序」 | +| `last closed-ceiling…` 旁白匹配 | 同上旧闸句 | 同上新短语;`gates.length === 1` 不动 | 同上 | +| `public decision fields…` | 深比较不含 `birth_time_source` | 含 `birth_time_source`,仍深比较 | BUG-690 公开字段;不得改成部分匹配 | + +## 验收数字 + +- 新测:`closed-pool invite names two minutes only when two candidates remain` 通过;既有 open-collect-invite 3 条仍绿(现 4/4) +- 定向:`rectification-decision-authority`(含 `public decision fields`)、`agent-voice-copy-contract`、`rectification-collection-question-pool`、`rectification-probe-pool-exhausted-20260911`、`rectification-tiebreak-before-card-20260914`、`rectification-birth-time-provenance-20260914` 全绿 +- `tsc --noEmit`:0 错 +- `npm run lint`:0 error(既有 warning 未顺手改) +- 全量 `npm test`:3023 / pass 2856 / fail 153 / skip 14。失败条数相对本机 Windows 上一轮 154 少 1(`public decision fields` 已绿)。失败类型仍是 CRLF、Skill SHA、Docker/DB、无 symlink。Linux 门禁基线 27;本轮修的 5 条里,authority 已在本机绿,exhaustion-exit 四条因 Skill SHA 无法在本机导入,留给 Linux 门禁。 +- `next build`:本工作树 `frontend/node_modules` 是 junction,未跑 + +## 环境缺口 + +- 无 staging 登录态、无 Chrome:真机清单见 `docs/testing/rectification-invite-copy-fix-20260915.md` +- `next build`:本工作树 `frontend/node_modules` 是 junction,未跑;`/` 是否仍 `○ Static` 需在有真实 `node_modules` 的树上复核 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6943dd01..9b8c8a5b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -187,7 +187,7 @@ | `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待验收 | `codex/rectification-tiebreak-hold-exit-20260914` | -| `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-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` | diff --git a/docs/testing/rectification-invite-copy-fix-20260915.md b/docs/testing/rectification-invite-copy-fix-20260915.md new file mode 100644 index 00000000..fd84636f --- /dev/null +++ b/docs/testing/rectification-invite-copy-fix-20260915.md @@ -0,0 +1,9 @@ +# 邀请语与候选数一致:真机清单(2026-09-15) + +分支 `codex/rectification-invite-copy-fix-20260915`。本环境无 staging 登录态。 + +## BUG-692 + +- [ ] 固定七条线都答过「没有/跳过」后,范围卡仍出现,正文仍请再说一件记得确切哪一天的事、不限领域。 +- [ ] 交付卡上「现在还剩 … 里 N 个候选」与后面的「这两分钟 / 这几个候选」必须一致:N=2 才说「这两分钟」;N>2 说「这几个候选」,不得再写「这两分钟」。 +- [ ] 邀请其余部分不变:不限领域、日期优先例子、年月例子;不再出现「能问的都问完了」。 diff --git a/docs/testing/rectification-open-collect-invite-20260914.md b/docs/testing/rectification-open-collect-invite-20260914.md index 18cb4d7f..58d74cde 100644 --- a/docs/testing/rectification-open-collect-invite-20260914.md +++ b/docs/testing/rectification-open-collect-invite-20260914.md @@ -5,7 +5,7 @@ ## BUG-689 - [ ] 固定七条线(结婚 / 收入 / 搬家 / 家人 / 学业 / 健康 / 职业)都答过「没有/跳过」后,范围卡仍出现。 -- [ ] 前两名相对可能性相差不超过约 3 个百分点时,卡的正文(不是浅色小字)写「这两分钟按现有信息分不开」,并请再说一件记得确切哪一天的事、不限领域;例子里至少有登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天中的两个。记不得哪一天时,才退到年月。 +- [ ] 前两名相对可能性相差不超过约 3 个百分点时,卡的正文(不是浅色小字)写「按现有信息分不开」,并请再说一件记得确切哪一天的事、不限领域;例子里至少有登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天中的两个。记不得哪一天时,才退到年月。句首按还剩几个候选:「2 个」说「这两分钟」,多于 2 个说「这几个候选」,不得和「N 个候选」打架。 - [ ] 正文里不再出现「能问的都问完了」,也不再把已经说过没有的线写成「哪年结婚或订婚 / 哪年收入 / 家里哪年添丁」。 - [ ] 「更像这个」和「新建对话按此时间排盘」的出口仍可用,邀请不挡住采用。 - [ ] 并列出卡后,在输入框补一件带日期的经历(能到日更好,年月也可以):账本应记下这件事,范围或候选排序应有可见变化,或出现新的一问。不得停在「问完了」且没有任何反应。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 817ac88b..54223bdc 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -234,7 +234,7 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | -| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines; when the top two relative likelihoods are within 3 points and collect lines are closed, a body-size invite to add one more event of any kind, asking for an exact day first then falling back to year-month (never “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered. | enabled | +| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines; when the top two relative likelihoods are within 3 points and collect lines are closed, a body-size invite to add one more event of any kind, asking for an exact day first then falling back to year-month (never “能问的都问完了”). The invite’s subject matches the remaining count in the same paragraph: 2 → “这两分钟”, more than 2 → “这几个候选”, unknown → omit that clause. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered. | enabled | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible | | `confirmed` | “已确认校正时间:HH:MM” | enabled | | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 0a6a0e7c..82a1532c 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -46,7 +46,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 | | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | | 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 | -| 能问的都问完了 | 这两分钟按现有信息分不开。你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 | +| 能问的都问完了 | 还剩 2 个候选时:「这两分钟按现有信息分不开。」还剩 4 个时:「这几个候选按现有信息分不开。」候选数未知时只说「按现有信息分不开。」后面接:你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。句首的「这两分钟 / 这几个候选」必须和同段话里的候选数一致,不得硬编码。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 | | 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 | | 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集只有输入框,没有「没有」「记不清」芯片,也不挂在输入框上方。范围小字是状态句,不可点。 | 想跳过可以打字「没有」或「记不清」。 | | 只报一件后立刻按家人/学业/财务轮着问,题干带「2020 年前后」 | 记下后问「还有吗?比如……」(只列还没提过的具体事)。答「没有了」后才从用户说过的年份追问,例如「2020 年毕业后第一份工作大概哪年开始?」 | 先等用户说完;年份只来自用户,不用生日推。 | diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index d009321f..9adf5b6c 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -5,6 +5,7 @@ import { RECTIFICATION_USER_COPY, REPRESENTATIVE_MINUTE_DISCLAIMER, rangeDeliveryCaptionWithoutClosedInvite, + rangeDeliveryClosedInviteFromHint, rangeDeliveryEventCopy, rangeDeliveryShowsOpenCollectInvite, } from "@/lib/rectification-agentic/user-copy"; @@ -42,13 +43,12 @@ export function RectificationRangeDelivery({ const markdown = delivery?.verification_markdown ?? result.verificationReportMarkdown; const sharedTraits = delivery?.shared_traits ?? []; + const closedInvite = rangeDeliveryClosedInviteFromHint(delivery?.narrow_hint); const showOpenCollectInvite = rangeDeliveryShowsOpenCollectInvite({ hint: delivery?.narrow_hint, columns, }); - const caption = showOpenCollectInvite || delivery?.narrow_hint?.includes( - RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed, - ) + const caption = showOpenCollectInvite || closedInvite ? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint) : delivery?.narrow_hint ?? null; @@ -60,9 +60,9 @@ export function RectificationRangeDelivery({ {caption ? (

{caption}

) : null} - {showOpenCollectInvite ? ( + {showOpenCollectInvite && closedInvite ? (

- {RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed} + {closedInvite}

) : null} {delivery?.provenance_line ? ( diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 45860a15..a0d7b79e 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -136,6 +136,34 @@ export const RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES = [ "亲人离世", ] as const; +/** Stable invite stem after the count-dependent subject. */ +export const RANGE_DELIVERY_COLLECT_CLOSED_INVITE = `你要是还记得确切哪一天的事,不限领域,说出来我接着算——${RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.join("、")}都成。记不得哪一天的,有年月也行,比如${RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.join("、")}。`; + +const CLOSED_INVITE_HEAD = /(?:这两分钟|这几个候选)?按现有信息分不开。$/u; + +/** Count=2 → 「这两分钟」; >2 → 「这几个候选」; unknown / 0 / 1 → omit. */ +export function rangeDeliveryCollectClosedSubject(candidateCount?: number): string | null { + if (candidateCount === 2) return "这两分钟"; + if (typeof candidateCount === "number" && Number.isFinite(candidateCount) && candidateCount > 2) { + return "这几个候选"; + } + return null; +} + +export function rangeDeliveryCollectClosed(candidateCount?: number): string { + const subject = rangeDeliveryCollectClosedSubject(candidateCount); + const head = subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。"; + return `${head}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`; +} + +export function rangeDeliveryClosedInviteFromHint(hint: string | null | undefined): string | null { + if (!hint) return null; + const at = hint.indexOf(RANGE_DELIVERY_COLLECT_CLOSED_INVITE); + if (at < 0) return null; + const head = hint.slice(0, at).match(CLOSED_INVITE_HEAD); + return `${head?.[0] ?? ""}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`; +} + export const RECTIFICATION_USER_COPY = { genericCollectQuestion: GENERIC_COLLECT_QUESTION, collectQuestionByDomain: USER_COLLECT_QUESTION, @@ -173,7 +201,6 @@ export const RECTIFICATION_USER_COPY = { rangeDeliveryTieBreakEntry: "再答两道参考题微调排序", rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。", rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。", - rangeDeliveryCollectClosed: `这两分钟按现有信息分不开。你要是还记得确切哪一天的事,不限领域,说出来我接着算——${RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.join("、")}都成。记不得哪一天的,有年月也行,比如${RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.join("、")}。`, verificationReportSummary: "查看验证报告", } as const; @@ -192,8 +219,8 @@ export function rangeDeliveryCaptionWithoutClosedInvite( hint: string | null | undefined, ): string | null { if (!hint) return null; - const invite = RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed; - if (!hint.includes(invite)) return hint; + const invite = rangeDeliveryClosedInviteFromHint(hint); + if (!invite) return hint; const stripped = hint.replaceAll(invite, "").replace(/[。.]\s*$/u, "").trim(); return stripped.length > 0 ? stripped : null; } @@ -202,8 +229,10 @@ export function rangeDeliveryShowsOpenCollectInvite(input: { hint: string | null | undefined; columns: readonly { probability_percent: number }[]; }): boolean { - const invite = RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed; - return Boolean(input.hint?.includes(invite) && rangeDeliveryTopTwoTied(input.columns)); + return Boolean( + rangeDeliveryClosedInviteFromHint(input.hint) + && rangeDeliveryTopTwoTied(input.columns), + ); } export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly> = { @@ -557,7 +586,9 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed, - RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed, + rangeDeliveryCollectClosed(2), + rangeDeliveryCollectClosed(4), + rangeDeliveryCollectClosed(), RECTIFICATION_USER_COPY.verificationReportSummary, postAdoptVerifyDoneCopy("04:53", false), rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "", diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 9d0461c5..61daba0b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -340,7 +340,7 @@ function adoptHostNarration(input: { input.dossier.evidence, input.dossier.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, - undefined, + catalog.remainingCandidateCount, catalog.remainingCredibleRange, ); if (!hint || delivered.includes(hint)) return delivered; diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index 3e22d9f4..7eeeecf9 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -4,7 +4,7 @@ * value, not a fixed domain rotation. Age-band years never enter prompts. */ -import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../user-copy.ts"; +import { rangeDeliveryCollectClosed, USER_COLLECT_QUESTION } from "../user-copy.ts"; import { canonicalCollectDomain } from "./domain-alias.ts"; export const COLLECT_KIND_ORDER = [ @@ -844,9 +844,9 @@ export function rangeNarrowHint( if (open.length === 0) { const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes); if (range && (candidateCount ?? 0) > 0) { - return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选。${RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed}`; + return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选。${rangeDeliveryCollectClosed(candidateCount)}`; } - return RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed; + return rangeDeliveryCollectClosed(candidateCount); } const remaining = remainingCandidatesLine( splitTimes, diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 69b10baa..fe30a664 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { normalizeBirthTimeSource } from "../src/lib/rectification-agentic/birth-time-provenance.ts"; import { ADOPT_OUTCOMES, decideRectification, @@ -706,6 +707,10 @@ test("public decision fields are derived from decideRectification", () => { credible_range: decision.credibleRange, // 旧 公开字段无 stop_reason → 新 只读暴露停止原因 → 保留 采用/确认门字段语义不变 stop_reason: decision.stopReason ?? null, + // 原值: 公开字段集合不含 birth_time_source + // 新值: 与 publicNextAction 同集,含 birth_time_source + // 原因: BUG-690 投影带来源标签;BUG-692 深比较跟上,不得改成部分匹配 + birth_time_source: normalizeBirthTimeSource(decision.birthTimeSource), }); const publicOverlay = overlayPublicDecision({ selectionAllowed: true }, decision); assert.equal(publicOverlay.selectionAllowed, false); diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index c3e18959..d6ed76cc 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -551,12 +551,15 @@ function assistantAppendCalls(calls: Array<{ fn: string; args: Record }>) { return assistantAppendCalls(calls).filter((item) => ( GATE_SENTENCE.test(String(item.args.p_assistant_message)) - || /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/.test( - String(item.args.p_assistant_message), - ) + || EXIT_CARRIER_COPY.test(String(item.args.p_assistant_message)) )); } @@ -698,12 +701,12 @@ test("closed ceiling with training open delivers a range and a concrete next-col // 原因: 产品撤回门槛句终态,训练门开必须交付(BUG-646) assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE); assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够|还差/); - // 原值: /范围还能再收一截|目前范围|当前范围/ - // 新值: 旁白写「范围已经收到…能问的都问完了」;「目前范围」在卡标题上 - // 原因: BUG-687 拒答后的收口文案 + // 原值: /范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/ + // 新值: 旁白也可写「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」 + // 原因: BUG-689 交付邀请;BUG-692 短语表跟上 assert.match( persisted.hostNarration ?? "", - /范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/, + EXIT_CARRIER_COPY, ); const repaired = await ensureNonTerminalTurnExit({ @@ -755,12 +758,12 @@ test("inconsistent projection logs ranked_count 0 and still delivers a gate", as }).find((item) => item?.event === "rectification_exhaustion_collect"); assert.equal(log?.ranked_count, 0); assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够/); - // 原值: /范围还能再收一截|目前范围|就能开始筛|当前范围|排不出可比较的候选/ - // 新值: 旁白也可写「范围已经收到…能问的都问完了」 - // 原因: BUG-687 + // 原值: /范围还能再收一截|目前范围|就能开始筛|当前范围|排不出可比较的候选|范围已经收到|能问的都问完了/ + // 新值: 旁白也可写「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」 + // 原因: BUG-689 交付邀请;BUG-692 短语表跟上 assert.match( persisted.hostNarration ?? "", - /范围还能再收一截|目前范围|就能开始筛|当前范围|排不出可比较的候选|范围已经收到|能问的都问完了/, + EXIT_CARRIER_COPY, ); }); @@ -858,21 +861,13 @@ test("last closed-ceiling card concatenates the gate into one append", async () expectedRevision: 6, }); const gates = exitAppendCalls(accounting.calls); - // 原值: 恰好 1 条交付闸,旁白含「目前范围」 - // 新值: 恰好 1 条交付闸,旁白可含「范围已经收到 / 能问的都问完了」 - // 原因: BUG-688 恢复「恰好一条」;文案放宽来自 BUG-687 + // 原值: 恰好 1 条交付闸,旁白含「目前范围 / 范围已经收到 / 能问的都问完了」 + // 新值: 恰好 1 条交付闸,旁白可含「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」 + // 原因: BUG-688 恢复「恰好一条」;BUG-689 邀请文案;BUG-692 短语表跟上。不含「只微调排序」。 assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message))); assert.doesNotMatch(applied.narration, /领域不限|做不了|材料不够|还差 \d+ 件/); - assert.match( - applied.narration, - /范围还能再收一截|目前范围|就能开始筛|当前范围|范围已经收到|能问的都问完了/, - ); - assert.equal( - (String(gates[0]?.args.p_assistant_message).match( - /范围还能再收一截|目前范围|就能开始筛|范围已经收到|能问的都问完了/g, - ) ?? []).length > 0, - true, - ); + assert.match(applied.narration, EXIT_CARRIER_COPY); + assert.equal(EXIT_CARRIER_COPY.test(String(gates[0]?.args.p_assistant_message)), true); }); test("closed ceiling with a leftover discriminator persists the card not the gate", async () => { @@ -982,9 +977,9 @@ test("ensureNonTerminalTurnExit writes a host turn when exhausted with no carrie }); assert.ok(repaired.hostNarration); assert.equal(repaired.terminalNote, true); - // 原值: exitAppendCalls 只认「目前范围」等旧闸句,计 1 条 - // 新值: 收口句「能问的都问完了 / 范围已经收到」也算载体 - // 原因: BUG-687 + // 原值: 收口句「能问的都问完了 / 范围已经收到」也算载体 + // 新值: 「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」也算载体;仍恰好 1 条 + // 原因: BUG-689 邀请文案;BUG-692 短语表跟上。不含「只微调排序」。 assert.equal(exitAppendCalls(accounting.calls).length, 1); }); diff --git a/frontend/tests/rectification-open-collect-invite-20260914.test.ts b/frontend/tests/rectification-open-collect-invite-20260914.test.ts index 55677c3a..93b71f6a 100644 --- a/frontend/tests/rectification-open-collect-invite-20260914.test.ts +++ b/frontend/tests/rectification-open-collect-invite-20260914.test.ts @@ -9,7 +9,7 @@ import { RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES, RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES, RANGE_DELIVERY_TIE_PERCENT, - RECTIFICATION_USER_COPY, + rangeDeliveryCollectClosed, rangeDeliveryShowsOpenCollectInvite, } from "../src/lib/rectification-agentic/user-copy.ts"; import { @@ -216,7 +216,53 @@ test("closed collect copy invites another dated event instead of saying the pool // 新值: 仍不得写「哪年结婚或订婚」这类还开着的线;D1b 允许用「登记结婚那天」作日精度例子 // 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b 优先要确切日期 assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/); - assert.ok(copy.includes(RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed)); + assert.ok(copy.includes(rangeDeliveryCollectClosed(5))); + assert.doesNotMatch(copy, /这两分钟/); + assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选。这几个候选按现有信息分不开/); +}); + +test("closed-pool invite names two minutes only when two candidates remain", () => { + const two = rangeNarrowHint( + CLOSED_LAYERS, + DATED_EVIDENCE, + declineAllTargeted(), + ["04:48", "05:07"], + 2, + ["04:48", "05:07"], + ); + assert.match(two, /现在还剩 04:48–05:07 里 2 个候选。这两分钟按现有信息分不开/); + assert.doesNotMatch(two, /这几个候选/); + assert.match(two, /不限领域/); + assert.match(two, /确切哪一天/); + assert.match(two, /有年月也行/); + assert.match(two, /接着算/); + const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => two.includes(item)); + assert.ok(dayHits.length >= 2, dayHits.join(",")); + const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => two.includes(item)); + assert.ok(exampleHits.length >= 2, exampleHits.join(",")); + + const four = rangeNarrowHint( + CLOSED_LAYERS, + DATED_EVIDENCE, + declineAllTargeted(), + ["04:47", "04:53"], + 4, + ["04:47", "04:53"], + ); + assert.match(four, /现在还剩 04:47–04:53 里 4 个候选。这几个候选按现有信息分不开/); + assert.doesNotMatch(four, /这两分钟/); + assert.match(four, /不限领域/); + assert.match(four, /登记结婚那天/); + + const unknown = rangeNarrowHint( + CLOSED_LAYERS, + DATED_EVIDENCE, + declineAllTargeted(), + ); + assert.equal(unknown, rangeDeliveryCollectClosed()); + assert.doesNotMatch(unknown, /这两分钟|这几个候选/); + assert.match(unknown, /^按现有信息分不开。/); + assert.match(unknown, /不限领域/); }); test("the range card puts the closed-pool invite in the body only when the top two are tied", () => { @@ -249,6 +295,8 @@ test("the range card puts the closed-pool invite in the body only when the top t assert.match(tiedHtml, /换专业/); assert.match(tiedHtml, /创业/); assert.match(tiedHtml, /更像这个/); + assert.match(tiedHtml, /这几个候选按现有信息分不开/); + assert.doesNotMatch(tiedHtml, /这两分钟按现有信息分不开。/); assert.doesNotMatch(tiedHtml, /问完了/); const caption = tiedHtml.match( /class="rectification-range-delivery__narrow"[^>]*>([^<]*)