diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc36750..d2fa2c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cb136a9c..7dd19b29 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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(待独立验收)。 diff --git a/docs/tasks/FIX-rect-adaptive-5.md b/docs/tasks/FIX-rect-adaptive-5.md new file mode 100644 index 00000000..86135df6 --- /dev/null +++ b/docs/tasks/FIX-rect-adaptive-5.md @@ -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 宽度,包含 / 不包含两种结果页。 diff --git a/docs/tasks/FIX-rect-adaptive-6.md b/docs/tasks/FIX-rect-adaptive-6.md new file mode 100644 index 00000000..6ef253a1 --- /dev/null +++ b/docs/tasks/FIX-rect-adaptive-6.md @@ -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 宽):结果页包含 / 不包含两种;首页默认态;首页「你选的比较范围」态。 diff --git a/docs/tasks/FIX-rect-adaptive-7.md b/docs/tasks/FIX-rect-adaptive-7.md new file mode 100644 index 00000000..b16e4546 --- /dev/null +++ b/docs/tasks/FIX-rect-adaptive-7.md @@ -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 宽):结果页一张、首页默认态一张。 diff --git a/docs/tasks/PROGRESS-rect-adaptive-20261114.md b/docs/tasks/PROGRESS-rect-adaptive-20261114.md index 9125d07e..64cc4924 100644 --- a/docs/tasks/PROGRESS-rect-adaptive-20261114.md +++ b/docs/tasks/PROGRESS-rect-adaptive-20261114.md @@ -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 错误。 diff --git a/docs/tasks/rect-adaptive-replay.md b/docs/tasks/rect-adaptive-replay.md index 66d60b8d..996d1eef 100644 --- a/docs/tasks/rect-adaptive-replay.md +++ b/docs/tasks/rect-adaptive-replay.md @@ -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)。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 8c0053bc..55886673 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 0f5442b3..cfc29939 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -608,3 +608,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。 +## 生时纠正问卷(#260) + +- 结果页在范围后写「现有信息不足以确认更准确的出生时间。」不提供收窄或比较按钮(FIX-7)。事件列表只写日期和类别。 +- 首页比较态(只有 FIX-7 之前选过的旧记录)写「你选的比较范围」和「你填的 HH:MM 没有改」,按钮「改回填的时间」。只填过范围时写「你填的范围没有改」,按钮「改回原范围」。 + diff --git a/frontend/src/app/api/chart-choice/adopt/route.ts b/frontend/src/app/api/chart-choice/adopt/route.ts index 44044c61..e2076e27 100644 --- a/frontend/src/app/api/chart-choice/adopt/route.ts +++ b/frontend/src/app/api/chart-choice/adopt/route.ts @@ -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)); diff --git a/frontend/src/components/chart-choice/chart-choice-page.tsx b/frontend/src/components/chart-choice/chart-choice-page.tsx index 3bdfaaa5..412ff02a 100644 --- a/frontend/src/components/chart-choice/chart-choice-page.tsx +++ b/frontend/src/components/chart-choice/chart-choice-page.tsx @@ -294,12 +294,11 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { } } - async function saveFinal(final: Extract, useAnswerWindow?: true) { + async function saveFinal(final: Extract) { 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, submitted: ChartChoiceChartAnswers, @@ -356,7 +367,8 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { ); case "home": - return ; + return void restoreDefault(id)} />; case "narrow": return ( @@ -447,8 +459,7 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { ); case "final": return 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> = { - 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
{!pending ? 改答案 : null}

{hasReportedTime ? `按你填的 ${clock}` : `按范围中点 ${clock}`}

范围 {segmentLabel(defaultWindow, reference)}

-

{advisory}

- +

现有信息不足以确认更准确的出生时间。

+
{reported ?

按 {clock} 看六张盘

: null} - {evidence ?

{evidence}

: null} + {events.length ?
+

你提到的事

+ +
: null}
用 {clock} - {!sameWindow && onUseAnswerWindow ? - {containsReference ? "收窄到" : "改用"} {range} - : null}
; } + const evidence = matchedEvidence(decision); return <> {!pending ? 改答案 : 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 (
{current ? (
-

当前使用

+

{comparisonOnly ? "你选的比较范围" : "当前使用"}

{savedAtLabel(current.created_at)} 保存

{clockLabel( @@ -1084,7 +1084,7 @@ export function HomeView({ history, onStartOver }: { )}

{decision?.segment ? <>

范围 {segmentLabel(decision.segment)}

- {confidence ?

{confidence}

: null} + {comparisonOnly ?

{reportedAt ? `你填的 ${clockLabel(reportedAt)} 没有改` : "你填的范围没有改"}

: null} @@ -1105,8 +1105,13 @@ export function HomeView({ history, onStartOver }: { }
看星盘 - 重新纠正 + 重新纠正
+ {comparisonOnly && current.reusable ?
+ onRestoreDefault(current.id)}> + {reportedAt ? "改回填的时间" : "改回原范围"} + +
: null}
) : (
开始纠正
diff --git a/frontend/src/lib/chart-choice/contract.ts b/frontend/src/lib/chart-choice/contract.ts index a85d6d26..fdac9113 100644 --- a/frontend/src/lib/chart-choice/contract.ts +++ b/frontend/src/lib/chart-choice/contract.ts @@ -251,7 +251,7 @@ export type ChartChoiceRecordDetail = { readonly saved_at: string; } & Partial & 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 }[]; }; diff --git a/frontend/src/lib/chart-choice/server.ts b/frontend/src/lib/chart-choice/server.ts index 9a44a966..6aa8fdf6 100644 --- a/frontend/src/lib/chart-choice/server.ts +++ b/frontend/src/lib/chart-choice/server.ts @@ -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(path: string, body: Record, fetchImpl: typeof fetch = fetch): Promise { +async function engine(path: string, body: Record, fetchImpl: typeof fetch = fetch, timeoutMs = ENGINE_TIMEOUT_MS): Promise { 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("/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, diff --git a/frontend/src/lib/personal-report-longform-birth.ts b/frontend/src/lib/personal-report-longform-birth.ts index 19ea44ae..dc297867 100644 --- a/frontend/src/lib/personal-report-longform-birth.ts +++ b/frontend/src/lib/personal-report-longform-birth.ts @@ -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 } : {}), diff --git a/frontend/tests/chart-choice-rounds-page-20261113.test.tsx b/frontend/tests/chart-choice-rounds-page-20261113.test.tsx index d8b37254..0307cd56 100644 --- a/frontend/tests/chart-choice-rounds-page-20261113.test.tsx +++ b/frontend/tests/chart-choice-rounds-page-20261113.test.tsx @@ -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; type Complete = Extract; 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(); + const html = renderToStaticMarkup(); assert.ok(html.includes(`

${record.saved_time}

`)); 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(), /第一印象/); const final = renderToStaticMarkup(); 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(); - 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(); + 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}`)); + assert.ok(final.includes(`${knowsTime ? "按你填的" : "按范围中点"} ${clock}`)); + assert.doesNotMatch(final, /出生时间在|已验证|把握|倾向|对得上|吻合|更像|改用/); + assert.match(final, /范围 [^<]+<\/p>]*>现有信息不足以确认更准确的出生时间。/); + assert.match(final, /你提到的事/); + assert.equal((final.match(/换)/g) ?? []).length, reported.undetermined_charts.length); + assert.doesNotMatch(final, /你的回答|收窄|比较|改用/); + assert.equal((actions.match(/ { + 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(); + assert.ok(html.includes(copy)); + assert.ok(html.includes(`>${label}`)); + 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 | undefined; let provenance: Record | undefined; let savedClock: unknown; + let declaredStart: unknown; const admin = { rpc: async (name: string, args: Record) => { assert.equal(name, "chart_choice_adopt_v4"); saved = (args.p_detail as { decision: ChartChoiceCompletion }).decision; provenance = args.p_provenance as Record; 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) => { + 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).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); + } }); diff --git a/frontend/tests/fixtures/chart-choice-adaptive-midpoint-20261114.json b/frontend/tests/fixtures/chart-choice-adaptive-midpoint-20261114.json new file mode 100644 index 00000000..15df2d6e --- /dev/null +++ b/frontend/tests/fixtures/chart-choice-adaptive-midpoint-20261114.json @@ -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" + } + } + } +} diff --git a/frontend/tests/personal-report-birth-window-20261113.test.ts b/frontend/tests/personal-report-birth-window-20261113.test.ts index ddb124a7..cc7ee1c7 100644 --- a/frontend/tests/personal-report-birth-window-20261113.test.ts +++ b/frontend/tests/personal-report-birth-window-20261113.test.ts @@ -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 [