Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56a3c08145 |
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-16 — 生时校正的交付范围按还活着的时间段显示
|
||||
|
||||
问完题之后,卡片和旁白不再把中间已经排除的空档算进「目前范围」。相邻不超过 4 分钟的段会并在一起;超过三段时只写出前三段,并写明一共几段、大约还剩多少分钟。打分、采用和确认没有改。
|
||||
|
||||
## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额
|
||||
|
||||
- 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。
|
||||
|
||||
@@ -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。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修。
|
||||
|
||||
@@ -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 没有交付。这和写盘、读盘保持同一个数。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 真机清单:交付范围按还活着的段显示(2026-10-16)
|
||||
|
||||
这一版只改「目前范围」怎么写。题数仍是原来的上限,进区间的门槛仍是 8 分。不要把任何人的出生日期、钟点或姓名写进这份清单、进度或聊天记录。
|
||||
|
||||
在一台已登录的测试账号上,用前后各 30 分钟的窗口走完一次校正:
|
||||
|
||||
1. 问完这一轮会出的选择题,直到出现范围卡片。
|
||||
2. 看卡片标题是一段,还是用顿号分开的几段。
|
||||
3. 若是两段或三段,旁白里应能看到「剩余约 X 分钟,分 N 段」。不应出现「定到分钟」。
|
||||
4. 若超过三段,标题只写最前面的三段,并写一共几段、大约还剩多少分钟。
|
||||
5. 中间已经空出来的间隔,不应再被画成一整段连续时间。
|
||||
6. 点「更像这个」之后,排盘用的是选中的那一分钟,不是把整段都确认掉。
|
||||
7. 这一版不会因为门槛改成 6 而少留人,也不会多问到 12 题。若题数或收窄幅度和改版前一样,这是预期。
|
||||
|
||||
本轮没有在真机上执行这份清单。
|
||||
+1
-1
@@ -1025,7 +1025,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
|
||||
|
||||
**卡头以范围为主(D3,2026-09-26)。** 主标题仍是「目前范围 HH:MM–HH:MM(对照了 N 件经历)」(`<strong>`,`--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 件经历)」(`<strong>`,`--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%),差距不明显的数字只会被读成「分出了高下」。
|
||||
|
||||
|
||||
@@ -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 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 |
|
||||
|
||||
@@ -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 && (
|
||||
<RectificationReadonlyRange
|
||||
range={candidateResult.credibleRange}
|
||||
displayLabel={livingDisplayFromCandidates(candidateResult.candidates, candidateResult.declaredBirthDate)?.label ?? null}
|
||||
sessionOutcome={candidateResult.sessionOutcome}
|
||||
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
|
||||
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
|
||||
|
||||
@@ -22,11 +22,13 @@ import { RectificationChoiceCard } from "./rectification-choice-card";
|
||||
|
||||
export function RectificationReadonlyRange({
|
||||
range,
|
||||
displayLabel,
|
||||
sessionOutcome,
|
||||
discriminatingEventProbes,
|
||||
contrastProbes,
|
||||
}: Readonly<{
|
||||
range: readonly [string, string];
|
||||
displayLabel?: string | null;
|
||||
sessionOutcome: string | null;
|
||||
discriminatingEventProbes: unknown;
|
||||
contrastProbes: unknown;
|
||||
@@ -35,6 +37,7 @@ export function RectificationReadonlyRange({
|
||||
<p className="rectification-readonly-range" role="status">
|
||||
{rectificationReadonlyRangeCopy({
|
||||
range,
|
||||
displayLabel,
|
||||
sessionOutcome,
|
||||
discriminatingEventProbes,
|
||||
contrastProbes,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number, WindowInterval>();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -492,6 +492,7 @@ export function contrastProbesFromReceipt(receipt: Readonly<Record<string, unkno
|
||||
/** Timeline range line. Never says 收窄. Ban the exhausted-deadend phrase. */
|
||||
export function rectificationReadonlyRangeCopy(input: Readonly<{
|
||||
range: readonly [string, string];
|
||||
displayLabel?: string | null;
|
||||
sessionOutcome?: string | null;
|
||||
discriminatingEventProbes?: unknown;
|
||||
contrastProbes?: unknown;
|
||||
@@ -509,7 +510,8 @@ export function rectificationReadonlyRangeCopy(input: Readonly<{
|
||||
: outcome === "collect_evidence"
|
||||
? "再说一件带年月的事就能继续"
|
||||
: "还在核对";
|
||||
return `目前范围 ${input.range[0]}–${input.range[1]},${suffix}`;
|
||||
const shown = input.displayLabel?.trim() || `${input.range[0]}–${input.range[1]}`;
|
||||
return `目前范围 ${shown},${suffix}`;
|
||||
}
|
||||
|
||||
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
|
||||
import {
|
||||
inclusiveIntervalMinutes,
|
||||
livingDisplayFromCandidates,
|
||||
mergeLivingDisplayIntervals,
|
||||
unionWindowIntervals,
|
||||
windowIntervalsLabel,
|
||||
type WindowInterval,
|
||||
} from "../src/lib/rectification-agentic/core/candidate-window.ts";
|
||||
import { deliveryTurnNarration } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
|
||||
|
||||
const DAY = "2000-01-01";
|
||||
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
|
||||
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
|
||||
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
|
||||
|
||||
function at(clock: string): string {
|
||||
return `${DAY}T${clock}`;
|
||||
}
|
||||
|
||||
function cluster(start: string, end: string): WindowInterval {
|
||||
const startOffset = Number(start.slice(0, 2)) * 60 + Number(start.slice(3, 5));
|
||||
const endOffset = Number(end.slice(0, 2)) * 60 + Number(end.slice(3, 5));
|
||||
return {
|
||||
segment_index: 0,
|
||||
start_index: startOffset,
|
||||
end_index: endOffset,
|
||||
start_offset_minutes: startOffset,
|
||||
end_offset_minutes: endOffset,
|
||||
start_at: at(start),
|
||||
end_at: at(end),
|
||||
};
|
||||
}
|
||||
|
||||
test("one living segment stays one segment", () => {
|
||||
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/);
|
||||
});
|
||||
@@ -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)} 分钟"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user