diff --git a/CHANGELOG.md b/CHANGELOG.md index 09218c61..e20f9910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-16 — 生时校正的交付范围按还活着的时间段显示 + +问完题之后,卡片和旁白不再把中间已经排除的空档算进「目前范围」。相邻不超过 4 分钟的段会并在一起;超过三段时只写出前三段,并写明一共几段、大约还剩多少分钟。打分、采用和确认没有改。 + ## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额 - 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c0e40027..53fec49b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36488,3 +36488,48 @@ - 相关记录:BUG-1361、BUG-1362。 - 复发自:无。 - 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。 + +## BUG-1367 | 交付范围按声明窗口的首尾显示,中间空档也被算进去 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-16 / 2026-10-16 +- 来源:issue #77。任务书 `docs/tasks/TASK-rectification-narrower-delivery-20261016.md`。研究记录里 ±60 的显示宽度中位约 71 分钟、实际剩余中位约 42 分钟。那份 77 例回放本轮没有重跑。 +- 现象:问完题后,交付卡上的「目前范围」是一整段首尾。中间已经排除的空档仍看起来像还可能的时间。 +- 触发条件:同一声明窗口里有多段还活着的簇,段与段之间的空档大于 4 分钟。`segment_index` 是用户声明的窗口段,不是候选簇。 +- 根因:`interval_union_width` 和 `unionWindowIntervals` 把同一声明段里的区间收成最早到最晚。交付卡标题走这条包络。 +- 修复:卡片、旁白、口头可说的钟点和遥测宽度改用还活着的簇。落后峰值达到 8 分的候选不进这段显示,和现在的进区间门槛相同。相邻空档不超过 4 分钟的段合并;超过三段时只写前三段,并写一共几段、大约还剩多少分钟。打分、采用、确认、分离和 `MIN_SEPARATION_LEAD` 不动。写盘和读盘仍用同一个 8。会话标题仍由数据库里的单段生成,本轮不改表。 +- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖单段、空档 4 分钟合并、空档 5 分钟拆开、五段只显示前三段,以及卡片标题不再画出整段包络。`tests/test_rectification_living_display_intervals.py` 锁定同一套合并,并断言同一对区间的决策包络宽度不变。 +- 防复发:上述测试。显示函数放在密封打分文件之外,打分路径不引用它。没有簇区间的旧结果仍用原来的首尾。 +- 相关记录:BUG-423、BUG-560。 +- 复发自:无。 +- 修复版本:分支 `codex/rectification-narrower-delivery-20261016`。 + +## BUG-1368 | 剩余候选大多同侧时题目切不开,题数上限仍是 8 + +- 状态:blocked +- 首次发现 / 最近更新:2026-10-16 / 2026-10-16 +- 来源:issue #77 任务书根因 2。本轮没有新的回放来复核「同侧占比」。 +- 现象:后面几题往往排除得很少。可出题数量和轮数上限都停在 8 这一档。 +- 触发条件:探针仍按原有顺序供给;`DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`。 +- 根因:任务书指向探针供给上限和选题不按剩余后验对半。本轮没有改这些文件,也没有用新回放重新测量,根因沿用任务书。 +- 修复:未改。探针文件在密封打分身份里。对半出题会改变问哪些题,必须先有红线里的真值保留对照。本轮没有跑成 77 例、三个窗口、全对以及分别翻转第 1、3、5 题的生产回放,所以这项撤下。红线没有放宽。 +- 验证:轮数上限仍是 8。没有新增选题测试。 +- 防复发:在这项真正交付之前,不得把「少数一侧不足两成仍不出题」写成已经生效。 +- 相关记录:BUG-1367。 +- 复发自:无。 +- 修复版本:未修。 + +## BUG-1369 | 进入交付区间仍要落后峰值 8 分 + +- 状态:blocked +- 首次发现 / 最近更新:2026-10-16 / 2026-10-16 +- 来源:issue #77 任务书根因 3。本轮没有分档回放。 +- 现象:一道题通常只拉开约 4 分。候选至少要连续两道题都反向,才会离开交付区间。 +- 触发条件:`credible_range`、`credible_intervals` 和还有效的候选都用 `MIN_SEPARATION_LEAD = 8`。 +- 根因:任务书写明进区间门槛是 8,而单题分差更小。本轮没有把门槛改成 6,也没有新回放证明改成 6 之后真值仍留在区间里。 +- 修复:未新增另一套交付门槛,也没有按窗口回退表。写盘和读盘仍都用 8,避免一边 6、一边 8。分离、采用、确认和盘型段汇总仍用 8。 +- 验证:`MIN_SEPARATION_LEAD` 仍是 8。没有「写 6 读 6」的新测试,因为这条路径没有落地。 +- 防复发:不得只改写盘或只改读盘。不得用修改 `MIN_SEPARATION_LEAD` 来收窄区间。 +- 相关记录:BUG-423、BUG-560、BUG-1367。 +- 复发自:无。 +- 修复版本:未修。 diff --git a/docs/tasks/PROGRESS-rectification-narrower-delivery-20261016.md b/docs/tasks/PROGRESS-rectification-narrower-delivery-20261016.md new file mode 100644 index 00000000..57b48867 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-narrower-delivery-20261016.md @@ -0,0 +1,74 @@ +# 进度:生时校正交付区间收窄 2026-10-16 + +issue #77。分支 `codex/rectification-narrower-delivery-20261016`。 + +## 结论 + +这一轮只交付分段显示(T1)。问完题之后,卡片和旁白按还活着的时间段写「目前范围」,不再把中间已经排除的空档涂成一整段。打分、采用、确认、出题和进区间门槛都没有改。 + +T0 基线回放没有跑。T2 对半出题、T3 门槛 6、T4 合并回放都没有做。原因:任务书的红线要求先在同一套生产回放上量出「真值还在不在交付区间」。现有回放脚本不记录这一格,也不翻转第 1、3、5 题。没有这组数字就不能改「谁留在区间里」或「问哪一题」。红线没有放宽。 + +## 基线 + +- 计划里的 base `712a0fd2a19947405a1dfb486bad96a7661d41c3` 在 `git fetch origin --prune` 之后存在,分支从这里开出。 +- 任务书示例里的 `origin/staging` @ `9b7509155` 不是这次开工点。fetch 时 `origin/staging` 已经是 `712a0fd2a19947405a1dfb486bad96a7661d41c3`。 +- 研究记录 `docs/research/rectification_width_funnel_2026_10_16.md` 只作背景,不用它代替本轮基线。 + +## 做了什么 + +- 前端 `livingDisplayFromCandidates`:还没淘汰、并且离峰值不足 8 分的候选,取出各自的簇区间。空档不超过 4 分钟的相邻段合并;空档 5 分钟保持两段。宽度按每段首尾都算进去的分钟数相加。超过 3 段时,`windowIntervalsLabel` 只写前 3 段,再写「等 N 段,共约 X 分钟」。 +- 没有簇区间的旧结果仍用原来的首尾,避免把缺字段收成空白。 +- 交付卡标题、盘型交付的分钟范围、采用和中间轮旁白、口头可说的钟点、遥测里的范围宽度,在有簇区间时改用上面的结果。 +- Python 侧同一套合并放在 `scripts/rectification/living_display_intervals.py`。没有放进 `candidate_window.py`,因为那个文件在密封打分身份里,改它会改冻结哈希。打分代码不引用这个新文件。 +- `frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CHANGELOG.md` 已同步。 + +## 口径 + +| 位置 | 这一轮用的口径 | +| --- | --- | +| `scripts/rectification/candidate_window.py` 的 `interval_union_width` | 决策包络,未改 | +| `scripts/rectification/api_service.py` 的宽度 | 决策包络,未改 | +| `scripts/rectification/decision_policy.py` 的平台宽度 | 决策包络,未改 | +| `credible-range.ts` 的还有效区间 | 决策包络,门槛仍是 8 | +| `windowIntervalsWidth`、精度门、候选平台 | 决策包络,未改 | +| 技能报告里的「不可分宽度」 | 决策包络,未改 | +| 时间轴色带 | 仍是 `credible_range` 的单段首尾 | +| 会话标题 | 仍是数据库函数里的单段。本轮不改表 | +| 扫描块上的钟点标签 | 仍用块自己的区间;段数超过 3 时会走同一句「等 N 段」 | +| 交付卡、盘型范围、旁白、口头钟点、遥测宽度 | 有簇区间时用还活着的段;没有则仍用旧首尾 | + +只读范围那一行要等结果里已经有 `credible_range` 才出现。簇完全拆开、旧范围为空时,这行仍不显示;卡片标题仍按活着的段写。 + +## 没做的 + +- T0:没有产出 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json`。 +- T2:`DEFAULT_MAX_DISCRIMINATION_ROUNDS` 仍是 8。探针供给、对半选题、少数一侧不足 20% 就不出,都没改。记为 BUG-1368,状态 blocked。 +- T3:没有 `DELIVERY_RANGE_LEAD`,没有分档回退表。写盘和读盘仍都是 8。记为 BUG-1369,状态 blocked。 +- T4:没有产出 `docs/research/rectification_narrower_delivery_replay_2026_10_16.json`。 +- 显示用的候选是虚构的卡片形状,不是一份 ±60 黄金样本。仓库里现成的黄金样本没有「前后 60 分钟、中间有大于 4 分钟空档」的簇,本轮也没有跑出这样一份回放。 +- 真机清单写在 `docs/testing/rectification-narrower-delivery-20261016.md`,没有在手机或浏览器里走一遍。 + +## 检查 + +| 检查 | 命令 | 结果数字 | +| --- | --- | --- | +| 类型检查 | `frontend` 下 `tsc --noEmit` | 0 错误 | +| lint | `frontend` 下 `npm run lint` | 0 error,123 warning(原有警告,没有顺手改) | +| 显示单测 | 含在下面的前端测试里 | 6 条都通过 | +| Python 显示 | `.venv\Scripts\python.exe -m pytest tests/test_rectification_living_display_intervals.py -q` | 4 passed | +| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 62 passed | +| 前端测试 | 564 个不含数据库夹具的测试文件,`tsx --test --test-concurrency=8` | TAP 计 3938 条:通过 3893,失败 45,退出码 1 | +| 生产路径 PG 回放 | `varga_resolution_persisted_replay.py`,77 例 × 三档 × {全对,翻第 1/3/5 题} | 未跑 | +| Python 快速门 | `run_quality_gate.py --profile quick` | 未跑 | +| 数据库套件 | `npm run test:db` | 未跑(没改表) | +| 首屏构建 | `frontend` 下 `npm run build` | 退出码 0。`/` 为 Static。首页引用的 29 个 JS 按 gzip-9 合计 570406 字节。没有用改动前的代码在这台机器上再构建一次,所以不写增减百分比 | + +现有回放脚本固定全答对、最多 6 轮、两种段顺序都跑,记录的是分盘段是否保住真值,不是「真值在不在交付区间」。一次中途编译失败的单例冒烟不能当作基线,已经作废。 + +`npm test` 先按全量启动。数据库容器的健康检查一直停在 starting,C 盘当时只剩约 4.2 GB,所以中止了这次全量,并清掉测试容器。随后改跑 564 个不启动数据库夹具的测试文件,跳过 50 个会拉起 PostgreSQL 的文件。45 条失败都不是这次显示改动的断言:4 条是 Windows 上把盘符拼成 `G:\G:\` 后找不到文件;6 条是本机没有 WSL;约 32 条是加载 `tailwind-merge` 时解析失败。其中首页往返那一个文件单独再跑仍是 17 失败、0 通过,同一条解析错误,不是并行才出现;1 条是回答计时期望 `answer_truncated` 实际得到 `empty_answer`;2 条是技能路径用反斜杠,对不上写成正斜杠的断言。本轮 6 条分段显示测试都在通过的 3893 条里。日志没有打出运行器最后的汇总行,上面的数字是按 TAP 的 ok / not ok 行数出来的。 + +## 偏离 + +- 任务书让步是先交 T0+T1。T0 没有数字,所以不能声称 ±60 已从约 71 分钟降到约 42 分钟。代码只保证:有空档时卡片不再画包络。 +- 会话标题和「不可分宽度」仍是决策包络。标题要改数据库函数,任务书禁止本轮改表。 +- 显示成员仍用门槛 8,因为门槛 6 没有交付。这和写盘、读盘保持同一个数。 diff --git a/docs/testing/rectification-narrower-delivery-20261016.md b/docs/testing/rectification-narrower-delivery-20261016.md new file mode 100644 index 00000000..4022ee72 --- /dev/null +++ b/docs/testing/rectification-narrower-delivery-20261016.md @@ -0,0 +1,15 @@ +# 真机清单:交付范围按还活着的段显示(2026-10-16) + +这一版只改「目前范围」怎么写。题数仍是原来的上限,进区间的门槛仍是 8 分。不要把任何人的出生日期、钟点或姓名写进这份清单、进度或聊天记录。 + +在一台已登录的测试账号上,用前后各 30 分钟的窗口走完一次校正: + +1. 问完这一轮会出的选择题,直到出现范围卡片。 +2. 看卡片标题是一段,还是用顿号分开的几段。 +3. 若是两段或三段,旁白里应能看到「剩余约 X 分钟,分 N 段」。不应出现「定到分钟」。 +4. 若超过三段,标题只写最前面的三段,并写一共几段、大约还剩多少分钟。 +5. 中间已经空出来的间隔,不应再被画成一整段连续时间。 +6. 点「更像这个」之后,排盘用的是选中的那一分钟,不是把整段都确认掉。 +7. 这一版不会因为门槛改成 6 而少留人,也不会多问到 12 题。若题数或收窄幅度和改版前一样,这是预期。 + +本轮没有在真机上执行这份清单。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 006fbc0b..4a64a57a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1025,7 +1025,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。 -**卡头以范围为主(D3,2026-09-26)。** 主标题仍是「目前范围 HH:MM–HH:MM(对照了 N 件经历)」(``,`--type-title-sm`),紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。 +**卡头以范围为主(D3,2026-09-26;2026-10-16 起按活着的段显示)。** 主标题仍是「目前范围 …(对照了 N 件经历)」(``,`--type-title-sm`)。范围不再把同一声明窗口里已经空出来的间隔涂成一整段:只画出还活着的簇,相邻空档不超过 4 分钟的并成一段;超过三段时只写最前面的三段,再写「等 N 段,共约 X 分钟」。没有簇区间的旧结果仍用原来的首尾。紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认,也不承诺定到分钟;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。 **百分比只在差距明显时出现(D4,2026-09-26)。** 前两列「相对可能性」相差 ≥ 5 个百分点(`RANGE_DELIVERY_PERCENT_MIN_GAP`)时每列照旧写「相对可能性 N%」;否则三列都不写数字,卡上(共享性格句之上)一句「这几个时刻按现在的方法区分不开。」(2026-09-29 起不再邀请补经历,BUG-1084)(`.rectification-range-delivery__indistinct`,`--type-body-md`、正文墨色)。只有一列时没有第二名可比,不写数字也不写这句。排序、列数、性格 / 经历 / 未来窗三行不变。理由:每件事约 11.5 分是窗口内恒定项、约 2.1 分随分钟变,比例分数天然挤在一起(26% / 26% / 20%),差距不明显的数字只会被读成「分出了高下」。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 9d726ac1..31efb4f1 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -327,7 +327,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 | | 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 | | 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 你说的这些经历我都对照过了,下面是这次的结果。(2026-10-07 前为「我按你说的经历认真分析过了,……」,BUG-1258) | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 | -| 相对支持度 16 · 采用此时间 | 卡头主标题是范围「目前范围 05:00–05:15(对照了 N 件经历)」,副标题「最可能 HH:MM」(代表分钟,只在写百分比时出现;区分不开时不写)。一行至多三列。相同性格句只写一次。每列:有差别的性格句、人话经历对照、下一个时段,点「更像这个」;第一名比第二名高 5 个百分点及以上时,每列再写「相对可能性 N%」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是范围和区间里并排的候选分钟,不是支持度数字。 | +| 相对支持度 16 · 采用此时间 | 卡头主标题是范围「目前范围 05:00–05:15(对照了 N 件经历)」,副标题「最可能 HH:MM」(代表分钟,只在写百分比时出现;区分不开时不写)。同一窗口里已经空出来的间隔不要涂成一整段:写成还活着的几段(相邻空档不超过 4 分钟的并成一段);超过三段时只写前三段,再写「等 N 段,共约 X 分钟」。两三段时旁白可以补「剩余约 X 分钟,分 N 段」,不承诺定到分钟。一行至多三列。相同性格句只写一次。每列:有差别的性格句、人话经历对照、下一个时段,点「更像这个」;第一名比第二名高 5 个百分点及以上时,每列再写「相对可能性 N%」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是范围和区间里并排的候选分钟,不是支持度数字。中间空档不是还可能的出生时间。 | | 相对可能性 26% / 26% / 20%(前两名差不到 5 个百分点) | 不写任何数字,卡上一句:「这几个时刻按现在的方法区分不开。」(2026-09-29 起,原句「……补一件带年月的经历能帮助分开。」作废,BUG-1084) | 差距不明显的百分比只会让人误以为分出了高下(2026-09-26 产品决定,取代 09-08「三列都写相对可能性」的定稿)。排序与三列内容不变。 | | 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。 | 旁白不得念内部计分词。卡上的数字按上一行的 5 个百分点规则显示,旁白不念百分比。 | | 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 | diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 59d2aa84..9e15a2be 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -7,6 +7,7 @@ import { createPortal } from "react-dom"; import type { ChatMessage } from "@/lib/chat-message-view"; import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result"; import { RectificationRangeDelivery } from "@/components/rectification-range-delivery"; +import { livingDisplayFromCandidates } from "@/lib/rectification-agentic/core/candidate-window"; import { RECTIFICATION_EMPTY_ACTION_LABEL, RECTIFICATION_EMPTY_COPY, @@ -610,6 +611,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( {rectificationReadonlyRangeCopy({ range, + displayLabel, sessionOutcome, discriminatingEventProbes, contrastProbes, diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index 5f860eb1..f2adff88 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -1,6 +1,6 @@ "use client"; -import { windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window"; +import { livingDisplayFromCandidates, windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window"; import type { RectificationCandidateResult } from "@/lib/rectification-candidate-result"; import { RECTIFICATION_USER_COPY, @@ -44,7 +44,10 @@ export function RectificationRangeDelivery({ const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked; const adoptAllowed = sessionOutcomeAllowsAdopt(result.sessionOutcome) && result.canAdopt === true; const columns = delivery?.columns ?? []; - const rangeLabel = result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate) + const living = livingDisplayFromCandidates(result.candidates, result.declaredBirthDate); + const rangeLabel = living + ? living.label + : result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate) : range ? `${range[0]}–${range[1]}` : null; const title = rangeLabel ? eventCount > 0 diff --git a/frontend/src/lib/rectification-agentic/core/candidate-window.ts b/frontend/src/lib/rectification-agentic/core/candidate-window.ts index d9fd5de9..d31dca64 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-window.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-window.ts @@ -1,3 +1,5 @@ +import { MIN_SEPARATION_LEAD } from "./candidate-separation.ts"; + /** Civil date metadata belongs to a result, never reconstructed for historical rows. */ export type CandidateInterval = Readonly<{ start_at: string; end_at: string }>; export type WindowInterval = CandidateInterval & Readonly<{ @@ -111,6 +113,129 @@ export function requestCandidateIntervals( return [{ start_at: at(date, startOffset), end_at: at(date, startOffset + width) }]; } +/** + * Display-only merge. Adjacent living clusters whose gap is at most this many + * minutes become one segment. Decision envelopes (`unionWindowIntervals`, + * `interval_union_width`) stay on the declared-segment span. + */ +export const LIVING_DISPLAY_MERGE_GAP_MINUTES = 4; +/** After the merge, the label writes at most this many segments, then a count. */ +export const LIVING_DISPLAY_MAX_SEGMENTS = 3; + +export type LivingClusterSource = Readonly<{ + status?: string; + posterior_score?: number; + relativeSupport?: number; + cluster_intervals?: readonly CandidateInterval[] | null; + clusterIntervals?: readonly CandidateInterval[] | null; +}>; + +export type LivingDisplay = Readonly<{ + intervals: readonly CandidateInterval[]; + label: string; + widthMinutes: number; + segmentCount: number; +}>; + +function stampMs(value: string): number { + return Date.parse(`${value}:00Z`); +} + +/** Inclusive clock minutes of the rows as given. Does not merge. */ +export function inclusiveIntervalMinutes(rows: readonly CandidateInterval[]): number { + return rows.reduce((sum, row) => { + const width = (stampMs(row.end_at) - stampMs(row.start_at)) / 60000 + 1; + return Number.isFinite(width) && width > 0 ? sum + width : sum; + }, 0); +} + +/** + * Merge living cluster intervals for display and telemetry only. + * Gap is the number of clock minutes strictly between the two intervals. + * A gap of 4 merges; a gap of 5 stays two segments. Overlaps merge. + */ +export function mergeLivingDisplayIntervals( + rows: readonly CandidateInterval[], + gapMinutes = LIVING_DISPLAY_MERGE_GAP_MINUTES, +): readonly CandidateInterval[] { + const sorted = rows + .filter((row) => validLocalMinute(row.start_at) && validLocalMinute(row.end_at) && row.start_at <= row.end_at) + .map((row) => ({ start_at: row.start_at, end_at: row.end_at })) + .sort((left, right) => left.start_at < right.start_at ? -1 : left.start_at > right.start_at ? 1 : (left.end_at < right.end_at ? -1 : 1)); + const merged: CandidateInterval[] = []; + for (const row of sorted) { + const previous = merged.at(-1); + if (!previous) { + merged.push(row); + continue; + } + const gap = (stampMs(row.start_at) - stampMs(previous.end_at)) / 60000 - 1; + if (gap <= gapMinutes) { + if (row.end_at > previous.end_at) merged[merged.length - 1] = { start_at: previous.start_at, end_at: row.end_at }; + } else { + merged.push(row); + } + } + return merged; +} + +function clusterScore(row: LivingClusterSource): number | null { + if (typeof row.posterior_score === "number" && Number.isFinite(row.posterior_score)) return row.posterior_score; + if (typeof row.relativeSupport === "number" && Number.isFinite(row.relativeSupport)) return row.relativeSupport; + return null; +} + +/** + * Living clusters inside `lead` of the peak, merged for display. + * Returns null when no candidate carries a cluster interval, so callers keep + * the historical envelope label. + */ +export function livingDisplayFromCandidates( + candidates: readonly LivingClusterSource[] | null | undefined, + birthDate?: string | null, + lead = MIN_SEPARATION_LEAD, +): LivingDisplay | null { + if (!candidates?.length) return null; + const scored = candidates + .filter((row) => row.status !== "eliminated") + .map((row) => ({ row, score: clusterScore(row) })) + .filter((item): item is { row: LivingClusterSource; score: number } => item.score !== null); + if (!scored.length) return null; + const peak = Math.max(...scored.map((item) => item.score)); + const intervals: CandidateInterval[] = []; + for (const item of scored) { + if (peak - item.score >= lead) continue; + const parts = item.row.cluster_intervals ?? item.row.clusterIntervals ?? []; + for (const part of parts) { + if (part?.start_at && part?.end_at) intervals.push({ start_at: String(part.start_at), end_at: String(part.end_at) }); + } + } + if (!intervals.length) return null; + const merged = mergeLivingDisplayIntervals(intervals); + if (!merged.length) return null; + return { + intervals: merged, + label: windowIntervalsLabel(merged, birthDate), + widthMinutes: inclusiveIntervalMinutes(merged), + segmentCount: merged.length, + }; +} + +/** Fields to spread into range narration. Null when there is nothing to switch. */ +export function livingDisplayNarration( + candidates: readonly LivingClusterSource[] | null | undefined, + birthDate?: string | null, + lead = MIN_SEPARATION_LEAD, +): { displayLabel: string; displayWidthMinutes: number; displaySegmentCount: number } | null { + const display = livingDisplayFromCandidates(candidates, birthDate, lead); + if (!display) return null; + return { + displayLabel: display.label, + displayWidthMinutes: display.widthMinutes, + displaySegmentCount: display.segmentCount, + }; +} + /** Within each declared segment retain historical envelope semantics, but never bridge segments. */ export function unionWindowIntervals(rows: readonly WindowInterval[]): readonly WindowInterval[] { const groups = new Map(); @@ -137,7 +262,10 @@ export function windowIntervalsLabel(rows: readonly CandidateInterval[], birthDa if (date === birthDate) return clock; return `${date < birthDate ? "前一天" : "后一天"} ${clock}`; }; - return rows.map((row) => `${label(row.start_at)}–${label(row.end_at)}`).join("、"); + const text = (row: CandidateInterval) => `${label(row.start_at)}–${label(row.end_at)}`; + if (rows.length <= LIVING_DISPLAY_MAX_SEGMENTS) return rows.map(text).join("、"); + const shown = rows.slice(0, LIVING_DISPLAY_MAX_SEGMENTS).map(text).join("、"); + return `${shown},等 ${rows.length} 段,共约 ${inclusiveIntervalMinutes(rows)} 分钟`; } function validLocalMinute(value: unknown): value is string { diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 3a4ee115..6c12e79b 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -472,6 +472,10 @@ export type RangeNarrationInput = { fitPercent?: number | null; /** Scored tap answers (choice, not unsure). The delivery body counts these, not typed events (BUG-1085). */ choiceCount?: number | null; + /** Living-cluster label. When set, it replaces the single credible-range span. */ + displayLabel?: string | null; + displayWidthMinutes?: number | null; + displaySegmentCount?: number | null; }; function clockMinutes(value: string): number | null { @@ -540,10 +544,11 @@ function progressClause( openingRange: readonly [string, string] | null | undefined, rangeText: string, currentRange: readonly [string, string], + currentWidthOverride?: number | null, ): string | null { if (!openingRange) return null; const openingWidth = rangeWidthMinutes(openingRange[0], openingRange[1]); - const currentWidth = rangeWidthMinutes(currentRange[0], currentRange[1]); + const currentWidth = currentWidthOverride ?? rangeWidthMinutes(currentRange[0], currentRange[1]); if (!openingWidth || !currentWidth || currentWidth >= openingWidth) return null; return `已经从最初的 ${openingWidth} 分钟收到 ${rangeText} 这 ${currentWidth} 分钟`; } @@ -554,10 +559,10 @@ export function nonConvergingRangeNarration( ): string { const range = input.credibleRange ?? null; const representative = input.representativeTime?.trim() || null; - const rangeText = formatClockRange(range); + const rangeText = input.displayLabel?.trim() || formatClockRange(range); const variant = input.variant ?? "delivery"; - const progress = range && rangeText - ? progressClause(input.openingRange, rangeText, range) + const progress = rangeText && (range || input.displayWidthMinutes) + ? progressClause(input.openingRange, rangeText, range ?? ["", ""], input.displayWidthMinutes) : null; const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null; const explain = variant === "delivery" ? (input.stopExplain?.trim() || null) : null; @@ -615,11 +620,16 @@ export function nonConvergingRangeNarration( * invites more events. */ export function deliveryTurnNarration(input: RangeNarrationInput = {}): string { - const rangeText = formatClockRange(input.credibleRange ?? null); + const rangeText = input.displayLabel?.trim() || formatClockRange(input.credibleRange ?? null); + const segmentNote = input.displaySegmentCount && input.displaySegmentCount >= 2 + && typeof input.displayWidthMinutes === "number" + && !(input.displayLabel ?? "").includes("共约") + ? `剩余约 ${input.displayWidthMinutes} 分钟,分 ${input.displaySegmentCount} 段。` + : ""; const sentence1 = input.segmentSummary - ? chartSummaryCopy(input.segmentSummary) + (rangeText ? `分钟范围 ${rangeText}。` : "") - : input.productDelivery ? unknownProductDeliveryCopy(input.productDelivery) + (rangeText ? `分钟范围 ${rangeText}。` : "") - : rangeText ? `目前范围 ${rangeText}。` : "当前几个候选还分不开。"; + ? chartSummaryCopy(input.segmentSummary) + (rangeText ? `分钟范围 ${rangeText}。` : "") + segmentNote + : input.productDelivery ? unknownProductDeliveryCopy(input.productDelivery) + (rangeText ? `分钟范围 ${rangeText}。` : "") + segmentNote + : rangeText ? `目前范围 ${rangeText}。${segmentNote}` : "当前几个候选还分不开。"; const choices = typeof input.choiceCount === "number" && Number.isFinite(input.choiceCount) ? Math.max(0, Math.trunc(input.choiceCount)) : 0; diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts index cf8edf39..b0d91377 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts @@ -24,6 +24,7 @@ import { import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; +import { livingDisplayNarration } from "@/lib/rectification-agentic/core/candidate-window.ts"; import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { optionIdForAnswerClass, @@ -262,15 +263,20 @@ export async function handleRectificationTypedMessage( narrateAdopt, }); const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction); + const living = livingDisplayNarration( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates, + birthDate, + ) ?? {}; const intermediate = nonConvergingRangeNarration({ productDelivery: decision.productDelivery, segmentSummary: decision.segmentSummary, credibleRange: decision.credibleRange, representativeTime: decision.representativeTime, variant: "intermediate", + ...living, }); const assistantMessage = allowDelivery - ? (idle.hostNarration || nonConvergingRangeNarration(decision)) + ? (idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living })) : ((idle.terminalNote ? "" : idle.hostNarration) || intermediate); const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, @@ -360,7 +366,11 @@ export async function handleRectificationTypedMessage( caseId, narrateAdopt, }); - const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision); + const living = livingDisplayNarration( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates, + birthDate, + ) ?? {}; + const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living }); const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts index 235fd52f..a768add7 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts @@ -31,6 +31,7 @@ import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../re import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt"; import { focusSpokenPrompt } from "./turn-question"; import { previousInferenceFromReceipt } from "../core/compose-receipt.ts"; +import { livingDisplayFromCandidates } from "../core/candidate-window.ts"; import { activityChangedFromTool, mapStreamChunkToActivity, @@ -647,11 +648,15 @@ export async function streamV9Attempt( // P3: the only clocks and percentages a model sentence may carry are this // turn's server facts (range, representative minute, event fit rate). const fitRate = refinementFromDecisionReceipt(latestDossier.latestResult?.decisionReceipt ?? null).event_fit_rate; - const whitelistSources = [ - { credibleRange: rangeAfterEvidence, credibleIntervals: latestInference?.credible_intervals ?? null, - representativeTime: latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? null }, - { credibleRange: decision.credibleRange ?? null, representativeTime: decision.representativeTime ?? null }, - ].map((source) => spokenFactWhitelist({ ...source, fitPercent: fitRate?.percent ?? null })); + const livingSpeech = livingDisplayFromCandidates(latestInference?.candidates); + const representativeTime = latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? decision.representativeTime ?? null; + const whitelistSources = (livingSpeech + ? [{ credibleIntervals: livingSpeech.intervals, representativeTime }] + : [ + { credibleRange: rangeAfterEvidence, credibleIntervals: latestInference?.credible_intervals ?? null, + representativeTime }, + { credibleRange: decision.credibleRange ?? null, representativeTime: decision.representativeTime ?? null }, + ]).map((source) => spokenFactWhitelist({ ...source, fitPercent: fitRate?.percent ?? null })); spokenFacts = { ranges: [...new Set(whitelistSources.flatMap((item) => item.ranges))], clocks: [...new Set(whitelistSources.flatMap((item) => item.clocks))], diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 8f273b1a..651f05c2 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -11,6 +11,7 @@ import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts"; import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { productDeliveryForDomain } from "./product-delivery.ts"; import { nextProbe } from "../core/build-state"; +import { livingDisplayNarration } from "../core/candidate-window.ts"; import { deliveryNarrationAllowed, engineCapabilityCeilingFromReceipt, @@ -352,6 +353,10 @@ function adoptHostNarration(input: { }): string { const inference = previousInferenceFromReceipt(input.receipt); const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate; + const birthDate = input.receipt?.candidate_window_contract === "dated-v1" + && typeof input.receipt.declared_birth_date === "string" + ? input.receipt.declared_birth_date + : null; const delivered = deliveryAdoptNarration({ credibleRange: input.decision.credibleRange, representativeTime: input.decision.representativeTime, @@ -361,6 +366,7 @@ function adoptHostNarration(input: { segmentSummary: input.dossier.case.rectificationDomain === null ? null : inference?.segment_summary ?? null, productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain), openingRange: openingRangeFromDossier(input.dossier), + ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), }); // BUG-1147: the segment card already says which chart is credible and which cannot be told // apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it. @@ -2078,6 +2084,7 @@ async function persistExhaustionCollect(input: { representativeTime: decision.representativeTime ?? input.decision.representativeTime, openingRange: openingRangeFromDossier(dossier), variant: "intermediate", + ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), }); const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, @@ -2256,6 +2263,7 @@ async function persistExhaustionCollect(input: { representativeTime: decision.representativeTime ?? input.decision.representativeTime, openingRange: openingRangeFromDossier(dossier), variant: "intermediate", + ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), }); const hostNarration = exhaustionCarrierNarration(range, hint); return { diff --git a/frontend/src/lib/rectification-agentic/v9/telemetry.ts b/frontend/src/lib/rectification-agentic/v9/telemetry.ts index b61f5564..9b37cabb 100644 --- a/frontend/src/lib/rectification-agentic/v9/telemetry.ts +++ b/frontend/src/lib/rectification-agentic/v9/telemetry.ts @@ -17,6 +17,7 @@ import type { RectificationDecision } from "../core/rectification-decision.ts"; import { deliveryWidthMinutes } from "../core/precision-gate.ts"; import { rangeWidthMinutes } from "../user-copy.ts"; import { previousInferenceFromReceipt } from "./inference-adapter.ts"; +import { livingDisplayFromCandidates } from "../core/candidate-window.ts"; import { decisionBudgetFromInference } from "./decision-from-dossier.ts"; export const RECTIFICATION_TELEMETRY_FIELDS = [ @@ -277,7 +278,10 @@ export function buildRectificationTelemetryRow(input: RectificationTelemetryInpu questions_open: kindCount("open"), }; const typedSum = Object.values(typed).reduce((sum, value) => sum + value, 0); - const width = deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange); + const livingWidth = livingDisplayFromCandidates( + previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.candidates, + )?.widthMinutes ?? null; + const width = livingWidth ?? deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange); return { window_radius_minutes: windowRadiusMinutes(input.dossier.case.candidateRange), birth_time_source: telemetryBirthTimeSource(input.dossier.case), diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index f056cd70..830b380e 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -492,6 +492,7 @@ export function contrastProbesFromReceipt(receipt: Readonly { + const merged = mergeLivingDisplayIntervals([cluster("08:00", "08:09")]); + assert.equal(merged.length, 1); + assert.equal(inclusiveIntervalMinutes(merged), 10); + assert.equal(windowIntervalsLabel(merged, DAY), "08:00–08:09"); +}); + +test("a four-minute gap merges and a five-minute gap stays two segments", () => { + const merged = mergeLivingDisplayIntervals([cluster("08:00", "08:10"), cluster("08:15", "08:20")]); + assert.equal(merged.length, 1); + assert.equal(merged[0]?.end_at, at("08:20")); + assert.equal(inclusiveIntervalMinutes(merged), 21); + + const split = mergeLivingDisplayIntervals([cluster("08:00", "08:10"), cluster("08:16", "08:20")]); + assert.equal(split.length, 2); + assert.equal(inclusiveIntervalMinutes(split), 16); + assert.equal(windowIntervalsLabel(split, DAY), "08:00–08:10、08:16–08:20"); +}); + +test("five segments show the first three and the total remaining minutes", () => { + const rows = [ + cluster("08:00", "08:01"), + cluster("08:12", "08:13"), + cluster("08:24", "08:25"), + cluster("08:36", "08:37"), + cluster("08:48", "08:49"), + ]; + const merged = mergeLivingDisplayIntervals(rows); + assert.equal(merged.length, 5); + assert.equal(inclusiveIntervalMinutes(merged), 10); + assert.equal( + windowIntervalsLabel(merged, DAY), + "08:00–08:01、08:12–08:13、08:24–08:25,等 5 段,共约 10 分钟", + ); +}); + +test("the declared-segment envelope still fills a gap the display keeps", () => { + const rows = [cluster("08:00", "08:01"), cluster("08:48", "08:49")]; + const envelope = unionWindowIntervals(rows); + assert.equal(envelope.length, 1); + assert.equal(envelope[0]?.start_at, at("08:00")); + assert.equal(envelope[0]?.end_at, at("08:49")); + assert.equal(mergeLivingDisplayIntervals(rows).length, 2); +}); + +function cardResult(): RectificationCandidateResult { + const early = cluster("08:00", "08:09"); + const late = cluster("08:40", "08:49"); + const dropped = cluster("09:20", "09:29"); + return { + resultId: "result-living", + candidates: [ + { candidateId: ID_A, rank: 1, time: "08:04", relativeSupport: 20, tiedMinuteCount: 1, clusterIntervals: [early] }, + { candidateId: ID_B, rank: 2, time: "08:44", relativeSupport: 14, tiedMinuteCount: 1, clusterIntervals: [late] }, + { candidateId: ID_C, rank: 3, time: "09:24", relativeSupport: 10, tiedMinuteCount: 1, clusterIntervals: [dropped] }, + ], + overallConfidence: "low", + selectionAllowed: false, + canAdopt: false, + confirmationAllowed: false, + decisionReceipt: null, + representativeTime: "08:04", + 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: "provisional_range", + declaredBirthDate: DAY, + credibleRange: ["08:00", "08:49"], + credibleIntervals: [cluster("08:00", "08:49")], + rangeDelivery: null, + verificationReportMarkdown: null, + }; +} + +test("the delivery card title uses living segments, not the segment envelope", () => { + const result = cardResult(); + const living = livingDisplayFromCandidates(result.candidates, DAY); + assert.ok(living); + assert.equal(living.segmentCount, 2); + assert.equal(living.widthMinutes, 20); + assert.equal(living.label, "08:00–08:09、08:40–08:49"); + const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, { + result, + acceptingCandidateId: null, + readonly: true, + onAccept: () => undefined, + })); + assert.match(html, /目前范围 08:00–08:09、08:40–08:49/); + assert.doesNotMatch(html, /08:00–08:49/); + const narration = deliveryTurnNarration({ + credibleRange: result.credibleRange, + representativeTime: "08:04", + displayLabel: living.label, + displayWidthMinutes: living.widthMinutes, + displaySegmentCount: living.segmentCount, + }); + assert.match(narration, /目前范围 08:00–08:09、08:40–08:49/); + assert.match(narration, /剩余约 20 分钟,分 2 段/); + assert.doesNotMatch(narration, /定到分钟/); +}); + +test("the delivery card reads living clusters from the candidate, not only the envelope", () => { + const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8"); + assert.match(source, /livingDisplayFromCandidates\(result\.candidates/); + assert.match(source, /windowIntervalsLabel\(result\.credibleIntervals/); +}); diff --git a/scripts/rectification/living_display_intervals.py b/scripts/rectification/living_display_intervals.py new file mode 100644 index 00000000..fee1c15a --- /dev/null +++ b/scripts/rectification/living_display_intervals.py @@ -0,0 +1,79 @@ +"""Display-only merge of living candidate intervals. + +Decision width stays on ``interval_union_width`` / ``unionWindowIntervals``. +This module is not part of the sealed scoring identity: scoring must not import it. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +LIVING_DISPLAY_MERGE_GAP_MINUTES = 4 +LIVING_DISPLAY_MAX_SEGMENTS = 3 + + +def _stamp(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def merge_living_display_intervals( + intervals: list[dict[str, Any]], + gap_minutes: int = LIVING_DISPLAY_MERGE_GAP_MINUTES, +) -> list[dict[str, str]]: + """Merge intervals whose gap is <= gap_minutes. Gap is the open minutes between them.""" + rows = [] + for row in intervals: + start, end = str(row.get("start_at") or ""), str(row.get("end_at") or "") + if len(start) < 16 or len(end) < 16 or start > end: + continue + try: + _stamp(start) + _stamp(end) + except ValueError: + continue + rows.append({"start_at": start[:16], "end_at": end[:16]}) + rows.sort(key=lambda item: (item["start_at"], item["end_at"])) + merged: list[dict[str, str]] = [] + for row in rows: + if not merged: + merged.append(row) + continue + previous = merged[-1] + gap = int((_stamp(row["start_at"]) - _stamp(previous["end_at"])).total_seconds() // 60) - 1 + if gap <= gap_minutes: + if row["end_at"] > previous["end_at"]: + previous["end_at"] = row["end_at"] + else: + merged.append(row) + return merged + + +def inclusive_interval_minutes(intervals: list[dict[str, str]]) -> int: + total = 0 + for row in intervals: + width = int((_stamp(row["end_at"]) - _stamp(row["start_at"])).total_seconds() // 60) + 1 + if width > 0: + total += width + return total + + +def living_display_label( + intervals: list[dict[str, str]], + birth_date: str | None = None, + max_segments: int = LIVING_DISPLAY_MAX_SEGMENTS, +) -> str: + def label(value: str) -> str: + date, clock = value[:10], value[11:16] + if not birth_date: + return f"{date} {clock}" + if date == birth_date: + return clock + return f"{'前一天' if date < birth_date else '后一天'} {clock}" + + def text(row: dict[str, str]) -> str: + return f"{label(row['start_at'])}–{label(row['end_at'])}" + + if len(intervals) <= max_segments: + return "、".join(text(row) for row in intervals) + shown = "、".join(text(row) for row in intervals[:max_segments]) + return f"{shown},等 {len(intervals)} 段,共约 {inclusive_interval_minutes(intervals)} 分钟" diff --git a/tests/test_rectification_living_display_intervals.py b/tests/test_rectification_living_display_intervals.py new file mode 100644 index 00000000..d6d135c9 --- /dev/null +++ b/tests/test_rectification_living_display_intervals.py @@ -0,0 +1,77 @@ +"""Display merge for living candidate intervals. Decision envelopes stay untouched.""" +from __future__ import annotations + +from scripts.rectification.candidate_window import interval_union_width +from scripts.rectification.living_display_intervals import ( + inclusive_interval_minutes, + living_display_label, + merge_living_display_intervals, +) + +DAY = "2000-01-01" + + +def _at(clock: str) -> str: + return f"{DAY}T{clock}" + + +def _interval(start: str, end: str, segment: int = 0, start_offset: int = 0, end_offset: int = 0) -> dict: + return { + "start_at": _at(start), + "end_at": _at(end), + "segment_index": segment, + "start_index": start_offset, + "end_index": end_offset, + "start_offset_minutes": start_offset, + "end_offset_minutes": end_offset, + } + + +def test_single_segment_stays_one_piece(): + rows = [_interval("08:00", "08:09")] + merged = merge_living_display_intervals(rows) + assert len(merged) == 1 + assert inclusive_interval_minutes(merged) == 10 + assert living_display_label(merged, DAY) == "08:00–08:09" + + +def test_gap_of_four_minutes_merges_and_five_stays_split(): + touch = merge_living_display_intervals([ + _interval("08:00", "08:10"), + _interval("08:15", "08:20"), + ]) + assert len(touch) == 1 + assert touch[0]["end_at"] == _at("08:20") + assert inclusive_interval_minutes(touch) == 21 + + split = merge_living_display_intervals([ + _interval("08:00", "08:10"), + _interval("08:16", "08:20"), + ]) + assert len(split) == 2 + assert inclusive_interval_minutes(split) == 11 + 5 + assert living_display_label(split, DAY) == "08:00–08:10、08:16–08:20" + + +def test_five_segments_label_keeps_the_first_three_and_the_total(): + rows = [ + _interval("08:00", "08:01"), + _interval("08:12", "08:13"), + _interval("08:24", "08:25"), + _interval("08:36", "08:37"), + _interval("08:48", "08:49"), + ] + merged = merge_living_display_intervals(rows) + assert len(merged) == 5 + assert inclusive_interval_minutes(merged) == 10 + assert living_display_label(merged, DAY) == "08:00–08:01、08:12–08:13、08:24–08:25,等 5 段,共约 10 分钟" + + +def test_decision_envelope_still_fills_a_declared_segment_gap(): + """原口径:同一 segment_index 的首尾包络。显示合并不得改这个函数。""" + rows = [ + _interval("08:00", "08:01", start_offset=0, end_offset=1), + _interval("08:48", "08:49", start_offset=48, end_offset=49), + ] + assert interval_union_width(rows) == 50 + assert len(merge_living_display_intervals(rows)) == 2