自适应生时纠正及公开案例回放(#260) #265
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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(待独立验收)。
|
||||
|
||||
@@ -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 宽度,包含 / 不包含两种结果页。
|
||||
@@ -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 宽):结果页包含 / 不包含两种;首页默认态;首页「你选的比较范围」态。
|
||||
@@ -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 错误。
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user