fix(rectification): say 这两分钟 only when two candidates remain (BUG-692)
Independent Staging Quality Gate / validate (push) Successful in 9m43s
Independent Staging Quality Gate / publish (push) Successful in 8m50s

Count-aware closed-pool invite, catch the five stale contract assertions, and mark M1b V1/V2 as not_measured.
This commit is contained in:
jesse-ux
2026-09-15 09:43:51 +08:00
parent 416b9a693c
commit 039b0a2608
17 changed files with 201 additions and 51 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-15 — 并列邀请按候选数说话,不再把 4 个候选叫成「这两分钟」
生时校正里,固定采集线问完后的补充邀请会按还剩几个候选来写:剩 2 个说「这两分钟」,多于 2 个说「这几个候选」,数不清楚时不说这半句。不再出现同一段话里「4 个候选」和「这两分钟」打架。Skill 版本不变。
## 2026-09-14 — 报上来的时间会标明是记录还是大概记得
生时校正里,医院记录称作「出生记录时间」,家人记得称作「你填的大概时间」,只知道时段称作「你给的时间段」,不再把后两类叫成「你的出生时间」。和目前范围比较时只说相差几分钟;有医院记录却落在范围外时,两者并列,不替你选边。Skill 版本不变。
+16
View File
@@ -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(公开字段未进深比较)
- 修复版本:待发布
+7 -3
View File
@@ -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-15BUG-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 天出现真值挤出的档位不得推荐。
@@ -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` | 两轮脚本的回归测试 |
@@ -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` 的树上复核
+1 -1
View File
@@ -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` |
@@ -0,0 +1,9 @@
# 邀请语与候选数一致:真机清单(2026-09-15)
分支 `codex/rectification-invite-copy-fix-20260915`。本环境无 staging 登录态。
## BUG-692
- [ ] 固定七条线都答过「没有/跳过」后,范围卡仍出现,正文仍请再说一件记得确切哪一天的事、不限领域。
- [ ] 交付卡上「现在还剩 … 里 N 个候选」与后面的「这两分钟 / 这几个候选」必须一致:N=2 才说「这两分钟」;N>2 说「这几个候选」,不得再写「这两分钟」。
- [ ] 邀请其余部分不变:不限领域、日期优先例子、年月例子;不再出现「能问的都问完了」。
@@ -5,7 +5,7 @@
## BUG-689
- [ ] 固定七条线(结婚 / 收入 / 搬家 / 家人 / 学业 / 健康 / 职业)都答过「没有/跳过」后,范围卡仍出现。
- [ ] 前两名相对可能性相差不超过约 3 个百分点时,卡的正文(不是浅色小字)写「这两分钟按现有信息分不开」,并请再说一件记得确切哪一天的事、不限领域;例子里至少有登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天中的两个。记不得哪一天时,才退到年月。
- [ ] 前两名相对可能性相差不超过约 3 个百分点时,卡的正文(不是浅色小字)写「按现有信息分不开」,并请再说一件记得确切哪一天的事、不限领域;例子里至少有登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天中的两个。记不得哪一天时,才退到年月。句首按还剩几个候选:「2 个」说「这两分钟」,多于 2 个说「这几个候选」,不得和「N 个候选」打架。
- [ ] 正文里不再出现「能问的都问完了」,也不再把已经说过没有的线写成「哪年结婚或订婚 / 哪年收入 / 家里哪年添丁」。
- [ ] 「更像这个」和「新建对话按此时间排盘」的出口仍可用,邀请不挡住采用。
- [ ] 并列出卡后,在输入框补一件带日期的经历(能到日更好,年月也可以):账本应记下这件事,范围或候选排序应有可见变化,或出现新的一问。不得停在「问完了」且没有任何反应。
+1 -1
View File
@@ -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 invites 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 |
+1 -1
View File
@@ -46,7 +46,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 |
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
| 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 |
| 能问的都问完了 | 这两分钟按现有信息分不开。你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 |
| 能问的都问完了 | 还剩 2 个候选时:「这两分钟按现有信息分不开。」还剩 4 个时:「这几个候选按现有信息分不开。」候选数未知时只说「按现有信息分不开。」后面接:你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。句首的「这两分钟 / 这几个候选」必须和同段话里的候选数一致,不得硬编码。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 |
| 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 |
| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集只有输入框,没有「没有」「记不清」芯片,也不挂在输入框上方。范围小字是状态句,不可点。 | 想跳过可以打字「没有」或「记不清」。 |
| 只报一件后立刻按家人/学业/财务轮着问,题干带「2020 年前后」 | 记下后问「还有吗?比如……」(只列还没提过的具体事)。答「没有了」后才从用户说过的年份追问,例如「2020 年毕业后第一份工作大概哪年开始?」 | 先等用户说完;年份只来自用户,不用生日推。 |
@@ -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 ? (
<p className="rectification-range-delivery__narrow">{caption}</p>
) : null}
{showOpenCollectInvite ? (
{showOpenCollectInvite && closedInvite ? (
<p className="rectification-range-delivery__invite">
{RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed}
{closedInvite}
</p>
) : null}
{delivery?.provenance_line ? (
@@ -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<Record<string, string>> = {
@@ -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"]) ?? "",
@@ -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;
@@ -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,
@@ -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);
@@ -551,12 +551,15 @@ function assistantAppendCalls(calls: Array<{ fn: string; args: Record<string, un
));
}
// 原值: /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/
// 新值: 加上「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」
// 原因: BUG-689 把「能问的都问完了」换成交付邀请;BUG-692 短语表跟上。不含「只微调排序」(BUG-688)。
const EXIT_CARRIER_COPY = /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了|收到.{0,80}这 \d+ 分钟|按现有信息分不开|说出来我接着算/;
function exitAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
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);
});
@@ -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"[^>]*>([^<]*)</,