自适应生时纠正及公开案例回放(#260) #265

Merged
Claude merged 9 commits from codex/rect-adaptive-20261114 into staging 2026-11-14 20:00:38 +08:00
17 changed files with 1319 additions and 62 deletions
+5 -2
View File
@@ -4,6 +4,10 @@
- 报告正文和 Markdown 导出按段落、主题和时间点显示证据等级。缺少必需依据时说明哪一项受限。计算依据可查看各结论的等级上限。占星 Skill 版本不变(BUG-1887)。
## 2026-11-14 — 生时纠正结果只按填写时间保存
- 问卷结果说明现有信息不足以确认更准确的出生时间,只提供按填写时间(或范围中点)保存,不再提供改用、收窄或比较。之前选过比较范围的记录,首页可一键改回原填写时间或范围。沿用当前 Skill 版本(BUG-1886)。
## 2026-11-14 — 年运附录增加 KP 宫头和子主
- 按主题列出 Placidus 宫头及子主。记录出生时间误差范围内的变化,误差未知时标注参考取样。沿用当前 Skill 版本(BUG-1884)。
@@ -24,8 +28,7 @@
## 2026-11-14 — 生时纠正按回答调整下一题
- 每次作答后缩小候选时段,按仍能区分的生活领域选下一题。年龄选项附年份,进度显示已确认的盘数。
- 当前记录以代表时刻和范围为主,结果保留对得上的事件和把握分级。占星 Skill 版本不变。
- 默认保留填写时间与答题前的范围。答题区间只作参考,点「收窄到」或「改用」才会采用。
- 当前记录显示代表时刻和范围,问卷结果保留用户提供的事件。占星 Skill 版本不变。
## 2026-11-13 — 年运报告可导出 Markdown
+14
View File
@@ -25464,6 +25464,20 @@
- 关联记录:#269、BUG-1857、BUG-1868、BUG-1869。
- 修复版本:`codex/annual-pravesha-20261114`,待独立验收。
## BUG-1886 | 问卷建议改用不含参照时刻的区间
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-14 / 2026-11-14。
- 影响面:生时纠正问卷结果页。
- 现象及触发:答题区间未包含填写时刻或范围中点时,结果仍突出该段、显示匹配事件并提供「改用」按钮。
- 根因:FIX-4 把显式采纳作为保护措施,未按答题段是否包含参照时刻限制建议。同宽度回放中,不含填写时刻的问卷段仅命中 7 / 33。
- 修复:范围带显示原范围与参照时刻。答题段包含参照时刻时展示答题提示、区间高亮和匹配依据。答题段同时更窄时提供收窄。
- 验证及防复发:真实引擎夹具覆盖填写钟点和范围中点的两种关系。390 像素 Chromium 验证默认保存及收窄提交。见 [#260 进度](tasks/PROGRESS-rect-adaptive-20261114.md)。
- 11-14 占星师裁定:问卷结果固定说明无法确认更准确的出生时间。首页和问卷结果隐藏把握分级与吻合判断,事件按日期及类别展示。符合原筛选条件的区间用于比较,可从首页恢复到填写时间或原范围。长报告回归验证任意宽度的比较区间都保持 `reading_minute_confirmed=false` 和 `birth_time_accuracy=provisional`。
- FIX-7(11-14):#272 配对重跑(77 人 × 8 偏差)中,答题段更窄且包含填写时刻的 29 次只有 13 次包含公开记录时刻,不缩窄的 483 次全部包含。产品负责人决定去掉「按 X 这段比较」:结果页不画答题段,只留「用 HH:MM」,前端不再提交 useAnswerWindow;后端来源与迁移保留,旧比较记录首页仍可改回。测试 `chart-choice-rounds-page-20261113.test.tsx` 断言两种情况都只有一个按钮、时间带无答题段。
- 关联记录:#260、#272、BUG-1881。
- 修复版本:`codex/rect-adaptive-20261114`,待独立验收。
## BUG-1887 | 年运模型自报把握,主题与时间点没有依赖上限
- 状态:fixed-pending-verify(待独立验收)。
+37
View File
@@ -0,0 +1,37 @@
# #260 退回 2:去掉「你的经历更像…」和「改用这段」(2026-11-14,Claude)
本文件补充 FIX-0 至 FIX-4。冲突时以本文件为准。Bug 编号 BUG-1886。
## 为什么改
`rect-adaptive-replay.md` 的回放里,问卷选出的段不包含填写时间的 33 例中,只有 7 例命中(21.2%)。这时让用户「改用」答题选出的段,大多数情况是把对的时间改错。产品负责人 11-14 同意去掉。
包含填写时间的 44 例中有 38 例命中,「收窄」保留。
## 改法(只改结果页,`frontend/src/components/chart-choice/chart-choice-page.tsx` 的 `FinalStep` 问卷分支)
1. **问卷选出的段包含参照时间**(参照时间 = 填的时间;没填时间时 = 范围中点):保持现状。
- 提示「你的回答和填的时间对得上。」(没填时间时不要说「填的时间」,改成「你的回答落在范围中间这段。」);
- 主按钮「用 05:00」,次按钮「收窄到 …」。
2. **问卷选出的段不包含参照时间:**
- 不显示「你的经历更像 … 这段」,也不换成别的说法,提示句整句不出;
- 不显示次按钮,只剩主按钮「用 05:00」;
- `SelectedBand` 不画答题选出的段,只画原范围和参照时间;
- `matchedEvidence` 那句「…和这段时间的大运对得上」不显示(它说的是答题选出的那段)。
3. 「改用」两个字在这个组件里不再出现。`onUseAnswerWindow` 只在包含参照时间、且段比原范围窄时传下去。
4. 后端、保存来源、迁移、报告链路都不改。`user_choice_from_answers` 仍只由「收窄」写入。
## 文档
- `docs/BUG_HISTORY.md` 新增 BUG-1886(只在文件末尾追加这一节,不批量替换):现象、根因(回放 21% 命中)、修复、验证、防复发。不写出生资料或案例名。
- `rect-adaptive-replay.md` 结论段补一句:「不包含填写时间时不再给改用建议(BUG-1886)」。
- 更新 `PROGRESS-rect-adaptive-20261114.md`。
## 验收
- 测试(`frontend/tests/` 下新增或扩 `chart-choice-rounds-page-20261113.test.tsx`):
- 段不包含参照时间:渲染里没有「更像」「改用」,没有次按钮,没有「大运对得上」;
- 段包含参照时间且更窄:有「收窄到」,点击走 `onUseAnswerWindow`;
- 没填时间的情况各一例。
- 门禁:Python quick gate、前端相关测试、`tsc`、`npx eslint .` 0 errors。
- 截图:390 宽度,包含 / 不包含两种结果页。
+49
View File
@@ -0,0 +1,49 @@
# #260 退回 3:按占星师 11-14 裁定收口文案和「收窄」(2026-11-14,Claude)
本文件补充 FIX-0 至 FIX-5。冲突时以本文件为准。仍记在 BUG-1886 下(同一问题:结果页把未验证的答题结果说得太确定),在该节补充,不新开编号。
## 依据
占星师 11-14 对回测的裁定(要点,原文由产品负责人保管,不入库):
- 原始出生记录不自动覆盖;用户的任何选择另存为「分析假设」,可撤销,不改出生时间、来源,也不置 minute_confirmed。
- 问卷用于收集事实、展示候选差异。未校准的排序不能发布「已校正」「你的真实时间更可能是某一分钟」。
- 停用「吻合率 → 准确率 / 高置信度」的转换。low / medium / high 只能作内部标签,不转换成用户看到的把握或概率;源里本来也没有「倾向」这一档。
- 她给的文案:「现有信息不足以确认更准确的出生时间。……你可以选择一个用于比较,不代表真实时间已被证实。」
## 改法
1. **把握分级不再给用户看。**
- 删掉 `CONFIDENCE_LABEL` 在首页(`HomeView`)和结果页的所有显示,「把握 高/中/低」「倾向」都不出现。
- 后端 `evidence_summary.confidence` 保留,只作内部字段和回放统计。
2. **结果页(问卷分支):**
- 标题下第一句固定为「现有信息不足以确认更准确的出生时间。」,放在「范围 …」之后。
- 删掉「你的回答和填的时间对得上。」「你的回答落在范围中间这段。」两句。
- 删掉 `matchedEvidence` 那句「…和这段时间的大运对得上」。问卷分支不再显示任何「对得上 / 吻合」类的话;已有的事件列表照常显示。
- 次按钮文案从「收窄到 X」改为「按 X 这段比较」。按钮下加一行小字:「只用于比较,不代表真实时间已被证实,随时可以改回。」只有 `canNarrow` 时才出现,条件不变。
3. **首页:**
- 当前来源是 `user_choice_from_answers` 时:
- 标签从「当前使用」改为「你选的比较范围」;
- 下加一行「你填的 05:00 没有改」;
- 加次按钮「改回填的时间」,一点即保存 `reported_time_default`(没填时间时用 `window_midpoint_default`,按钮文案「改回原范围」),不重新答题。
- 其他来源保持 FIX-1 的样子。
4. **报告链路:**
- 写一条测试:来源为 `user_choice_from_answers` 时,无论区间多窄,`reading_minute_confirmed` 都不能为 true,`birth_time_accuracy` 不能被改成 `confirmed`(`frontend/src/lib/personal-report-longform-birth.ts` 第 146 行附近)。
- 如果现在会变成 true,就修掉。
5. 除第 3 条的「改回」保存外,后端、迁移、保存来源都不改。
## 文档
- BUG-1886 一节补「11-14 占星师裁定」:把握分级不外露、比较文案、可撤销、minute_confirmed 测试。
- `rect-adaptive-replay.md` 加一句:「low/medium/high 是旧算法内部标签,不代表准确率(占星师 11-14)。」
- 更新 PROGRESS。
## 验收
- 测试:
- 首页和结果页都不出现「把握」「倾向」「对得上」「更像」「改用」;
- 「按 X 这段比较」只在段包含参照时间且比原范围窄时出现;
- 「改回填的时间」保存 `reported_time_default`;
- 第 4 条的 minute_confirmed 测试。
- 门禁:Python quick gate、前端相关测试、`tsc`、`npx eslint .` 0 errors。
- 截图(390 宽):结果页包含 / 不包含两种;首页默认态;首页「你选的比较范围」态。
+46
View File
@@ -0,0 +1,46 @@
# #260 退回 4:去掉「按 X 这段比较」(2026-11-14,Claude)
本文件补充 FIX-0 至 FIX-6,冲突时以本文件为准。仍记在 BUG-1886 下。
## 为什么改
#272 做了配对重跑(77 人 × 8 个偏差,共 616 次),按网站实际选出的那一段统计:
| 结果页的情况 | 次数 | 包含真实时间 |
|---|---:|---:|
| 不收窄(保留原范围) | 483 | 483 |
| 比原范围窄,且包含填的时间 | 29 | 13(45%) |
| 比原范围窄,不含填的时间 | 104 | 7(6.7%) |
FIX-6 保留的「按 X 这段比较」就是第二行:一半以上会把真实时间排除在外。产品负责人 11-14 决定去掉。
## 改法
1. 结果页问卷分支:
- 删掉「按 X 这段比较」按钮和下面那行小字;
- 只保留主按钮「用 05:00」(没填时间时「用范围中点」,文案照旧)。
2. 结果页不再画答题选出的段:
- 时间条只画原范围和参照时间,包含和不包含两种情况一样;
- 删掉 `canNarrow`、`containsReference` 等只为这个按钮服务的分支;
- 「现有信息不足以确认更准确的出生时间。」和事件列表保留。
3. 前端不再调用 `saveFinal(step, true)`:
- 删掉 `FinalStep` 的 `onUseAnswerWindow` 属性;
- 后端写入 `user_choice_from_answers` 的分支和迁移保留,不删,旧记录还要能读。
4. 首页的「你选的比较范围」和「改回填的时间」保留。只有之前已经选过的记录会显示它们,新记录不会再出现。
5. 后端、迁移、报告链路都不改。
## 文档
- BUG-1886 补一段:#272 重跑结果(上表)和去掉比较按钮的原因。
- `rect-adaptive-replay.md` 结论处加一句:「结果页不再提供任何收窄或改用,见 #272。」
- 更新 PROGRESS。
## 验收
- 测试:
- 结果页(包含 / 不包含两种)都只有一个按钮;
- 不出现「比较」「收窄」「改用」;
- 时间条上没有答题段;
- 旧的 `user_choice_from_answers` 记录在首页仍显示「改回填的时间」,点了保存 `reported_time_default`。
- 门禁:Python quick gate、前端相关测试、`tsc`、`npx eslint .` 0 errors。
- 截图(390 宽):结果页一张、首页默认态一张。
@@ -34,3 +34,39 @@
- 前端全量 3734 条,3654 通过、43 失败、37 跳过、0 取消。全部失败测试名均可在同机对照找到,没有既有测试名缺失。Docker Desktop 的 Linux 引擎不可用,容器数据库套件留待独立验收。
- 新迁移只扩充 v2 写入函数的来源允许值,兼容已经部署的写入及历史复用。已在隔离 PostgreSQL 实施迁移,验证旧来源、两种新来源、报告区间和历史复用。迁移版本唯一性检查在同机 staging 和本分支均检出同样三组历史重号,新迁移版本无冲突。
- 77 例回放中问卷命中 45 例,填写时间居中的同宽区间命中 54 例,随机同宽区间期望命中 49.49 例。两种健康题配置下,44 个候选段包含填报时刻,其中命中 38 个,其余段命中 7 / 33。见[回放报告](rect-adaptive-replay.md)。
## FIX-5 返工(BUG-1886)
- 基于修复任务书提交 `63af951c` 和 staging `c18a094a` 更新 PR #265。问卷结果页按填写时刻或范围中点检查答题段。包含参照时刻时显示答题依据,且答题段更窄时才显示「收窄到」。其余结果显示原范围、参照时刻和主按钮。
- 使用真实引擎补齐无填写时刻的两份合成样本。前端定向 29 项通过。390 像素 Chromium 核对两种时间带、默认保存与收窄提交,截图随 PR 提供。
- TypeScript 与生产构建通过,ESLint 0 错误,63 条既有警告。首页保持静态。两边按首页 HTML 中的唯一脚本和样式文件测得 gzip JS 519350 字节、CSS 93367 字节。
- 本机 Docker Desktop 响应 HTTP 500。以相同的进程 PATH 和并发度对照全量前端测试。两边均为 3739 项,3656 通过、46 失败、37 跳过、0 取消,失败名称一致。下表涉及的断言所在测试已按新行为改名,其余测试名称一致。
- Python 选盘及仓库隐私回归共 110 项通过。
- Python 快速门 1496 通过、4 失败、2 跳过,另有 29 个子测试通过。在 staging `c18a094a` 单独复跑两个相关测试文件,复现同样四项失败。分别涉及 Kaala / PyJHora 和 VedAstro 咨询层。快速门在 Python 阶段退出,前端各项已单独执行。
- 总预检确认远端可见和引擎适配器可用。目录扫描与预检定向测试分别达到 90 秒和 45 秒预算,与本工单先前记录一致。
| 既有断言原值 | 新值 | 原因 |
| --- | --- | --- |
| 答题段排除填写时刻时显示「你的经历更像」和「改用」 | 隐藏答题提示、候选带高亮与匹配依据。操作区只有默认保存 | FIX-5 根据回放限制收窄建议 |
| 答题段包含填写时刻时显示原提示和「收窄到」 | 保留两项,并校验时间带及匹配依据。增加范围中点内外两组引擎响应 | 验证缺钟点时采用范围中点的同一规则 |
## FIX-6 返工(BUG-1886)
- 基于返工任务书提交 `474caca7`,沿用 PR #265。问卷完成后显示固定说明和用户提供的事件。只有包含参照时刻且比原范围窄的答题段提供比较入口。比较态的首页显示原始申报仍在,并提供一键恢复。
- 恢复只针对当前仍可复用的比较记录。服务端用原申报重新计算默认时刻和六盘,再经现有保存流程写入 `reported_time_default` 或 `window_midpoint_default`。选用比较范围时保留原申报的范围和来源。无钟点时使用申报范围的中点,跨两小时的范围由引擎计算完整范围的默认盘。比较来源在长报告中始终保持 provisional。
- Chromium 390 像素用合成资料核对四个页面状态,并实际点击保存比较和恢复按钮。真实引擎独立验证了钟点、区间中点和接近全天的范围恢复,三种都返回原区间和六盘。
- Python 选盘及隐私回归共 126 项通过。前端定向的两种恢复来源和过期记录拒绝均有覆盖。
- 前端定向 37 项通过、3 项跳过。TypeScript 和生产构建通过,ESLint 0 错误、63 条既有警告。首页保持静态,首载 gzip JS 519350 字节、CSS 93367 字节,与 staging 相同。
- 前端全量 3742 项,3659 通过、46 失败、37 跳过、0 取消。对照同机无 Docker 配置下的 staging,失败名称完全一致,新增 3 项。保留 FIX-5 曾改名的比较条件测试。
- Python 快速门 1496 项通过、4 项失败、2 项跳过,另有 29 个子测试通过。三项 Kaala / PyJHora 和一项 VedAstro 咨询层失败均与已在同机 staging 复现的名单一致。
- 预检的远端可见性、适配器和碎片扫描通过。定向套件遇到台账 #220 / #237 已记录的历史镜像路径断言。
| 既有断言原值 | 新值 | 原因 |
| --- | --- | --- |
| 问卷结果不出现「准确」;包含参照时刻时写答题吻合、大运吻合及「收窄到」 | 验证任务书固定句、事件列表、比较按钮和说明。排除准确率、把握及吻合判断,并补原宽区间不显示比较按钮的断言 | FIX-6 将问卷输出作为比较范围 |
| 首页默认态显示代表时刻、范围和未定分盘 | 保留这些断言,补把握分级和恢复入口只在相应状态显示的断言 | 区分默认保存与可撤销的比较状态 |
## FIX-7(Claude 执行,11-14)
- Codex 按退回次数暂停后,产品负责人要求合并,由 Claude 直接执行 FIX-7:结果页去掉「按 X 这段比较」和答题段,`saveFinal` 不再带 useAnswerWindow。
- 验证:`tsc` 通过;chart-choice 前端测试 27 项通过;ESLint 0 错误。
+6
View File
@@ -22,6 +22,8 @@
两组均为倾向 77 / 77,档内命中 45 / 77。高、中、低各 0 例,无法估计这些档的命中率。启用健康题时,本次回放的选中率和平均区间宽度没有变化。
low/medium/high 是旧算法内部标签,不代表准确率(占星师 11-14)。
两种模式的偏差分组相同。
| 填写偏差(分钟) | 案例数 | 问卷命中 | 同宽随机期望 | 同宽填写时间居中 |
@@ -36,3 +38,7 @@
| +10 | 9 | 7 | 4.29 | 3 |
问卷段包含填写时间 44 / 77(57.1%),其中命中 38 / 44(86.4%)。其余 33 段命中 7 段(21.2%)。这些是同一次回放的分组统计。问卷不优于随机,默认采用填写时间并保留原窗口。
不包含填写时间时不再给改用建议(BUG-1886)。
结果页不再提供任何收窄或改用,见 #272 配对重跑与 FIX-rect-adaptive-7(BUG-1886)。low/medium/high 是旧算法内部标签,不代表准确率(占星师 11-14)。
+2 -2
View File
@@ -357,8 +357,8 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa
### Rectification surface
- **按盘选盘(#239,BUG-1873):** 范围、答题、结果和当前记录用平面布局,靠留白和细线分组。答题用 36px 时间带、22px 衬线标题和浅底选项,选项内的 8px 小带表示覆盖范围。事件选项原地展开年份和类别,展开 0.32 秒,遵循系统的减少动态效果设置。结果突出候选段,六盘用细带展示段内换座,并列出证据与冲突。首页保留星盘、重做和按未定盘续问。
- **自适应生时纠正(#260,BUG-1881):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围与把握分级。结果仅列出匹配的事件依据。
- **#260 返工:** 结果首屏标题是「按你填的 HH:MM」,下方显示原答题范围和答题参考。六盘按主按钮时刻显示,跨换座线的盘标「还没定」和换座时刻。全宽主按钮为「用 HH:MM」,下方描边按钮根据候选段是否包含该时刻显示「收窄到」或「改用」;两个范围相同时隐藏次按钮。没有填写钟点时,标题明确说「按范围中点」。保存后首页仍先显示采用时刻,再显示保存范围与六盘状态。
- **自适应生时纠正(#260,BUG-1881):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。
- **问卷结果(#260,BUG-1886):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」时间带只画原范围和参照时刻,不画答题段;只有一个主按钮「用 HH:MM」,不提供收窄或比较(FIX-7)。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
+5
View File
@@ -608,3 +608,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
## 生时纠正问卷(#260)
- 结果页在范围后写「现有信息不足以确认更准确的出生时间。」不提供收窄或比较按钮(FIX-7)。事件列表只写日期和类别。
- 首页比较态(只有 FIX-7 之前选过的旧记录)写「你选的比较范围」和「你填的 HH:MM 没有改」,按钮「改回填的时间」。只填过范围时写「你填的范围没有改」,按钮「改回原范围」。
@@ -2,13 +2,13 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { chartAnswersSchema, chartChoiceCaller, chartChoiceErrorResponse, evidenceInput, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support";
import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice } from "@/lib/chart-choice/server";
import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
const side = z.enum(["before", "after"]);
const bodySchema = z.object({
const choiceSchema = z.object({
subjectId: z.string().max(64).optional(),
window: windowSchema.optional(),
resolved: resolvedSchema.optional(),
@@ -25,6 +25,11 @@ const bodySchema = z.object({
rangeChosen: z.boolean().optional(),
...evidenceInput,
}).strict().refine((body) => !body.useAnswerWindow || (body.adaptive && body.mode === "choice"));
const bodySchema = z.union([choiceSchema, z.object({
mode: z.literal("restore_default"),
subjectId: z.string().max(64).optional(),
recordId: z.string().uuid(),
}).strict()]);
/**
* POST /api/chart-choice/adopt — preview (mode "preview") or save the chosen chart. The server
@@ -36,6 +41,11 @@ export async function POST(request: Request) {
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
try {
if (parsed.data.mode === "restore_default") {
return NextResponse.json(await restoreChartChoiceDefault(
caller.supabase, createAdminSupabaseClient(), caller.userId, parsed.data,
));
}
if (parsed.data.mode === "preview") {
if (parsed.data.adaptive) {
return NextResponse.json(await previewAdaptiveChartChoice(caller.supabase, caller.userId, parsed.data));
@@ -294,12 +294,11 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
}
}
async function saveFinal(final: Extract<Step, { kind: "final" }>, useAnswerWindow?: true) {
async function saveFinal(final: Extract<Step, { kind: "final" }>) {
setPending(true);
try {
const { decision } = await postJson<{ decision: FinalDecision }>("/api/chart-choice/adopt", {
...final.body, mode: "choice", ...(rangeChosen ? { rangeChosen: true } : {}),
...(useAnswerWindow ? { useAnswerWindow } : {}),
});
afterSave(clockLabel(decision.saved_at, final.packet.reported_at, !decision.saved_at.endsWith(":00") && decision.saved_at.length === 19));
} catch (error) {
@@ -309,6 +308,18 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
}
}
async function restoreDefault(recordId: string) {
setPending(true);
try {
const { saved_at } = await postJson<{ saved_at: string }>("/api/chart-choice/adopt", { subjectId, recordId, mode: "restore_default" });
afterSave(clockLabel(saved_at));
} catch (error) {
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "start" } });
} finally {
setPending(false);
}
}
async function finishQuestions(
currentStep: Extract<Step, { kind: "chart_range" | "chart_question" }>,
submitted: ChartChoiceChartAnswers,
@@ -356,7 +367,8 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
</Card>
);
case "home":
return <HomeView history={history} onStartOver={startOver} />;
return <HomeView history={history} onStartOver={startOver} reportedAt={start?.reportedAt ?? null}
pending={pending} onRestoreDefault={(id) => void restoreDefault(id)} />;
case "narrow":
return (
<Card>
@@ -447,8 +459,7 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
);
case "final":
return <FinalStep packet={step.packet} decision={step.decision} pending={pending}
onBack={() => restore(step.back)} onAccept={() => void saveFinal(step)}
onUseAnswerWindow={() => void saveFinal(step, true)} />;
onBack={() => restore(step.back)} onAccept={() => void saveFinal(step)} />;
}
}
}
@@ -965,20 +976,13 @@ function matchedEvidence(decision: FinalDecision): string | null {
return `${labels.join("、")},和这段时间的大运对得上${count < 3 ? ",先作参考" : ""}。`;
}
const CONFIDENCE_LABEL: Readonly<Record<string, string>> = {
high: "把握 高", medium: "把握 中", low: "把握 低", tendency_only: "倾向",
};
export function FinalStep({ packet, decision, pending, onBack, onAccept, onUseAnswerWindow }: {
export function FinalStep({ packet, decision, pending, onBack, onAccept }: {
readonly packet: FinalPacket;
readonly decision: FinalDecision;
readonly pending: boolean;
readonly onBack: () => void;
readonly onAccept: () => void;
readonly onUseAnswerWindow?: () => void;
}) {
const evidence = matchedEvidence(decision);
const confidence = CONFIDENCE_LABEL[decision.evidence_summary?.confidence ?? ""];
const seconds = decision.saved_at.length === 19 && !decision.saved_at.endsWith(":00");
const saved = clockLabel(decision.saved_at, packet.reported_at, seconds);
if (packet.status === "questionnaire") {
@@ -986,38 +990,31 @@ export function FinalStep({ packet, decision, pending, onBack, onAccept, onUseAn
const reference = reported?.saved_at ?? packet.reported_at;
const defaultWindow = reported?.segment ?? packet.window;
const hasReportedTime = reported?.reported_known ?? packet.reported_known;
const reportedTime = minutesFrom(reference);
const starts = minutesFrom(decision.segment.start_at);
const ends = minutesFrom(decision.segment.end_at);
const containsReference = starts <= reportedTime && reportedTime <= ends;
const sameWindow = starts === minutesFrom(defaultWindow.start_at) && ends === minutesFrom(defaultWindow.end_at);
const clock = clockLabel(reference);
const range = segmentLabel(decision.segment, reference);
const advisory = hasReportedTime && containsReference
? "你的回答和填的时间对得上。"
: `你的经历更像 ${range} 这段${confidence ? `(${confidence})` : ""}。`;
const events = decision.events ?? [];
return <section className="chart-choice-flat chart-choice-result-flat">
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
<div className="chart-choice-flat-section">
<h2 className="chart-choice-heading">{hasReportedTime ? `按你填的 ${clock}` : `按范围中点 ${clock}`}</h2>
<p className="chart-choice-fine">范围 {segmentLabel(defaultWindow, reference)}</p>
<p className="chart-choice-note">{advisory}</p>
<SelectedBand window={defaultWindow} segment={decision.segment} reportedAt={reference} />
<p className="chart-choice-note">现有信息不足以确认更准确的出生时间。</p>
<ChoiceBand window={defaultWindow} reportedAt={reference} segments={[1]} />
</div>
{reported ? <div className="chart-choice-flat-section">
<h3 className="chart-choice-fine">按 {clock} 看六张盘</h3>
<KeyCharts charts={reported.key_charts} undetermined={reported.undetermined_charts}
reference={reported.saved_at} segment={reported.segment} />
</div> : null}
{evidence ? <p className="chart-choice-note chart-choice-flat-section is-ruled">{evidence}</p> : null}
{events.length ? <div className="chart-choice-flat-section">
<p className="chart-choice-fine">你提到的事</p>
<EventChips events={events} />
</div> : null}
<div className="chart-choice-full-action chart-choice-result-actions">
<Primary disabled={pending} onClick={onAccept}>用 {clock}</Primary>
{!sameWindow && onUseAnswerWindow ? <Secondary disabled={pending} onClick={onUseAnswerWindow}>
{containsReference ? "收窄到" : "改用"} {range}
</Secondary> : null}
</div>
</section>;
}
const evidence = matchedEvidence(decision);
return <>
<Card>
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
@@ -1064,19 +1061,22 @@ function ReadingPlan({
);
}
export function HomeView({ history, onStartOver }: {
export function HomeView({ history, onStartOver, reportedAt, pending, onRestoreDefault }: {
readonly history: ChartChoiceHistory;
readonly onStartOver: () => void;
readonly reportedAt: string | null;
readonly pending: boolean;
readonly onRestoreDefault: (recordId: string) => void;
}) {
const current = history.records.find((record) => record.id === history.current_record_id) ?? null;
const decision = current?.detail.decision;
const confidence = CONFIDENCE_LABEL[decision?.evidence_summary?.confidence ?? ""];
const comparisonOnly = current?.detail.provenance?.source === "user_choice_from_answers";
return (
<section className="chart-choice-flat chart-choice-home">
{current ? (
<div className="chart-choice-flat-section">
<div className="chart-choice-card-top">
<p className="chart-choice-label">当前使用</p>
<p className="chart-choice-label">{comparisonOnly ? "你选的比较范围" : "当前使用"}</p>
<p className="chart-choice-fine">{savedAtLabel(current.created_at)} 保存</p>
</div>
<h2 className="chart-choice-heading chart-choice-current-time">{clockLabel(
@@ -1084,7 +1084,7 @@ export function HomeView({ history, onStartOver }: {
)}</h2>
{decision?.segment ? <>
<p className="chart-choice-fine">范围 {segmentLabel(decision.segment)}</p>
{confidence ? <p className="chart-choice-fine">{confidence}</p> : null}
{comparisonOnly ? <p className="chart-choice-fine">{reportedAt ? `你填的 ${clockLabel(reportedAt)} 没有改` : "你填的范围没有改"}</p> : null}
<SelectedBand window={current.detail.window} segment={decision.segment} reportedAt={current.detail.reported_at} />
<KeyCharts charts={decision.key_charts} undetermined={decision.undetermined_charts}
reference={decision.saved_at} segment={decision.segment} />
@@ -1105,8 +1105,13 @@ export function HomeView({ history, onStartOver }: {
</>}
<div className="chart-choice-button-pair">
<AppLink href="/chart" className="chart-choice-link">看星盘</AppLink>
<Primary onClick={onStartOver}>重新纠正</Primary>
<Primary disabled={pending} onClick={onStartOver}>重新纠正</Primary>
</div>
{comparisonOnly && current.reusable ? <div className="chart-choice-full-action">
<Secondary disabled={pending} onClick={() => onRestoreDefault(current.id)}>
{reportedAt ? "改回填的时间" : "改回原范围"}
</Secondary>
</div> : null}
</div>
) : (
<div className="chart-choice-full-action"><Primary onClick={onStartOver}>开始纠正</Primary></div>
+1 -1
View File
@@ -251,7 +251,7 @@ export type ChartChoiceRecordDetail = {
readonly saved_at: string;
} & Partial<ChartChoiceRoundResult> & ChartChoiceEvidence;
/** The adoption data saved by the history RPC. */
readonly provenance?: { readonly contract?: string; readonly segment?: ChartChoiceWindow };
readonly provenance?: { readonly contract?: string; readonly segment?: ChartChoiceWindow; readonly source?: string };
/** Labels of the flipped charts with both sides' ascendant, in comparison order. */
readonly charts: readonly { readonly chart: string; readonly label: string; readonly domain: string }[];
};
+63 -5
View File
@@ -28,6 +28,7 @@ import { declaredNeedsRange } from "./declared.ts";
const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
const ENGINE_TIMEOUT_MS = 8000;
const RESTORE_WIDE_RANGE_TIMEOUT_MS = 20_000;
const ROUNDS_VERSION = "chart-choice-v2";
const MAX_RECORD_SPAN_MINUTES = Math.max(...RECORD_RANGE_CHOICES);
@@ -73,7 +74,13 @@ async function loadSubject(supabase: unknown, userId: string, subjectId: unknown
throw new ChartChoiceError("subject_not_found", 404);
}
const row = declarationRow(resolved.row);
const prepared = await prepareChartViewProfile({ row });
const declared = lagnaPrecheckWindow({ row });
// Date and location still come from the declaration. For a range-only
// declaration the chart profile needs a calculation clock to resolve them.
const midpoint = declared && !reportedAt(declared) ? midpointAt(declared) : null;
const prepared = await prepareChartViewProfile({
row: midpoint ? { ...row, birth_date: midpoint.slice(0, 10), reported_birth_time: midpoint.slice(11, 16) } : row,
});
if (!prepared.ok) throw new ChartChoiceError("profile_incomplete", 409);
return {
subjectId: resolved.subjectId,
@@ -84,7 +91,7 @@ async function loadSubject(supabase: unknown, userId: string, subjectId: unknown
time: resolved.row.active_birth_time,
provenance: resolved.row.active_birth_provenance,
},
declared: lagnaPrecheckWindow({ row }),
declared,
location: {
lat: prepared.profile.latitude,
lon: prepared.profile.longitude,
@@ -98,6 +105,11 @@ function minutesOf(at: string): number {
return Date.parse(`${at}:00Z`) / 60_000;
}
function midpointAt(window: ChartChoiceWindow): string {
const minute = Math.floor((minutesOf(window.start_at) + minutesOf(window.end_at)) / 2);
return new Date(minute * 60_000).toISOString().slice(0, 16);
}
const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
function reportedAt(declared: LagnaPrecheckWindow): string | null {
@@ -135,13 +147,13 @@ function scanWindow(subject: Subject, requested: ChartChoiceWindow | undefined):
return requested;
}
async function engine<T>(path: string, body: Record<string, unknown>, fetchImpl: typeof fetch = fetch): Promise<T> {
async function engine<T>(path: string, body: Record<string, unknown>, fetchImpl: typeof fetch = fetch, timeoutMs = ENGINE_TIMEOUT_MS): Promise<T> {
let response: Response;
try {
response = await fetchImpl(`${apiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(ENGINE_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
body: JSON.stringify(body),
});
} catch {
@@ -464,6 +476,52 @@ export async function adoptChartChoice(
return saveChartChoice(admin, userId, request, await decideChartChoice(supabase, userId, request, fetchImpl, admin));
}
/** Restore the default for the current comparison using the declaration and no questionnaire answers. */
export async function restoreChartChoiceDefault(
supabase: unknown,
admin: RpcClient,
userId: string,
request: { subjectId?: string; recordId: string },
fetchImpl?: typeof fetch,
): Promise<{ saved_at: string }> {
const subject = await loadSubject(supabase, userId, request.subjectId);
const history = await readHistory(admin, userId, subject.subjectId);
const current = history.records.find((record) => record.id === history.current_record_id);
if (current?.id !== request.recordId || !current.reusable
|| current.detail.provenance?.source !== "user_choice_from_answers") {
throw new ChartChoiceError("record_stale", 409);
}
const declared = scanWindow(subject, undefined);
const at = subject.declared && reportedAt(subject.declared);
const start = minutesOf(declared.start_at);
const end = minutesOf(declared.end_at);
let window = declared;
let reference = at;
if (end - start > MAX_RECORD_SPAN_MINUTES * 2) {
// The engine accepts a short scan plus the full range for its default projection.
reference ??= midpointAt(declared);
const around = windowAround(reference, MAX_RECORD_SPAN_MINUTES);
window = { start_at: around.start_at > declared.start_at ? around.start_at : declared.start_at,
end_at: around.end_at < declared.end_at ? around.end_at : declared.end_at };
}
const packet = await engine<ChartChoiceEnginePacket>("/api/chart_choice/decide", {
...engineBody(subject, window), adaptive: true, stop: true,
...(window !== declared ? { reported_at: reference, reported_default_window: declared } : {}),
}, fetchImpl, window === declared ? ENGINE_TIMEOUT_MS : RESTORE_WIDE_RANGE_TIMEOUT_MS);
if (packet.status !== "complete" || packet.engine_version !== ROUNDS_VERSION || !packet.decision.reported_choice) {
throw new ChartChoiceError("no_choice", 409);
}
const latest = await readHistory(admin, userId, subject.subjectId);
if (latest.current_record_id !== current.id || !latest.records.some((record) => record.id === current.id && record.reusable)) {
throw new ChartChoiceError("record_stale", 409);
}
const { decision } = await saveChartChoice(admin, userId, { adaptive: true }, {
packet, subject, answers: {},
decision: { ...packet.decision, reported_choice: { ...packet.decision.reported_choice, reported_known: at !== null } },
});
return { saved_at: decision.saved_at };
}
async function saveChartChoice(admin: RpcClient, userId: string, request: ChartChoiceAdoptRequest, result: DecidedChoice) {
const { packet, answers, subject } = result;
let decision = result.decision;
@@ -520,7 +578,7 @@ async function saveChartChoice(admin: RpcClient, userId: string, request: ChartC
p_time: time.length === 5 ? `${time}:00` : time,
p_date: date,
p_offset: subject.location.tz,
...rangeToSave(subject, request.resumeRecordId ? undefined : request.window, request.rangeChosen),
...rangeToSave(subject, request.resumeRecordId ? undefined : request.window, request.rangeChosen && !request.useAnswerWindow),
p_provenance: {
contract: multi ? ROUNDS_VERSION : "chart-choice-v1",
source,
@@ -138,14 +138,15 @@ export function buildLongformBirthPayload(
const year = Number.parseInt(dateMatch[1], 10);
const account = asRecord(row);
const provenance = asRecord(account?.active_birth_provenance);
const comparisonOnly = provenance?.source === "user_choice_from_answers";
const multiClock = provenance?.contract === "chart-choice-v2" &&
(account?.birth_time_status === "accepted" || account?.birth_time_status === "confirmed")
? reportAdoptedClock(account.active_birth_time) : undefined;
const rectified = account && (provenance?.contract !== "chart-choice-v2" || multiClock)
? rectifiedReadingWindow(account, dateMatch[0], multiClock ?? timeMatch[0]) : null;
const readingMinuteConfirmed = rectified
const readingMinuteConfirmed = !comparisonOnly && (rectified
? rectified.source === "rectified_range" && rectified.before_minutes === 0 && rectified.after_minutes === 0
: extras.readingMinuteConfirmed ?? extras.birthTimeAccuracy === "confirmed";
: extras.readingMinuteConfirmed ?? extras.birthTimeAccuracy === "confirmed");
const birthWindow = rectified ?? (!readingMinuteConfirmed && account ? readingBirthWindow(account, timeMatch[0]) : undefined);
const gender = parseProfileGender(account?.gender);
return {
@@ -165,7 +166,7 @@ export function buildLongformBirthPayload(
today: extras.today,
target_year: extras.targetYear,
age: extras.targetYear - year,
birth_time_accuracy: rectified && readingMinuteConfirmed ? "confirmed" : extras.birthTimeAccuracy,
birth_time_accuracy: comparisonOnly ? "provisional" : rectified && readingMinuteConfirmed ? "confirmed" : extras.birthTimeAccuracy,
...(readingMinuteConfirmed ? {} : { reading_minute_confirmed: false }),
...(birthWindow ? { birth_time_window: birthWindow } : {}),
...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}),
@@ -8,6 +8,8 @@ import { ChartQuestion, FinalStep, HomeView, QuestionnaireRange, RangeStep, Roun
import type { ChartChoiceChartAnswers, ChartChoiceEnginePacket, ChartChoiceCompletion, ChartChoiceQuestionnaire, ChartChoiceRecord } from "../src/lib/chart-choice/contract";
import { eventSides } from "../src/lib/chart-choice/view";
register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
type Choice = Extract<ChartChoiceEnginePacket, { status: "choose" }>;
type Complete = Extract<ChartChoiceEnginePacket, { status: "complete" }>;
const fixture = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-rounds-display-20261113.json", import.meta.url), "utf8")) as {
@@ -23,6 +25,8 @@ const questionnaire = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-q
};
const adaptiveAdoption = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-adaptive-adoption-20261114.json", import.meta.url), "utf8")) as
Record<"inside" | "outside", { packet: ChartChoiceQuestionnaire; answers: ChartChoiceChartAnswers; result: Complete }>;
const adaptiveMidpoint = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-adaptive-midpoint-20261114.json", import.meta.url), "utf8")) as
Record<"inside" | "outside", { packet: ChartChoiceQuestionnaire; result: Complete }>;
test("real chart choice packets render minor lines, three rounds and the final evidence", () => {
const packet = fixture.packets[0];
@@ -59,11 +63,12 @@ test("the current record shows the adopted time and keeps unresolved charts visi
unchanged: [], decision: { ...decision, chosen_side: "none", votes: null, both_versions_domains: [] }, charts: [] },
};
const history = { current_record_id: record.id, records: [record] };
const html = renderToStaticMarkup(<HomeView history={history} onStartOver={noop} />);
const html = renderToStaticMarkup(<HomeView history={history} onStartOver={noop}
reportedAt={reported_at} pending={false} onRestoreDefault={noop} />);
assert.ok(html.includes(`<h2 class="chart-choice-heading chart-choice-current-time">${record.saved_time}</h2>`));
assert.match(html, /范围 /);
assert.equal((html.match(/还没定/g) ?? []).length, decision.undetermined_charts.length);
assert.doesNotMatch(html, /继续|历史|件事|你填的是/);
assert.doesNotMatch(html, /继续|历史|件事|你填的是|把握|倾向|改回/);
assert.match(html, /看星盘/);
assert.match(html, /重新纠正/);
});
@@ -90,7 +95,8 @@ test("questionnaire displays age years and hides rejected constraints from the r
assert.match(renderToStaticMarkup(<ChartQuestion {...props} packet={d1} question={d1.chart_questions[0]} answer={{}} />), /第一印象/);
const final = renderToStaticMarkup(<FinalStep packet={packet} decision={result.decision} pending={false} onBack={noop} onAccept={noop} />);
assert.doesNotMatch(final, /日常补充题指向不同时段|这个方法有多准|再补几件事|件有日期的事/);
assert.doesNotMatch(final, /出生时间在|已验证|准确/);
assert.doesNotMatch(final, /出生时间在|已验证|准确率/);
assert.match(final, /现有信息不足以确认更准确的出生时间/);
assert.match(final, /按你填的 05:00/);
assert.match(final, />用 05:00<\/button>/);
assert.doesNotMatch(final, /chart-choice-card/);
@@ -104,21 +110,57 @@ test("questionnaire displays age years and hides rejected constraints from the r
assert.doesNotMatch(adaptiveQuestion, /第 1 题/);
});
test("adaptive results offer a secondary action for an answer window on either side of the reported time", () => {
for (const [kind, { packet, result }] of Object.entries(adaptiveAdoption)) {
const final = renderToStaticMarkup(<FinalStep packet={packet} decision={result.decision}
pending={false} onBack={noop} onAccept={noop} onUseAnswerWindow={noop} />);
assert.match(final, kind === "inside" ? /你的回答和填的时间对得上/ : /你的经历更像/);
assert.match(final, kind === "inside" ? />收窄到 .*<\/button>/ : />改用 .*<\/button>/);
assert.match(final, kind === "inside" ? />用 04:55<\/button>/ : />用 05:00<\/button>/);
assert.doesNotMatch(final, /出生时间在|已验证|准确/);
const reported = result.decision.reported_choice!;
assert.equal((final.match(/换)/g) ?? []).length, reported.undetermined_charts.length);
test("adaptive results offer only the reference time, with no answer segment (BUG-1886 FIX-7)", () => {
for (const [cases, knowsTime] of [[adaptiveAdoption, true], [adaptiveMidpoint, false]] as const) {
for (const [kind, { packet, result }] of Object.entries(cases)) {
const reported = result.decision.reported_choice!;
assert.equal(reported.reported_known, knowsTime);
assert.ok(result.decision.events_detail?.some((event) => event.matched));
const final = renderToStaticMarkup(<FinalStep packet={packet} decision={result.decision}
pending={false} onBack={noop} onAccept={noop} />);
const clock = kind === "inside" ? "04:55" : "05:00";
const band = final.match(/class="chart-choice-band-segments"[^>]*>(.*?)<\/div>/)?.[1];
const actions = final.match(/class="chart-choice-full-action chart-choice-result-actions">(.*?)<\/div>/)?.[1];
assert.ok(band);
assert.ok(actions);
assert.ok(final.includes(`>用 ${clock}</button>`));
assert.ok(final.includes(`${knowsTime ? "按你填的" : "按范围中点"} ${clock}`));
assert.doesNotMatch(final, /出生时间在|已验证|把握|倾向|对得上|吻合|更像|改用/);
assert.match(final, /范围 [^<]+<\/p><p[^>]*>现有信息不足以确认更准确的出生时间。/);
assert.match(final, /你提到的事/);
assert.equal((final.match(/换)/g) ?? []).length, reported.undetermined_charts.length);
assert.doesNotMatch(final, /你的回答|收窄|比较|改用/);
assert.equal((actions.match(/<button\b/g) ?? []).length, 1);
assert.doesNotMatch(band, /is-highlighted/);
assert.equal((band.match(/<span\b/g) ?? []).length, 1);
assert.match(final, new RegExp(`▲ ${clock}`));
}
}
});
test("a comparison record offers the correct restore action without a confidence label", () => {
for (const [reportedAt, copy, label] of [
["1997-08-08T05:00", "你填的 05:00 没有改", "改回填的时间"],
[null, "你填的范围没有改", "改回原范围"],
] as const) {
const { decision, window, reported_at } = adaptiveAdoption.inside.result;
const current: ChartChoiceRecord = {
id: "comparison", created_at: "2026-11-13T03:00:00Z", saved_date: decision.saved_at.slice(0, 10),
saved_time: decision.saved_at.slice(11), chosen_side: "none", rule: decision.rule, reusable: true,
detail: { window, reported_at, line: null, other_lines: decision.remaining_key_lines, questions: [],
answers: {}, unchanged: [], charts: [], provenance: { source: "user_choice_from_answers" },
decision: { ...decision, chosen_side: "none", votes: null, both_versions_domains: [] } },
};
const html = renderToStaticMarkup(<HomeView history={{ current_record_id: current.id, records: [current] }}
reportedAt={reportedAt} pending={false} onStartOver={noop} onRestoreDefault={noop} />);
assert.ok(html.includes(copy));
assert.ok(html.includes(`>${label}</button>`));
assert.match(html, /你选的比较范围/);
assert.doesNotMatch(html, /当前使用|把握|倾向|对得上|更像|改用/);
}
});
test("the chart choice API service passes through the three display fields and saves final details", async () => {
register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { adoptChartChoice, buildChartChoicePacket, previewAdaptiveChartChoice, previewChartChoice } = await import("../src/lib/chart-choice/server");
const profile = {
name: "虚构甲", birth_date: "1997-08-08", reported_birth_time: "05:00", birth_time_source: "approximate",
@@ -144,11 +186,13 @@ test("the chart choice API service passes through the three display fields and s
let saved: Partial<ChartChoiceCompletion> | undefined;
let provenance: Record<string, unknown> | undefined;
let savedClock: unknown;
let declaredStart: unknown;
const admin = { rpc: async (name: string, args: Record<string, unknown>) => {
assert.equal(name, "chart_choice_adopt_v4");
saved = (args.p_detail as { decision: ChartChoiceCompletion }).decision;
provenance = args.p_provenance as Record<string, unknown>;
savedClock = args.p_time;
declaredStart = args.p_window_start;
return { data: {}, error: null };
} };
await adoptChartChoice(caller, admin, "fixture", request, fetchEngine);
@@ -201,4 +245,59 @@ test("the chart choice API service passes through the three display fields and s
assert.equal(provenance?.source, "user_choice_from_answers");
assert.deepEqual(provenance?.segment, suggested.segment);
assert.equal(saved?.evidence_summary?.confidence, suggested.evidence_summary?.confidence);
const noRangeProfile = { ...profile, declared_window_start: null, declared_window_end: null,
uncertainty_before_minutes: null, uncertainty_after_minutes: null, birth_time_uncertainty_known: false };
const noRangeCaller = { from: () => ({ select: () => ({ eq: () => ({
maybeSingle: async () => ({ data: noRangeProfile, error: null }),
}) }) }) };
const quickRange = { ...stopped, window: adaptiveAdoption.outside.packet.window, rangeChosen: true };
await adoptChartChoice(noRangeCaller, admin, "fixture", quickRange, adaptiveEngine);
assert.equal(declaredStart, "04:30");
await adoptChartChoice(noRangeCaller, admin, "fixture", { ...quickRange, useAnswerWindow: true }, adaptiveEngine);
assert.equal(declaredStart, null);
assert.equal(provenance?.source, "user_choice_from_answers");
});
test("restoring the current comparison saves the default with no questionnaire replay", async () => {
const { restoreChartChoiceDefault } = await import("../src/lib/chart-choice/server");
for (const scenario of [
{ input: adaptiveAdoption.inside, known: true, source: "reported_time_default" },
{ input: adaptiveMidpoint.inside, known: false, source: "window_midpoint_default" },
]) {
const { packet, result } = scenario.input;
const profile = {
name: "虚构甲", birth_date: packet.reported_at.slice(0, 10),
reported_birth_time: scenario.known ? packet.reported_at.slice(11) : null,
birth_time_source: scenario.known ? "approximate" : "period_only",
birth_time_status: "reported", birth_time_precision_kind: scenario.known ? "minutes" : "period",
declared_window_start: packet.window.start_at.slice(11), declared_window_end: packet.window.end_at.slice(11),
uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, birth_time_uncertainty_known: true,
latitude: 36.42, longitude: 114.21, timezone_offset: 8, timezone_id: "Asia/Shanghai", birth_place_label: "虚构市",
};
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: profile, error: null }) }) }) }) };
const current = { id: "comparison", reusable: true,
detail: { window: packet.window, provenance: { source: "user_choice_from_answers" } } };
const history = { current_record_id: current.id, records: [current] };
let writes = 0;
const admin = { rpc: async (name: string, args: Record<string, unknown>) => {
if (name === "chart_choice_records_v1") return { data: history, error: null };
assert.equal(name, "chart_choice_adopt_v4");
assert.equal((args.p_provenance as Record<string, unknown>).source, scenario.source);
assert.equal(args.p_window_start, null);
writes++;
return { data: {}, error: null };
} };
const fetchEngine: typeof fetch = async (_url, options) => {
const body = JSON.parse(String(options?.body));
assert.equal(body.stop, true);
assert.equal(body.adaptive, true);
assert.equal(body.chart_answers, undefined);
return Response.json(result);
};
await assert.rejects(restoreChartChoiceDefault(caller, admin, "fixture", { recordId: "older" }, fetchEngine), /record_stale/);
const restored = await restoreChartChoiceDefault(caller, admin, "fixture", { recordId: current.id }, fetchEngine);
assert.equal(restored.saved_at, result.decision.reported_choice?.saved_at);
assert.equal(writes, 1);
}
});
@@ -0,0 +1,874 @@
{
"inside": {
"packet": {
"engine_version": "chart-choice-v2",
"window": {
"start_at": "1997-08-08T04:40",
"end_at": "1997-08-08T05:10"
},
"reported_at": "1997-08-08T04:55",
"reported_known": false,
"ayanamsa": "raman",
"node_mode": "mean",
"status": "questionnaire",
"segment": {
"start_at": "1997-08-08T04:40:00",
"end_at": "1997-08-08T05:10:00"
},
"remaining_key_lines": 5,
"ordered_lines": [
{
"at": "1997-08-08T04:51:30",
"degree": 13.333333,
"charts": [
"D9"
]
},
{
"at": "1997-08-08T05:07:55",
"degree": 16.666667,
"charts": [
"D9"
]
},
{
"at": "1997-08-08T04:59:43",
"degree": 15.0,
"charts": [
"D10",
"D2",
"D6",
"D8",
"D4",
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:44:57",
"degree": 12.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T04:41:16",
"degree": 11.25,
"charts": [
"D8",
"D24"
]
}
],
"minor_lines": [
{
"at": "1997-08-08T04:47:24",
"charts": [
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:53:33",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:05:52",
"charts": [
"D24"
]
}
],
"question_charts": [
{
"chart": "D9",
"possibilities": 3
},
{
"chart": "D10",
"possibilities": 3
},
{
"chart": "D2",
"possibilities": 2
},
{
"chart": "D6",
"possibilities": 2
},
{
"chart": "D8",
"possibilities": 3
}
],
"chart_questions": [
{
"id": "D24-education-1",
"chart": "D24",
"kind": "event",
"domain": "education",
"prompt": "你在哪个年龄段有过升学、毕业或换专业的经历?",
"options": [
{
"value": "age-20-20",
"text": "20 岁",
"ages": [
20,
20
],
"possibilities": [
0
]
},
{
"value": "age-28-28",
"text": "28 岁",
"ages": [
28,
28
],
"possibilities": [
1
]
},
{
"value": "age-29-29",
"text": "29 岁",
"ages": [
29,
29
],
"possibilities": [
3
]
},
{
"value": "none",
"text": "都没有"
},
{
"value": "unsure",
"text": "记不清"
}
],
"categories": [
{
"id": "education",
"value": "education",
"text": "升学 / 考试 / 毕业"
}
],
"possibilities": [
{
"start_at": "1997-08-08T04:40:00",
"end_at": "1997-08-08T04:41:15",
"sign": "双鱼"
},
{
"start_at": "1997-08-08T04:41:16",
"end_at": "1997-08-08T04:47:23",
"sign": "白羊"
},
{
"start_at": "1997-08-08T04:47:24",
"end_at": "1997-08-08T04:53:32",
"sign": "金牛"
},
{
"start_at": "1997-08-08T04:53:33",
"end_at": "1997-08-08T04:59:42",
"sign": "双子"
},
{
"start_at": "1997-08-08T04:59:43",
"end_at": "1997-08-08T05:05:51",
"sign": "巨蟹"
},
{
"start_at": "1997-08-08T05:05:52",
"end_at": "1997-08-08T05:10:00",
"sign": "狮子"
}
]
}
],
"confirmed_charts": 1
},
"result": {
"engine_version": "chart-choice-v2",
"window": {
"start_at": "1997-08-08T04:40",
"end_at": "1997-08-08T05:10"
},
"reported_at": "1997-08-08T04:55",
"reported_known": false,
"ayanamsa": "raman",
"node_mode": "mean",
"status": "complete",
"segment": {
"start_at": "1997-08-08T04:53:33",
"end_at": "1997-08-08T04:59:42"
},
"remaining_key_lines": 0,
"ordered_lines": [],
"minor_lines": [
{
"at": "1997-08-08T04:47:24",
"charts": [
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:53:33",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:05:52",
"charts": [
"D24"
]
}
],
"question_charts": [],
"chart_questions": [],
"confirmed_charts": 6,
"saved_at": "1997-08-08T04:55",
"decision": {
"saved_at": "1997-08-08T04:55",
"segment": {
"start_at": "1997-08-08T04:53:33",
"end_at": "1997-08-08T04:59:42"
},
"rounds": [],
"undetermined_charts": [],
"events": [
{
"date": "2026",
"category": "education"
}
],
"key_charts": [
{
"chart": "D1",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D9",
"sign": "天蝎",
"decided_by": null
},
{
"chart": "D10",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D2",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D6",
"sign": "射手",
"decided_by": null
},
{
"chart": "D8",
"sign": "巨蟹",
"decided_by": null
}
],
"events_detail": [
{
"date": "2026",
"category": "education",
"matched": true
}
],
"evidence_summary": {
"event_count": 1,
"matched": 1,
"confidence": "tendency_only",
"coverage": {
"event_count": 1,
"category_count": 1,
"group_count": 1,
"groups": [
"education"
],
"year_span": 0,
"level": "thin"
},
"caveat": "这一步按占星师的方法计算,结果只作参考,不是验证结果。2026-11-06 我们在 77 个出生时间有可靠记录的案例上回测过同类的事件打分:30 分钟范围里头名命中 19%,随机挑也是 20%,直接用报的时间是 34%。"
},
"remaining_key_lines": 0,
"has_next": false,
"rule": "stopped",
"reported_choice": {
"saved_at": "1997-08-08T04:55",
"segment": {
"start_at": "1997-08-08T04:40",
"end_at": "1997-08-08T05:10"
},
"reported_known": false,
"key_charts": [
{
"chart": "D1",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D9",
"sign": "天蝎",
"decided_by": null,
"flips_at": "1997-08-08T04:51:30"
},
{
"chart": "D10",
"sign": "巨蟹",
"decided_by": null,
"flips_at": "1997-08-08T04:44:57"
},
{
"chart": "D2",
"sign": "巨蟹",
"decided_by": null,
"flips_at": "1997-08-08T04:59:43"
},
{
"chart": "D6",
"sign": "射手",
"decided_by": null,
"flips_at": "1997-08-08T04:59:43"
},
{
"chart": "D8",
"sign": "巨蟹",
"decided_by": null,
"flips_at": "1997-08-08T04:41:16"
}
],
"undetermined_charts": [
"D9",
"D10",
"D2",
"D6",
"D8"
],
"remaining_key_lines": 5
},
"asked": [
{
"id": "D24-education-1",
"chart": "D24",
"answer": {
"choice": "age-29-29",
"date": "2026",
"category": "education"
},
"before": [
{
"start_at": "1997-08-08T04:40:00",
"end_at": "1997-08-08T05:10:00"
}
],
"after": [
{
"start_at": "1997-08-08T04:53:33",
"end_at": "1997-08-08T04:59:42"
}
]
}
],
"conflict": [],
"resolved": [],
"stop_reason": "stopped"
}
}
},
"outside": {
"packet": {
"engine_version": "chart-choice-v2",
"window": {
"start_at": "1997-08-08T04:30",
"end_at": "1997-08-08T05:30"
},
"reported_at": "1997-08-08T05:00",
"reported_known": false,
"ayanamsa": "raman",
"node_mode": "mean",
"status": "questionnaire",
"segment": {
"start_at": "1997-08-08T04:30:00",
"end_at": "1997-08-08T05:30:00"
},
"remaining_key_lines": 11,
"ordered_lines": [
{
"at": "1997-08-08T05:07:55",
"degree": 16.666667,
"charts": [
"D9"
]
},
{
"at": "1997-08-08T04:51:30",
"degree": 13.333333,
"charts": [
"D9"
]
},
{
"at": "1997-08-08T05:24:22",
"degree": 20.0,
"charts": [
"D9",
"D6",
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:35:08",
"degree": 10.0,
"charts": [
"D9",
"D6",
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:59:43",
"degree": 15.0,
"charts": [
"D10",
"D2",
"D6",
"D8",
"D4",
"D12",
"D24"
]
},
{
"at": "1997-08-08T05:14:30",
"degree": 18.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T04:44:57",
"degree": 12.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T05:29:18",
"degree": 21.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T04:30:14",
"degree": 9.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T05:18:12",
"degree": 18.75,
"charts": [
"D8",
"D24"
]
},
{
"at": "1997-08-08T04:41:16",
"degree": 11.25,
"charts": [
"D8",
"D24"
]
}
],
"minor_lines": [
{
"at": "1997-08-08T04:47:24",
"charts": [
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:53:33",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:05:52",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:12:02",
"charts": [
"D12",
"D24"
]
}
],
"question_charts": [
{
"chart": "D9",
"possibilities": 5
},
{
"chart": "D10",
"possibilities": 6
},
{
"chart": "D2",
"possibilities": 2
},
{
"chart": "D6",
"possibilities": 4
},
{
"chart": "D8",
"possibilities": 4
}
],
"chart_questions": [
{
"id": "D9-divorce-1",
"chart": "D9",
"kind": "event",
"domain": "relationship",
"prompt": "你在哪个年龄段有过分开的经历?",
"options": [
{
"value": "age-20-20",
"text": "20 岁",
"ages": [
20,
20
],
"possibilities": [
4
]
},
{
"value": "none",
"text": "都没有"
},
{
"value": "unsure",
"text": "记不清"
}
],
"categories": [
{
"id": "divorce",
"value": "divorce",
"text": "离婚 / 分开"
}
],
"possibilities": [
{
"start_at": "1997-08-08T04:30:00",
"end_at": "1997-08-08T04:35:07",
"sign": "处女"
},
{
"start_at": "1997-08-08T04:35:08",
"end_at": "1997-08-08T04:51:29",
"sign": "天秤"
},
{
"start_at": "1997-08-08T04:51:30",
"end_at": "1997-08-08T05:07:54",
"sign": "天蝎"
},
{
"start_at": "1997-08-08T05:07:55",
"end_at": "1997-08-08T05:24:21",
"sign": "射手"
},
{
"start_at": "1997-08-08T05:24:22",
"end_at": "1997-08-08T05:30:00",
"sign": "摩羯"
}
]
}
],
"confirmed_charts": 1
},
"result": {
"engine_version": "chart-choice-v2",
"window": {
"start_at": "1997-08-08T04:30",
"end_at": "1997-08-08T05:30"
},
"reported_at": "1997-08-08T05:00",
"reported_known": false,
"ayanamsa": "raman",
"node_mode": "mean",
"status": "complete",
"segment": {
"start_at": "1997-08-08T05:24:22",
"end_at": "1997-08-08T05:30:00"
},
"remaining_key_lines": 5,
"ordered_lines": [
{
"at": "1997-08-08T04:59:43",
"degree": 15.0,
"charts": [
"D10",
"D2",
"D6",
"D8",
"D4",
"D12",
"D24"
]
},
{
"at": "1997-08-08T05:14:30",
"degree": 18.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T04:44:57",
"degree": 12.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T05:29:18",
"degree": 21.0,
"charts": [
"D10"
]
},
{
"at": "1997-08-08T04:30:14",
"degree": 9.0,
"charts": [
"D10"
]
}
],
"minor_lines": [
{
"at": "1997-08-08T04:47:24",
"charts": [
"D12",
"D24"
]
},
{
"at": "1997-08-08T04:53:33",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:05:52",
"charts": [
"D24"
]
},
{
"at": "1997-08-08T05:12:02",
"charts": [
"D12",
"D24"
]
}
],
"question_charts": [
{
"chart": "D10",
"possibilities": 2
}
],
"chart_questions": [],
"confirmed_charts": 5,
"saved_at": "1997-08-08T05:26",
"decision": {
"saved_at": "1997-08-08T05:26",
"segment": {
"start_at": "1997-08-08T05:24:22",
"end_at": "1997-08-08T05:30:00"
},
"rounds": [],
"undetermined_charts": [
"D10"
],
"events": [
{
"date": "2017",
"category": "divorce"
}
],
"key_charts": [
{
"chart": "D1",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D9",
"sign": "摩羯",
"decided_by": "event"
},
{
"chart": "D10",
"sign": "处女",
"decided_by": null,
"flips_at": "1997-08-08T05:29:18"
},
{
"chart": "D2",
"sign": "狮子",
"decided_by": null
},
{
"chart": "D6",
"sign": "水瓶",
"decided_by": null
},
{
"chart": "D8",
"sign": "处女",
"decided_by": null
}
],
"events_detail": [
{
"date": "2017",
"category": "divorce",
"matched": true
}
],
"evidence_summary": {
"event_count": 1,
"matched": 1,
"confidence": "tendency_only",
"coverage": {
"event_count": 1,
"category_count": 1,
"group_count": 1,
"groups": [
"relationship"
],
"year_span": 0,
"level": "thin"
},
"caveat": "这一步按占星师的方法计算,结果只作参考,不是验证结果。2026-11-06 我们在 77 个出生时间有可靠记录的案例上回测过同类的事件打分:30 分钟范围里头名命中 19%,随机挑也是 20%,直接用报的时间是 34%。"
},
"remaining_key_lines": 5,
"has_next": false,
"rule": "stopped",
"reported_choice": {
"saved_at": "1997-08-08T05:00",
"segment": {
"start_at": "1997-08-08T04:30",
"end_at": "1997-08-08T05:30"
},
"reported_known": false,
"key_charts": [
{
"chart": "D1",
"sign": "巨蟹",
"decided_by": null
},
{
"chart": "D9",
"sign": "天蝎",
"decided_by": null,
"flips_at": "1997-08-08T04:35:08"
},
{
"chart": "D10",
"sign": "狮子",
"decided_by": null,
"flips_at": "1997-08-08T04:30:14"
},
{
"chart": "D2",
"sign": "狮子",
"decided_by": null,
"flips_at": "1997-08-08T04:59:43"
},
{
"chart": "D6",
"sign": "摩羯",
"decided_by": null,
"flips_at": "1997-08-08T04:35:08"
},
{
"chart": "D8",
"sign": "狮子",
"decided_by": null,
"flips_at": "1997-08-08T04:41:16"
}
],
"undetermined_charts": [
"D9",
"D10",
"D2",
"D6",
"D8"
],
"remaining_key_lines": 11
},
"asked": [
{
"id": "D9-divorce-1",
"chart": "D9",
"answer": {
"choice": "age-20-20",
"date": "2017",
"category": "divorce"
},
"before": [
{
"start_at": "1997-08-08T04:30:00",
"end_at": "1997-08-08T05:30:00"
}
],
"after": [
{
"start_at": "1997-08-08T05:24:22",
"end_at": "1997-08-08T05:30:00"
}
]
}
],
"conflict": [],
"resolved": [],
"stop_reason": "stopped"
}
}
}
}
@@ -158,6 +158,20 @@ test("v2 reports reject a segment that excludes the exact saved second", () => {
});
});
test("a questionnaire comparison stays provisional even when saved to a single second", () => {
const start = "2000-01-01T05:00:00";
for (const end of [start, "2000-01-01T05:00:01", "2000-01-01T05:17:00"]) {
const payload = request({
...adopted, birth_time_status: "confirmed", active_birth_provenance: {
contract: "chart-choice-v2", source: "user_choice_from_answers", segment: { start_at: start, end_at: end },
},
}, true);
assert.equal(payload.reading_minute_confirmed, false);
assert.equal(payload.birth_time_accuracy, "provisional");
assert.equal(payload.minute, 0);
}
});
test("reported and legacy profiles use intake bounds without treating candidate bounds as adopted", () => {
const intake = { uncertainty_before_minutes: 10, uncertainty_after_minutes: 20 };
for (const row of [