diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7a3c7f..596129dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,36 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 导出弹窗:没有英文时顶部提示一次,选中态用主题色 + +- 英文版不可用时,打开导出弹窗在顶部提示一次,弹窗里不再留那行说明。英文可用时,「建议选 English」仍在语言选项下面。 +- 「整篇报告 / 自选章节」的描边和圆点、勾选框、弹窗里的中英切换,选中时用主题珊瑚色,底色保持中性。深色模式下不再用深红褐色块。 +- Skill 版本不变(BUG-1409)。 + +## 2026-10-21 — 上升的状态格改成「/」 + +- 星盘页本命表、月亮盘和分盘表里,上升的状态格写「/」。悬停、聚焦或点一下会说明:上升点是命盘的起点,不是行星,不判断入旺或落陷。 +- 个人报告「行星主星与状态」表的上升行同样写「/」,表下面加同一句说明。英文版写英文说明。已经生成的旧报告要重新生成才变。 +- Skill 版本不变。 + +## 2026-10-22 — 报告整句表恢复换行,档案页改别人后星盘跟着更新 + +- 报告里只有行星位置这类数值表保持单行,放不下时在表自己的框里横滑。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里。 +- 星盘页和星盘档案保存别人,走同一处。档案页保存成功后,星盘页和星历页不再留着旧盘。 +- 点编辑框内侧的空白边不会关掉框,未保存的修改还在。Esc 仍然关闭。别人已经填过的出生时间,那句「保存后不能再改」只出现在时间旁边。 +- Skill 版本不变(BUG-1410 收窄,BUG-1411、BUG-1412)。 + +## 2026-10-21 — 星盘页能改出生资料,人名看得出能点,报告行星表不再从中间断开 + +- 星盘、星历、报告顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,底部可以「+ 添加一个人」。 +- 星盘页标题旁只留日期、时间和地点,点这一行就能改。本人都能改;别人已经保存的出生时间仍然不能改。改了日期、时间或地点会提示:之前校正采用的时间作废,星盘按新填的时间重排。 +- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。 +- Skill 版本不变(BUG-1410)。 + ## 2026-10-21 — 英文版不再因个别漏译整份消失 - 个人报告的英文版改为在源头写英文,不再因为个别没译出的字把整份英文丢掉。 - 已经生成的报告需要重新生成,才看得到英文版。 -- Skill 版本不变(BUG-1410、BUG-1411)。 +- Skill 版本不变(BUG-1413、BUG-1414)。 ## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 574c778e..4750c694 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37016,11 +37016,75 @@ - 复发自:无。 - 修复版本:分支 `codex/synastry-staging-fix-20261021`。 -## BUG-1410 | 个人报告英文版因漏译汉字整份被丢掉(BUG-1272 复发) +## BUG-1409 | 导出弹窗把「没有英文」写成常驻说明,选中态没有主题色 -- 状态:resolved(本分支回归已过;合入 staging 后按 PROGRESS 做真机复测) +- 状态:resolved(待 staging 真机复测) - 首次发现:2026-10-21 - 最近更新:2026-10-21 +- 影响面:个人报告导出弹窗 +- 用户现象:英文版不可用时,弹窗里多一行灰字占位置。整篇 / 自选、勾选框和弹窗里的中英切换,选中和没选中都偏灰,不好分。 +- 触发条件:打开导出弹窗。英文版状态是没生成成功,或报告生成于英文版上线前。选中「整篇报告」「自选章节」、勾选章节,或在弹窗里切换中文 / English。 +- 根因:2026-09-30 把「为什么没有英文」写成了面板里的常驻说明。BUG-1078 为了躲开深色模式下 `--color-action-soft` 的深红褐色块,把选中态改成了中性灰加黑字。 +- 修复:英文不可用时,打开弹窗在 `showModal()` 之后用 `notifyInfo` 提示一次(固定 id `report-export-english`),弹窗内不再写这两句。选中描边、圆点、勾选框用 `--color-action-strong`,语言分段的选中文字用 `--color-action`,底色保持中性。英文可用时「建议选 English」仍留在语言选项下面。页面顶栏的中英切换没改。 +- 验证:`frontend/tests/report-export-drawer.test.tsx`、`frontend/tests/report-english-edition.test.tsx`、`frontend/tests/report-buttons-scope-20260928.test.ts`。 +- 防复发:上述测试锁定「每次打开只提示一次、弹窗内没有这两句」以及「选中规则不得出现 `--color-action-soft` / `--color-action-fill`」。 +- 相关记录:BUG-1078、BUG-1079、BUG-1121。issue #90。 +- 复发自:无。本条推翻 DESIGN.md「Selected states use `--color-canvas-muted` + `--color-ink`」这一句;BUG-1078 的主语(导出面板选中态不得用 `--color-action-soft` 深色块)保留。 +- 修复版本:分支 `codex/report-export-dialog-polish-20261021`。 + +## BUG-1410 | 报告行星表把星宿和速度从中间拆开 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人,issue #86) +- 最近更新:2026-10-22 +- 影响面:报告阅读页 Markdown 表格,尤其是「Birth Chart 行星原始位置」 +- 用户现象:九列被平均分宽。星宿「阿湿毗尼(Ashwini)」拆成三行,速度 `11.852534` 从数字中间断开。 +- 触发条件:打开一份含行星原始位置表的报告。阅读页给 `.personal-report-table-wrap table` 用了 `table-layout: fixed`,单元格 `overflow-wrap: anywhere`。打印样式再用同一套规则盖回去。 +- 根因:固定列宽加上任意断行。速度在阅读版导出里写成 6 位小数,更长,更容易被拆开。 +- 修复:表格按内容定列宽,单词和数字不从中间断开,放不下时只在表格容器里横向滑动。星宿英文名用浅色小字,和中文同一行。速度显示并写出 4 位小数。打印不再改回固定列宽和任意断行。2026-10-22 收窄:单行和按内容撑宽只给表头含速度、Speed、度数或黄经的数值表(`.personal-report-table-numeric`)。作用说明、建议这类整句表恢复换行。打印时所有表都是页宽,单元格可以换行。第一次修复把单行套到每一张 Markdown 表上,打印时宽表跑出纸面。 +- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 锁定数值表带这个类、说明表不带,并且单行规则只挂在这个类上,打印块里没有按内容撑宽和禁止换行。`tests/test_report_chart_blank_columns.py` 的行星行速度匹配 4 位小数。测试文件曾叫 `issue-86-chart-subject-table.test.tsx`,2026-10-22 改成按内容命名。 +- 防复发:上面两条。数值表不要改回 `table-layout: fixed`。不要把单行和按内容撑宽再套到每一张报告表上,打印块里也不要。整句表可以换行。 +- 相关记录:issue #86。 +- 复发自:无。 +- 修复版本:分支 `codex/chart-subject-table-20261021`。 + +## BUG-1411 | 档案页改了别人的出生资料,星盘和星历仍是旧盘 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-22(issue #86 第 1 次验收) +- 最近更新:2026-10-22 +- 影响面:星盘档案页保存他人;星盘页与星历页缓存 +- 用户现象:在档案页改完别人的出生日期、时间或地点,再打开星盘页或星历页,仍是改之前的盘。 +- 触发条件:档案页编辑一个已有的他人并保存成功。星盘页就地编辑同一人会清缓存,档案页这条保存没有清。 +- 根因:保存他人写成了两份。星盘编辑框在成功后清掉星盘和星历缓存。档案页的 `saveOther` 只刷新人物名单。 +- 修复:保存他人抽成 `saveOtherProfile`。请求、409 `reported_birth_time_is_immutable` 的文案、成功后清星盘和星历缓存都在这一处。星盘编辑框和档案页都调用它。档案页因此补上缓存清理。这是顺带修好的旧缺陷。 +- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 断言两处调用同一个函数;成功后两处缓存清理被调用,409 时不调用。 +- 防复发:该测试。不要在页面里再写一份保存他人的请求。 +- 相关记录:issue #86。BUG-1410。 +- 复发自:无。 +- 修复版本:分支 `codex/chart-subject-table-20261021`。 + +## BUG-1412 | 点星盘编辑框的内边距会关掉框并丢掉没保存的修改 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-22(issue #86 第 1 次验收) +- 最近更新:2026-10-22 +- 影响面:星盘页就地编辑出生资料的对话框 +- 用户现象:点在编辑框内侧的空白边上,或在输入框里拖选文字后松手落在这块空白上,编辑框关掉,刚填的内容没了。 +- 触发条件:编辑框有内边距。点在内边距上时,点击目标是对话框自己。旧逻辑把这当成点在遮罩上,直接关闭。 +- 根因:关闭条件只看点击目标是不是对话框,没有看点击是否落在框的矩形外面。遮罩点击的目标也是对话框,但坐标在矩形外;内边距的坐标在矩形内。 +- 修复:只有目标是对话框且坐标在矩形外才关闭。Esc 仍关闭。 +- 验证:`frontend/tests/chart-birth-editor-and-report-table.test.tsx` 用框内坐标断言不关闭、框外坐标断言关闭。 +- 防复发:该测试。不要再只按点击目标是对话框来关闭这个编辑框。 +- 相关记录:issue #86。 +- 复发自:无。 +- 修复版本:分支 `codex/chart-subject-table-20261021`。 + +## BUG-1413 | 个人报告英文版因漏译汉字整份被丢掉(BUG-1272 复发) + +- 状态:resolved(第 1 次打回后再修;合入 staging 后按修复进度做真机复测) +- 首次发现:2026-10-21 +- 最近更新:2026-10-22 - 影响面:个人报告英文版。约四成生成时整份英文被丢掉,页面只剩中文。 - 用户现象:揭幕后切到 English,没有英文正文。 - 触发条件:英文渲染里还留着汉字。Rath 起运裁到第 5 级、Panchanga 综合为凶、计都在起运宫或子运走 Table 14、月土合相不超过 10 度、完全 Kaal Sarp,都会碰上当时没有英文句子的那一行。这些情况合起来大约四成盘。 @@ -37029,25 +37093,25 @@ 2. `test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines` 断言的是旧阅读版词表 `PHRASES_EN`。完整数据版英文不走这张表,所以测试一直绿,生产路径却漏。 3. `tests/test_report_english_edition.py` 不在快速门清单里,staging 门禁不跑它。 4. BUG-1272 的修法是继续往末尾词表加条目。新句子或新盘况会再漏。 -- 修复:英文分支改用已有的英文孪生字段,缺的补上英文句子(Rath、Panchanga 综合性质、Dosha 摘要、瑜伽强度、Jaimini 职责、Vimsopaka 标题)。结构化附录的瑜伽表和 Dosha 摘要在英文里也改读 `combination_en`、`strength_en`、`effects_en`、`summary_en`;这些字段里还有汉字就留空,不退回中文。汉字判定和 `[source text retained]` 标记没有放宽。 -- 验证:`tests/test_report_english_han_sources.py` 5 条。`tests/test_report_english_sweep_smoke.py` 12 张覆盖第 5 级、凶、计都反向、Table 14、Punarphoo、完全 Kaal Sarp,进程池 4,199.6 秒,全绿。`tests/test_report_english_sweep.py` 200 张,进程池 4,3679 秒,全绿。把英文 Rath 行临时改回中文后,smoke 12/12 为 `han_leak`,测试变红,已改回。 -- 防复发:smoke 在快速门 `CORE_PYTEST_TARGETS` 里。200 张不进快速门。不要再只靠末尾词表补洞。 -- 相关记录:BUG-1272、BUG-1411。issue #89。 +- 修复:英文分支改用已有的英文孪生字段,缺的补上英文句子(Rath、Panchanga 综合性质、Dosha 摘要、瑜伽强度、Vimsopaka 标题)。Jaimini 职责的英文不写进 `scripts/jaimini.py`(生时校正打分冻结文件);渲染时按 Karaka 名查表。结构化附录的瑜伽表、Dosha 摘要、Sade Sati 和 Jaimini 职责在英文里读英文孪生字段。这些字段仍是汉字,或该有的英文孪生字段是空的,单元格写 `[translation missing: 字段名]`,不留空,也不退回中文。汉字判定和 `[source text retained]` 标记没有放宽。第 1 次实现曾改冻结文件、把缺译留成空格,并在测试里只挡住本进程的 PyJHora;这三条已按打回改掉。测试渲染把年度回放的两个隔离子进程换成与没装 PyJHora 时相同的 blocked 结果。 +- 验证:见 `docs/tasks/PROGRESS-report-english-han-leak-fix-20261022.md`。 +- 防复发:smoke 在快速门 `CORE_PYTEST_TARGETS` 里。200 张不进快速门。不要再只靠末尾词表补洞。不要改 `scripts/jaimini.py` 来补英文。缺译不要写成空单元格。 +- 相关记录:BUG-1272、BUG-1414。issue #89。本条在合并 staging 后从与 #86 相撞的 BUG-1410 改为 BUG-1413。 - 复发自:BUG-1272。旧测试和快速门都还在,覆盖面太窄,没有失效。 - 修复版本:分支 `codex/report-english-han-leak-20261021`。 -## BUG-1411 | 个人报告英文把中文拆碎后粘成一个词,并把「第 N 级」译成 house +## BUG-1414 | 个人报告英文把中文拆碎后粘成一个词,并把「第 N 级」译成 house -- 状态:resolved(本分支回归已过;合入 staging 后按 PROGRESS 做真机复测) +- 状态:resolved(第 1 次打回后再修;合入 staging 后按修复进度做真机复测) - 首次发现:2026-10-21 -- 最近更新:2026-10-21 +- 最近更新:2026-10-22 - 影响面:个人报告英文版里的 Rath、瑜伽组合、Dosha 摘要、Jaimini 职责和若干小标题。 - 用户现象:英文句子粘在一起,例如 `inperiod start`、`lordandstrong`、`weakafflicted`,或者出现 `house 5 level`。汉字门可能仍放行,因为汉字已经被拆掉了。 - 触发条件:英文路径仍输出中文,末尾词表按单字替换(「在」换成 in、「与」换成 and、「第」换成 `house `、「宫」换成空)。另外一条英文模板写成 `weak/afflicted`,已有的清理会把 `weak/` 收成 `weak`,变成 `weakafflicted`。 -- 根因:和 BUG-1410 同一条路径。短词先替换,长句被拆碎。结构化附录的瑜伽表一直打印中文 `combination`,再经逐词替换。 -- 修复:英文 Rath 用 `level N` 和英文级别名。附录瑜伽表和 Dosha 摘要在英文下改用英文孪生字段。`bvr_bandhubhisthyaktha_precise` 的英文模板改为 `weak or afflicted`。中文模板和中文字段没有改。没有往末尾词表加条目。 -- 验证:同上 200 张盘的粘连黑名单为 0。改前 16 张虚构盘里,这些粘连词每张盘都有若干条(见 PROGRESS)。 +- 根因:和 BUG-1413 同一条路径。短词先替换,长句被拆碎。结构化附录的瑜伽表一直打印中文 `combination`,再经逐词替换。 +- 修复:英文 Rath 用 `level N` 和英文级别名。附录瑜伽表和 Dosha 摘要在英文下改用英文孪生字段。`bvr_bandhubhisthyaktha_precise` 的英文模板改为 `weak or afflicted`。Jaimini 职责按 Karaka 名写英文,不经中文再替换。中文模板和中文字段没有改。没有往末尾词表加条目。 +- 验证:见 `docs/tasks/PROGRESS-report-english-han-leak-fix-20261022.md`。 - 防复发:200 张盘测试的黑名单。不要把「第 N 级」再送进会把「第」换成 `house ` 的词表。 -- 相关记录:BUG-1410、BUG-1272、BUG-1218。issue #89。 +- 相关记录:BUG-1413、BUG-1272、BUG-1218。issue #89。本条在合并 staging 后从与 #86 相撞的 BUG-1411 改为 BUG-1414。 - 复发自:无。与 BUG-1272 同一类末尾替换,单独记吃空格和错译。 - 修复版本:分支 `codex/report-english-han-leak-20261021`。 diff --git a/docs/tasks/PROGRESS-chart-ascendant-slash-20261021.md b/docs/tasks/PROGRESS-chart-ascendant-slash-20261021.md new file mode 100644 index 00000000..e5be2112 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-ascendant-slash-20261021.md @@ -0,0 +1,27 @@ +# 进度:上升状态格改成「/」(issue #87,2026-10-21) + +## 做了什么 + +- 星盘页本命表、月亮盘、分盘表里,上升的状态格写「/」,带 class `chart-page-asc-status`。悬停、键盘聚焦、手机点一下都走现有 `ChartSymbolHelp`。说明句是「上升点是命盘的起点,不是行星,不判断入旺或落陷」。读屏读到的是这一句。 +- 报告「行星主星与状态」表的上升行,中文和英文都写「/」。表下面紧跟一行说明:中文用上面那句,英文是 “The ascendant is the starting point of the chart, not a planet, so it has no dignity.” +- `frontend/DESIGN.md` 两处口径、`frontend/docs/VOICE.md` 上升那半句、`CHANGELOG.md` 已改。旧报告要重新生成才会变成「/」。 + +## 命令与结果 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend` 下 `tsc --noEmit -p .` | 通过,退出码 0 | +| 代码检查 | `frontend` 下 `npm run lint` | 0 个错误,122 条原有警告 | +| 星盘页相关测试 | `tsx --test`:`chart-varga-table.test.tsx`、`chart-moon-transit.test.tsx`、`chart-page-hook.test.ts` | 40 通过,0 失败,0 取消 | +| 报告上升行 | `.venv` 的 `pytest tests/test_report_chart_blank_columns.py -q` | 16 通过,退出码 0 | + +全量 `npm test` 和 `next build` 没有跑,交给验收。 + +## 偏离 + +1. 任务书要求 #86 合入 `origin/staging` 后再开工。开工时 #86 仍是待验收,`885bd566` 不在 `origin/staging`。核对这份提交:不改 `chart-vedic-tab.tsx`;`pl9_reader_export.py` 只把另一张表的速度改成 4 位小数;`DESIGN.md` 改的是顶栏人名按钮。本轮按产品要求直接做,基线是当时的 `origin/staging` `9812f3173`。#86 若先合入,本分支需要 rebase。预期重叠只在 `DESIGN.md` 的不同段落。 +2. `VOICE.md` 仍写上升「不适用」。改了这一处,避免和本单相反。任务书点名的是 `DESIGN.md`。 + +## 环境 + +本机没有登录后点过页面。Python 用的是 agent-loop 工作树里的 `.venv`。 diff --git a/docs/tasks/PROGRESS-chart-subject-table-20261021.md b/docs/tasks/PROGRESS-chart-subject-table-20261021.md new file mode 100644 index 00000000..33267ff7 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-subject-table-20261021.md @@ -0,0 +1,31 @@ +# 进度:星盘页人名、就地改资料、报告行星表(issue #86) + +规格是 Gitea issue #86,没有另写任务书。分支 `codex/chart-subject-table-20261021`,基线 `origin/staging` `bd3ce0e7b`。 + +## 改了什么 + +- 报告阅读页的 Markdown 表按内容定列宽。单词和数字不从中间断开。放不下时只在表格自己的框里横向滑动,页面不被撑开。星宿英文名用浅色小字,跟在中文后面同一行。速度显示并写出小数点后 4 位。打印不再改回固定列宽和任意断行。记入 BUG-1410。 +- 星盘、星历、报告和对话顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,弹出层底部有「+ 添加一个人」,去 `/people`。 +- 星盘页标题旁只留「日期 时间 · 地点」和一支笔。整行在当前页打开编辑框。本人各项都能改。别人已经保存的出生时间锁住,旁边写「填报的出生时间保存后不能再改」。改了日期、时间或地点才出现校正作废那一句。保存后星盘和星历按新资料重算。手机上这一行换到第二行,顶栏跟着长高。 + +## 和 issue 原文不一样的地方 + +- issue 写「已有 5 人就藏起添加入口」。线上档案上限是 20 人(`MAX_SAVED_SUBJECTS`)。满 20 人才藏。没有做成 5。 +- 别人的资料保存后,除了星盘缓存,星历缓存也清掉。档案页原来只刷新人物名单。星历页和星盘页共用这个人,不清的话会继续画旧时间。 +- 已经下载到本地的旧报告原文仍可能是 6 位小数。打开阅读页时会显示成 4 位。新导出的阅读版是 4 位。 +- 别人的出生时间仍然不能改。解开数据库限制是另一张单,本单不做。 + +## 检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend` 里 `tsc --noEmit -p .` | 退出码 0 | +| lint | `npm run lint` | 0 个错误,122 条原有警告。这次改过的文件 0 条警告 | +| 星盘页和本单测试 | `tsx --test tests/issue-86-chart-subject-table.test.tsx tests/chart-page-view.test.tsx` | 31 通过,0 失败 | +| 人名按钮、报告表、打印合同 | 同一轮 `tsx --test` 里的 `chat-profile-picker`、`report-reader-polish`、`report-fact-table-print`、两份 `personal-report-markdown-view` | 通过。当时行星表打印断言写错切片,已改后再跑星盘页那 31 条 | +| 阅读版速度 | `pytest tests/test_report_chart_blank_columns.py -q` | 通过,退出码 0 | +| 全量 `npm test` / `next build` | 本机执行约定交给验收再跑 | 未跑 | + +## 浏览器 + +本机没有对着已登录的星盘页点过这三处。验收时请看:人名按钮、弹出层里的添加入口、星盘页出生行和编辑框、一份带行星表的报告在窄屏下横向滑动。 diff --git a/docs/tasks/PROGRESS-chart-subject-table-fix-20261022.md b/docs/tasks/PROGRESS-chart-subject-table-fix-20261022.md new file mode 100644 index 00000000..1d10404a --- /dev/null +++ b/docs/tasks/PROGRESS-chart-subject-table-fix-20261022.md @@ -0,0 +1,48 @@ +# 进度:星盘页就地编辑 / 报告表换行(issue #86 第 1 次打回) + +修复单 `docs/tasks/TASK-chart-subject-table-fix-20261022.md`。分支 `codex/chart-subject-table-20261021`。开工先合入 `origin/staging` `2806bd5b75bd701e62e837fae70eda87d6d796ac`(只含文档,无冲突)。被打回的提交是 `885bd5665`。 + +## 改了什么 + +- **F1** 报告里只有表头含「速度 / Speed / 度数 / 黄经」的数值表加 `personal-report-table-numeric`,单行和按内容撑宽只挂在这个类上。作用说明、建议这类表恢复换行。打印时所有表都是页宽,单元格可以换行。打印块里不再出现按内容撑宽和禁止换行。 +- **F2** 「保存他人」收到 `frontend/src/lib/other-profile-save.ts` 的 `saveOtherProfile`。请求、409 `reported_birth_time_is_immutable` 的文案、成功后清星盘和星历缓存都在这里。星盘编辑框和档案页都调它。档案页以前只刷新名单,改完别人再看星盘仍是旧盘,记为 BUG-1411。 +- **F3** 编辑框只在点击落在框外(遮罩)时关闭。点内边距、或拖选文字后松手落在内边距上,不关闭、不丢掉未保存内容。Esc 仍关闭。记为 BUG-1412。 +- 测试文件改名为 `frontend/tests/chart-birth-editor-and-report-table.test.tsx`。仓库里没有别的测试按源码断言旧文件名;BUG-1410 的验证路径已改成新名字。 +- 「填报的出生时间保存后不能再改」只留在时间旁边那一行。表单说明里不再写。档案页编辑已经保存过出生时间的他人时,也锁住这一项,所以同一句仍然会出现,但只从这一处来。 + +## 断言改动 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 本单测试里的表格 CSS | `.personal-report-table-wrap` 的单元格含禁止换行;打印表按内容撑宽,单元格也不换行 | 这两条只出现在 `.personal-report-table-numeric` 下;打印块里没有;普通表宽 100% | 整句表和打印被数值表的单行规则带走了 | +| 同上,星宿英文名 | `.report-nakshatra-en` 自己禁止换行 | 仍是浅色小字;单行靠数值表单元格 | 禁止换行只留给数值表。数值表的格子已经不会从中间拆开英文名 | +| 同上,锁定提示 | 渲染结果里出现「填报的出生时间保存后不能再改」即可 | 锁定时正好一次,写在时间旁边;说明、编辑框源码、档案页源码里都没有这句 | 同一句原来在说明和锁定提示各出现一次 | +| `chart-library-other-profile.test.ts` | `/method: editingOther ? "PUT" : "POST"/` 写在人物页 | `/method: id ? "PUT" : "POST"/` 在 `saveOtherProfile` | 星盘编辑框和档案页共用这一处请求 | +| 同上 | `catch {` 后 180 字内「保存失败,请重试」 | `catch (caught)` 后仍有这句 | 409「填报的出生时间不能再改」要从共用函数原样提示,不能一律盖成保存失败 | + +没有删测试,也没有放宽「保存失败不得假装成功」「不写本地星盘库」这些断言。 + +## 检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend` 里 `node_modules\.bin\tsc --noEmit -p .` | 退出码 0 | +| lint | `npm run lint` | 0 个错误,122 条警告(与打回验收记录的基线相同)。这次改过的前端文件再跑 eslint,0 条警告 | +| 本单测试 | `tsx --test tests/chart-birth-editor-and-report-table.test.tsx` | 8 通过,0 失败 | +| 星盘页、档案页、报告表、CSS 合同 | 同一条 `tsx --test` 加上 `chart-page-view.test.tsx`、`people-archive-view.test.tsx`、`people-archive-regressions.test.ts`、`self-edit-people-20260928.test.tsx`、`profile-gender-ui-20260927.test.tsx`、`chart-library-other-profile.test.ts`、`settings-mvp-contract.test.ts`、`report-reader-polish.test.tsx`、`report-fact-table-print.test.ts`、`personal-report-markdown-view.test.ts`、`personal-report-longform-md.test.ts` | 105 通过,0 失败,0 取消 | +| 阅读版速度 | `pytest tests/test_report_chart_blank_columns.py -q`(本工作树没有 `.venv`,用了 `agent-loop` 工作树的解释器) | 16 通过,退出码 0 | +| 全量 `npm test` / `next build` | 修复单约定交给验收 | 未跑 | + +## 偏离 + +- 整句表用自动列宽加页宽,单元格可换行,没有退回改前的固定列宽。固定列宽是行星表被从中间拆开的原因。 +- 档案页在别人已经保存过出生时间时也锁住这一项。这样「保存后不能再改」只留在时间旁边,档案页不会把这句丢掉。 +- 开工时 `BUG_HISTORY` 去重后的最大号是 1404。新增 BUG-1411(档案页缓存)和 BUG-1412(点内边距关闭)。修复单写明 #85 占用 1404~1408,合入前会重编。没有再用 1404。 +- 没有在浏览器里点已登录的星盘页、档案页和报告页。 + +## 未做 + +- 没有 push。 +- 没有跑全量 `npm test`,没有 `next build`。 + +- 合入 staging 时 Claude 把本分支的 BUG-1404~1406 改为 **BUG-1410~1412**(1404~1408 归 #85,1409 归 #90)。 diff --git a/docs/tasks/PROGRESS-report-export-dialog-polish-20261021.md b/docs/tasks/PROGRESS-report-export-dialog-polish-20261021.md new file mode 100644 index 00000000..508ce930 --- /dev/null +++ b/docs/tasks/PROGRESS-report-export-dialog-polish-20261021.md @@ -0,0 +1,48 @@ +# 进度:导出弹窗提示与选中态(2026-10-21) + +英文不可用时,打开导出弹窗会在顶部提示一次,弹窗里不再留那两句。选中态的描边、圆点和勾选框改用主题色,底色保持中性。英文可用时,「建议选 English」仍在语言选项下面。页面顶栏的中英切换没有改。 + +## 基线 + +- 开工时 `origin/staging` = `9812f31737b9e9fdecc75952b1bac1558333c685`。任务书里写的 `bd3ce0e7b` 之后,staging 上又有纯文档提交。 +- 分支 `codex/report-export-dialog-polish-20261021`,工作树 `.worktrees/report-export-dialog-polish-20261021`。 +- 另开一份干净基线工作树没有成功:路径过长,写不下 `sealed_holdout_rerun_cross_midnight_2026_09_20.final.freeze.json`。所以没有单独的基线测试数字。本分支上没改过的用例是通过的。 + +## 断言三栏 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/report-buttons-scope-20260928.test.ts` | 范围卡和语言分段 `doesNotMatch(/--color-action-soft\|--color-action\b/)`,并且 `match(/--color-ink\|--color-canvas/)` | 四条选中规则不得含 `--color-action-soft` / `--color-action-fill`;描边、圆点、勾选框和语言分段用 `--color-action-strong`(文字用 `--color-action`);底色只许 `transparent` / `--color-canvas*`,勾选框小方块的底可以是 `--color-action-strong` | issue #90 推翻 DESIGN 那一句。旧正则会把 `--color-action-strong` 判成违规。BUG-1078「不得用 soft / fill 深色块」仍在 | +| `frontend/tests/report-english-edition.test.tsx` | legacy 时弹窗正文含「重新生成即可获得中英两版」 | 正文不含该句;`toast.info` 一次,文案为该句,id 为 `report-export-english`;仍没有 English 选项;英文可用时仍有「建议选 English」 | 说明改成打开时的顶部浮层 | + +## BUG 编号 + +这份 `docs/BUG_HISTORY.md` 在 staging 上的最大号是 BUG-1403。#85 未合入分支已经写到 BUG-1407,任务书把 1404 起留给 #85。#86 未合入分支另写了一个内容不同的 BUG-1404。本单用 **BUG-1408**,追加在文件末尾。文件约第 18099 行还有一份重复的 Bug History,按任务书没有去重。 合入 staging 时 Claude 改为 **BUG-1409**(#85 分支的 T11 已用 1408)。 + +## 检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| tsc | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| lint | 在 `frontend` 下 `npm run lint` | 0 error,122 warning。改过的四个文件单独 lint 没有输出 | +| 导出弹窗 | `node node_modules/tsx/dist/cli.mjs --test tests/report-export-drawer.test.tsx` | 8 pass,0 fail | +| 英文版 | 同上 `tests/report-english-edition.test.tsx` | 10 pass,0 fail | +| 选中态 | 同上 `tests/report-buttons-scope-20260928.test.ts` | 6 pass,0 fail | +| 类名 | 同上 `tests/class-name-definition-contract.test.ts` | 3 pass,0 fail | +| 深色 | 同上 `tests/dark-theme-contract.test.ts` | 5 pass,0 fail | +| 色板 | 同上 `tests/design-token-contract.test.ts` | 4 pass,1 fail | +| 隐私 | `.worktrees/agent-loop/.venv` 的 `python -m pytest tests/test_repo_privacy_markers.py -q` | 62 passed | + +六份前端测试是一次一起跑的:37 条里 36 过、1 失败、0 cancelled。失败的那条是 `design-token-contract.test.ts`「every color token used as a Tailwind utility is exposed through @theme」。`sourceFiles` 把 `URL.pathname` 再交给 `path.join`,Windows 上拼成 `G:\G:\Ferti\...`,打开 `chart/page.tsx` 时 ENOENT。这条测试和 `chart/page.tsx` 本单都没改,token 值也没改。Linux 上由 Claude 复验。 + +全量 `npm test` 和 `next build` 按分工没有跑。没有动数据库,`test:db` 没跑。 + +## 偏离 + +- 真机清单只加了一句核对说明,九步没有改。 +- 工作树的 `frontend/node_modules` 是指到主检出的目录联接,没有单独安装。 +- 本工作树没有 `.venv`,隐私测试用的是 `.worktrees/agent-loop/.venv`。 + +## 没做 + +浅色、深色和手机宽度的真机走查。清单在 `docs/testing/report-export-dialog-20261021.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1dfa419e..e283c7dd 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -251,6 +251,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| issue #86(无独立任务书) | [PROGRESS](PROGRESS-chart-subject-table-20261021.md) | 星盘页人名看得出能点、出生资料就地改、报告行星表不再从中间断开(BUG-1410) | 待验收 | `codex/chart-subject-table-20261021` | | [TASK-chart-surface-polish-20260929](TASK-chart-surface-polish-20260929.md)([验收修复](TASK-chart-surface-polish-fix-20260929.md)) | [PROGRESS](PROGRESS-chart-surface-polish-20260929.md) | 去引擎展示、庙旺三档保留、同位仅 D9、宫位左对齐、图标解释与桌面盘宽收紧(BUG-1098 / 1099) | 本地实施完成(定向/浏览器通过;全量保留 29 项基线失败,未推送) | `codex/chart-surface-polish-20260929`;不发布、不提升 main | | `TASK-docs-reconcile-20260926.md` | `PROGRESS-docs-reconcile-20260926.md` | **状态板 / BUG / BLOCKED 过期对账**(纯文档):约 35 行校正任务状态过期、BUG-085/086/743/981/984 记录过期、BLOCKED latency 条目过期;逐条附证据改,不把欠真机写成通过 | 待验收 | `codex/docs-reconcile-20260926`(本地提交,未推送) | | `TASK-people-ephemeris-ui-20260926.md` | `PROGRESS-people-ephemeris-ui-20260926.md` | **星盘档案页补样式 + 星历可读性改版**(原型 https://claude.ai/artifact/FmPy9xZ7dQw9u7yrW4WzTw ,产品 09-26 确认):/people 样式 0 条(BUG-1039)→ 手机列表→详情、桌面两栏;星历五要素先中文后梵文(附唯一对照表)、吉凶彩色标签、固定规则当日总评(不调模型)、标题栏大按钮移到底部提示卡。必须真实浏览器截图验收。与 BUG-1038 单并行、文件不交叉 | 已验收(Claude 09-26 直接执行模式:子代理实现,Claude 独立复验 tsc/lint 0、全量 3916 条失败名单与基线逐条一致、四路由 ○、gzip +0.00%、36 张浏览器截图核对与原型一致) | `0ab061b9`(已部署,run 2910,health 一致) | @@ -512,6 +513,13 @@ | `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:P0 `.gte` 已在 staging(BUG-1399)。本分支补时间线起止、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案,以及合盘创建真库测试(BUG-1400~1403) | 待验收 | `codex/synastry-staging-fix-20261021` | | `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` | | `TASK-other-subject-birth-time-edit-20261021.md` | — | 其他人的出生时间可以修改(去掉 `chart_profiles_guard_birth` 的填报时间不可改;推翻 people-archive P1 §验收 2 该条;active 锁不变) | 排队(等 #86 合入) | `codex/other-subject-birth-time-edit-20261021`;动表;不进循环 | -| `TASK-chart-ascendant-slash-20261021.md` | — | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 待领取(等 #86 合入) | `codex/chart-ascendant-slash-20261021` | -| `TASK-report-export-dialog-polish-20261021.md` | — | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 待领取(可与 #89 并行) | `codex/report-export-dialog-polish-20261021` | +| `TASK-report-export-dialog-polish-20261021.md` | `PROGRESS-report-export-dialog-polish-20261021.md` | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 已合入 staging | `codex/report-export-dialog-polish-20261021` | +| `TASK-chart-ascendant-slash-20261021.md` | `PROGRESS-chart-ascendant-slash-20261021.md` | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 已合入 staging | `codex/chart-ascendant-slash-20261021` | | `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待验收 | `codex/report-english-han-leak-20261021` | +| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` | +| `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` | +| `TASK-goel-jaimini-strength-20261022.md` | — | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 待领取(可与 #89 并行;须在 #91 印章前合入) | `codex/goel-jaimini-strength-20261022` | +| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` | +| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` | +| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-20261021.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 待领取 | `codex/synastry-staging-fix-20261021` | +| `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-20261021.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 待领取 | `codex/report-english-han-leak-20261021` | diff --git a/docs/tasks/TASK-chart-dasha-yogini-20261021.md b/docs/tasks/TASK-chart-dasha-yogini-20261021.md new file mode 100644 index 00000000..62be076d --- /dev/null +++ b/docs/tasks/TASK-chart-dasha-yogini-20261021.md @@ -0,0 +1,127 @@ +# 任务书:星盘页大运右栏可切换,加 Yogini(含小运)(2026-10-21,issue #88) + +> 产品 2026-10-21 已决定 **Ashtottari 不在本单**(见 P2 与「另行处理」)。 + +## 基线 + +- 写单时 `origin/staging` = `bd3ce0e7b`(此后 `308ca2a65` 只加了 #87 任务书)。**开工时以 #86、#87 合入后的 `origin/staging` 为准**;任一未合入就不开工,回报「等 #86 / #87」。 +- 执行方:**Grok(Windows + Docker)**。分支 `codex/chart-dasha-yogini-20261021`,工作树 `.worktrees/chart-dasha-yogini-20261021`,**只推这个分支**,不推 staging、不推 main。 +- 关联:BUG-1196、BUG-1262(Yogini 顺序与出生余额)、BUG-1341(`/api/dasha` 改走引擎模块)、BUG-1344(PyJHora 大运对照的来源)。 + +### 与 #86、#87 的文件重叠 + +| 文件 | #86 | #87 | 本单 | +| --- | --- | --- | --- | +| `frontend/src/components/chart-page/chart-page-view.tsx` | 出生资料行(页头 `birthline` 一带)、人名切换 | — | `ChartDashaTab` 的入参(等待 / 失败按层传) | +| `frontend/src/hooks/use-chart-page.ts` | 可能(切换人物时) | — | `mergeLayer`、`missing` 判定 | +| `frontend/tests/chart-page-hook.test.ts` | 可能 | 旧缓存时 `>不适用<` 断言改成 `/` | 新层的 429 / 失败 / 合并用例 | +| `frontend/tests/chart-page-view.test.tsx` | 可能 | — | 大运 Tab 渲染用例 | +| `frontend/src/app/globals.css`、`frontend/DESIGN.md` | 是 | 是 | 是(大运切换条、§大运 Tab) | + +`chart-vedic-tab.tsx`、`scripts/pl9_reader_export.py`(#87)本单不碰。#86 写单时没有任务书在 staging 上,开工时用 `git log -p origin/staging -- <上表文件>` 看它们实际改了什么,在其上改,不得回退。 + +## 现状实证(按符号定位) + +| # | 事实 | 位置 | +| --- | --- | --- | +| 1 | `/api/dasha` 对非 Vimshottari 体系调 `dasha_registry.compute_dasha`,响应只带 `periods` 和 `algorithm_label / algorithm_note / profile`。小运放进每个大运行的 `antardasha` 字段就会原样透出,**不需要改 `jyotish_api_server.py`** | `scripts/jyotish_api_server.py` `_compute_dasha_system`;`scripts/dasha_registry.py` `compute_dasha` yogini 分支 | +| 2 | Yogini 只有大运。行字段 `yogini / planet / full_years / years / start_date / end_date / unclipped_start_date / cycle_number`;第一段 `start_date` 已裁到出生时刻,未裁起点在 `unclipped_start_date`;年长 365.25 天;至少 3 轮(24 段) | `scripts/yogini_dasha.py` `_calculate_moon_balanced_yogini` | +| 3 | 完整解读也调 `calculate_yogini_dasha`(`jyotish_engine.cmd_full_reading`),`test_dasha_api_engine_parity.py::test_api_major_periods_match_full_reading_modules` 断言接口 `periods` 与完整解读模块行逐字段相等 | `scripts/jyotish_engine.py`(`from yogini_dasha import calculate_yogini_dasha`);同名测试 | +| 4 | PyJHora 4.8.7 的 Yogini 小运是 8 等分(`yogini.get_dhasa_bhukthi` 里 `parent_years / n`),与产品定的按比例算法不同,所以小运不与 PyJHora 对照(P1)。Yogini **大运**写单时实测与 PyJHora 主星顺序一致、起点误差 ≤1 天(虚构盘、Presley、Spears 等 4 张,各 24 段) | PyJHora `jhora/horoscope/dhasa/graha/yogini.py` | +| 5 | 前端 Chara 按需层链路:`CHART_VIEW_LAYERS`(`chart-view-engine.ts`)→ `parseChartViewLayers`(`app/api/chart-view/route.ts`)→ `assembleChartView` 按层并发调引擎(`chart-view-load.ts`)→ `buildChartView` / `charaTrack`(`chart-view-mapper.ts`)→ `chartViewOkSchema.dasha` 与 `layerFailures`(`chart-view-contract.ts`,轨道 `id` 是 `z.enum(["vimshottari","chara"])`)→ `useChartPage.requestLayer` / `mergeLayer`(`hooks/use-chart-page.ts`)→ `ChartPageView.selectTab("dasha")` 请求 `chara`(`components/chart-page/chart-page-view.tsx`)→ `ChartDashaTab` 右栏写死 `view.dasha.chara`,等待态 `OrbitWaiting`(`components/chart-page/chart-dasha-tab.tsx`) | 各文件按符号 | +| 6 | 照抄 Chara 会踩的三个坑:① 引擎缓存键 `chartViewEngineCacheKey` 只含 path 不含 body;Yogini 走 `/api/dasha`,将来其他体系也走同一 path,**不带 `cacheScope` 就会串缓存**(`chartViewEngineCachePath(path, cacheScope)`,行运层已这样做);② `mergeLayer` 对 `chara` 整个替换 `dasha` 对象(`{ dasha: incoming.dasha }`),新层这样写会把已到的 Chara 冲掉;③ `secondary-page-data.ts` 的 `refreshChartPage` 合并旧快照时只保留 `dasha.chara`,新轨道会在刷新后丢失 | `chart-view-engine.ts` `chartViewEngineCacheKey`;`use-chart-page.ts` `mergeLayer`;`secondary-page-data.ts` `refreshChartPage` | +| 7 | DESIGN.md 现有约定:大运 Tab「≥1024 时 Vimshottari 在左、Chara 在右;更窄则上下排」(`.chart-page-dasha-columns`);按需层「失败移除等待,再点该 Tab / chip 清失败并重试」 | `frontend/DESIGN.md` §大运 Tab、§按需层 | + +## 根因 + +不是 bug,是缺口:页面只接了 Chara 一个按需层,Yogini 引擎没有小运。 + +## 决策记录(产品负责人 2026-10-21) + +| # | 决定 | +| --- | --- | +| K1 | 左栏固定 Vimshottari 不变。右栏顶部加体系切换:**Chara / Yogini**,默认 Chara | +| K2 | Yogini 和 Chara 一样能点开大运看小运;「现在」卡显示大运、小运、小运剩余 | +| K3 | (原「Ashtottari 只在适用时出现」)随 P2 移出本单 | +| K4 | 数据走现有 `POST /api/dasha {dasha:"yogini"}`;Yogini 小运在 `scripts/yogini_dasha.py` 新增;不改冻结文件 `jyotish_api_server.py` | +| K5 | 核对(按 P1 调整):Yogini **大运**与 PyJHora 对照,≥3 张公开名人盘,主星顺序一致、起止日误差 ≤1 天;Yogini **小运**做内部自洽核对(见 T3)。PyJHora 只作测试参照,不进运行代码。对不上就停下报告,不得放宽误差;对不上则 Yogini 不上页面 | +| K6 | 前端照 Chara 按需层加 `yogini` 层;切到 Yogini 时才请求;失败只在右栏显示失败句,可重试;等待态沿用 `OrbitWaiting`,不新增加载动画 | +| K7 | 合同测试 fixture 取自真实 `/api/dasha` 响应(golden),不得手造 | +| K8 | 手机宽度两栏上下排,切换条横向不溢出 | +| K9 | 改了 Python,验收跑 Python 快速门全量 | +| K10 | 不在本单:Ashtottari、Kalachakra、Narayana、星座类体系 | +| P1 = A | Yogini 小运**按比例**:时长 = 大运年数 × 子运年数 ÷ 36,从该大运的 Yogini 起依次轮转 8 位;首个大运按出生时已走过的比例裁掉前面的部分。小运不与 PyJHora 对照(PyJHora 是等分)。页面在 Yogini 小运处注明「小运未经外部软件核对。」 | +| P2 = A | **Ashtottari 不在本单**。切换只有 Chara / Yogini。Ashtottari 的起运表与适用口径另立 issue(见「另行处理」) | + +## 硬红线 + +1. **不改 `scripts/jyotish_api_server.py`**(`git diff origin/staging -- scripts/jyotish_api_server.py` 必须为空);`tests/test_api_server_growth_contract.py` 照常通过。 +2. **不碰 Ashtottari**:`scripts/ashtottari_dasha.py`、`scripts/ashtottari_rule_profiles.py`、`dasha_registry.compute_dasha` 的 ashtottari 分支、完整解读里的 Ashtottari 段一律不改。 +3. PyJHora 只在测试里出现;`tests/test_runtime_jhora_import_scan.py` 必须通过;`scripts/**` 不得 `import jhora`。 +4. 对照测试必须**真跑**:工作树 `.venv` 按 `requirements-reference-engines.txt` 装 PyJHora 4.8.7;新对照用例 `pytest -rs` 不得出现 skip。跳过不算通过。 +5. 对照测试走**接口路径**(`_compute_dasha_system` 或 `compute_dasha`),不直接调模块。 +6. **停止条件**:Yogini 大运对照不过(主星顺序不同,或任一起止日误差 >1 天),或 T3 的内部核对不过,停下,在 PROGRESS 写清哪张盘、哪一段、差多少,回报;不得放宽误差、不得换样本凑通过、不得改 PyJHora 调用参数迎合引擎。此时 Yogini 不上页面。 +7. 不改完整解读(`cmd_full_reading`)的 Yogini 输出:小运只挂在接口返回的副本上;虚构盘完整解读逐字段不变(PROGRESS 附 diff 结论)。 +8. 前端 fixture 只来自真实 `/api/dasha` 响应,文件头写 `sourceCommand`(命令、盘、岁差、交点);不得手写形状。 +9. 改既有断言写「原值 / 新值 / 原因」三栏;测试总数不得少于开工基线。 +10. 不改数据库、不改 `.gitea/`、不升级依赖(PyJHora 只装进工作树 `.venv`,不改 `requirements*.txt`)。 +11. 隐私:fixture、测试、文档只用公开名人盘或虚构盘。 + +## 任务分解 + +| # | 内容 | 验收标准 | Grok 自跑 | Claude 验收 | +| --- | --- | --- | --- | --- | +| T1 | `scripts/yogini_dasha.py` 新增独立函数(如 `yogini_antardashas(major_row, *, birth_dt, year_days)`):从该大运自己的 Yogini 起依次 8 位;时长 = 大运 `full_years` × 子运年数 ÷ 36;从 `unclipped_start_date` 起排,结束于出生前的小运丢掉,跨出生的那段起点裁到出生(保留 `unclipped_start_date`);最后一段收尾到大运 `end_date`。`calculate_yogini_dasha` 输出不变 | 新单测(沿用 `test_yogini_seed_profile.py` 的盘):见 T3 内部核对 | `tests/test_yogini_seed_profile.py` + 新单测 | 复验 + 快速门 | +| T2 | `dasha_registry.compute_dasha` 的 yogini 分支:对大运行做深拷贝并挂 `antardasha`(字段 `level / planet / yogini / lord / years / start_date / end_date`,跨出生那段加 `unclipped_start_date`) | 虚构盘、Spears 接口响应有小运;完整解读 Yogini 模块与改前逐字段相同 | 同 T3 | 复验 | +| T3 | 核对(K5、P1、红线 4–6),写进 `tests/test_dasha_api_engine_parity.py`:**① 大运对照**:≥3 张公开盘(候选 Presley、Spears、Einstein、Trump,盘数据已在 `tests/smoke_test_runner.py` / 本测试文件里),接口大运对 PyJHora `yogini.get_dhasa_bhukthi(..., dhasa_level_index=1)`,主星顺序一致,起点(用 `unclipped_start_date`)误差 ≤1 天。**② 小运内部核对**(同几张盘的全部大运):每个大运的小运时长之和与大运(裁后)时长相差 ≤1 天;小运主星从该大运自己的 Yogini 起按 Goel Table I 轮转;首个大运第一条小运起点等于大运裁后起点(出生时刻),其余大运第一条小运起点等于大运起点;相邻小运首尾相接;未裁的首个大运按比例时长与公式一致。**③ 既有断言**:`test_api_major_periods_match_full_reading_modules` 对 yogini 由 `result["periods"] == engine_rows` 改为去掉 `antardasha` 后相等(三栏) | `pytest -rs` 0 skip;全部通过或按红线 6 停止 | 必跑 | 复验(本机已装 PyJHora 4.8.7) | +| T4 | 前端数据层:`CHART_VIEW_LAYERS` 加 `yogini`;`assembleChartView` 调 `/api/dasha`,body 为 `followUp` + `dasha: "yogini"`,**`cacheScope` 取体系名**(`"dasha:yogini"`);`chartViewDashaTrackSchema.id` 加 `yogini`,`chartViewOkSchema.dasha` 与 `layerFailures` 加 `yogini`;`chart-view-mapper.ts` 照 `charaTrack` 写 `yoginiTrack`:大运 / 小运日期直接用引擎值(`derived: false`),大运标签「 / <行星中文>」(例「Pingala / 太阳」),小运同样格式;`antardashaNote` = 「小运未经外部软件核对。」(放在 `labels.ts`,在轨道列表下与其他轨道的说明句同一位置,`.chart-page-note`)。**可推广要求**:大运层的「层名 → 引擎体系键 → 缓存 scope → 轨道键」写成一张表(一处常量),加第二个 `/api/dasha` 体系时只加一行 + 一个映射函数,不改 hook 与合并逻辑 | golden 测试:Yogini 真实响应映射后过 `chartViewOkSchema`;缓存测试:同一人先取 Chara 再取 Yogini、再换一个 `/api/dasha` 体系键(测试里传参),各拿各的数据 | 改到的测试文件 | 全量 `npm test` | +| T5 | `use-chart-page.ts`:`mergeLayer` 对大运层只替换各自那条轨道(`dasha: { ...current.dasha, [轨道]: incoming.dasha[轨道] }`,Chara 也改成这样),不再整体替换 `dasha`;`requestLayer` 中 Yogini 以 periods 为空判失败;`secondary-page-data.ts` `refreshChartPage` 按 T4 那张表保留所有已到的大运轨道,不再只写 `chara` | hook 测试:先到 Chara 再到 Yogini,Chara 仍在;反过来亦然;Yogini 429 只在 Yogini 层失败;D1 刷新后 Yogini 轨道仍在 | 改到的测试文件 | 全量 | +| T6 | 页面:`ChartDashaTab` 右栏顶部切换条(Chara / Yogini,默认 Chara,`role="tablist"` 或分段按钮,当前项样式沿用页面 Tab 下划线语言);进大运 Tab 仍只请求 Chara;切到 Yogini 才请求 `yogini`;等待 `OrbitWaiting`;失败只在右栏显示 `chartLayerFailureCopy` 的句子,再点当前选项重试(与 DESIGN.md 按需层重试一致,不另加按钮);左栏 Vimshottari 不受影响;`chart-page-view.tsx` 把按层的等待 / 失败传进 `ChartDashaTab` | 渲染测试:默认 Chara;切到 Yogini 只发 yogini 层;右栏失败时左栏正常;再点 Yogini 重新请求;Yogini「现在」卡有大运 / 小运 / 小运剩余;Yogini 轨道下有「小运未经外部软件核对。」,Chara、Vimshottari 下没有 | 改到的测试文件 | 全量 + `next build` | +| T7 | 样式与文档:切换条在 375px 不横向溢出(两个短标签,不用横向滚动;点击区 ≥44px);≥1024 两栏、更窄上下排沿用 `.chart-page-dasha-columns`;DESIGN.md §大运 Tab 改写(右栏可切换、Yogini 小运说明句、失败与重试、按需请求列表加 `/api/dasha` Yogini);`frontend/docs/VOICE.md` 若有大运 Tab 文案表则补这句;CHANGELOG;PROGRESS;真机清单 `docs/testing/chart-dasha-switch-20261021.md`(电脑与手机各打开大运 Tab,切两种体系、展开当前大运看小运、核对「现在」卡、手机看切换条不溢出) | 样式断言测试;文档齐 | — | 复验;`/` 仍 Static、首屏 gzip ±2% | + +### 按源码文本断言、需要同轮跟改的测试(AGENTS §7.8,开工时再 grep 一遍) + +| 测试 | 断言的内容 | +| --- | --- | +| `frontend/tests/chart-view-route.test.ts` | `loadSource` 匹配 `/api/dasha/chara`;`CHART_VIEW_LAYERS` 全层组装;`body.dasha.chara.title` | +| `frontend/tests/chart-page-hook.test.ts` | 按层 429 循环(层名列表带三栏注释,加层再写三栏);引擎桩按 path 分派(`/api/dasha` 要按 body 的 `dasha` 分派) | +| `frontend/tests/chart-page-view.test.tsx` | 大运 Tab 含「Chara Dasha(kn_rao 变体)」;chara 失败 / 等待;「现在」卡数量 = 2、时间条段数 = 两条轨道段数之和(默认 Chara 时应不变) | +| `frontend/tests/chart-view-zero-year.test.ts` | `ChartDashaTab` 渲染 Chara 0 年段、段数 | +| `frontend/tests/secondary-page-entry.test.ts` | `use-chart-page.ts` 源码文本(`useState(() => viewMatchingSubject(subjectId))`) | +| `frontend/tests/chart-profile-update-consistency.test.ts`、`chart-view-engine.test.ts` | 缓存键、`chartViewOkSchema` | +| `tests/test_chart_page_vedic_tables_contract.py` | 用 golden 跑 `buildChartView`(`chara: null`,新增入参要兼容) | +| `tests/test_dasha_api_engine_parity.py` | `periods == engine_rows`、未实现键报错 | +| `tests/run_all.py`「Yogini Dasha 8 periods」、`tests/test_yogini_seed_profile.py`、`tests/test_full_reading_conditional_dashas.py`、`tests/test_report_longform_*` | Yogini 模块输出(完整解读不变则不应变红) | + +## 让步顺序 + +1. 时间不够:T1–T3(Python + 核对)单独提交先推分支,前端后补;不得先上前端、后补核对。 +2. T4 的「可推广表」若与现有类型冲突难以收敛,可先只做 Yogini,但 T5 的「按轨道合并、刷新保留全部大运轨道」不让步。 +3. 核对不过:按红线 6 停,Yogini 不上页面,本单只交已完成的前端重构部分须经 Claude 确认后再推。 + +## 开工前置命令 + +```bash +git fetch origin --prune +git log --oneline -30 origin/staging # 确认 #86、#87 已合入;没合入就回报「等 #86 / #87」 +git worktree add -b codex/chart-dasha-yogini-20261021 .worktrees/chart-dasha-yogini-20261021 origin/staging +cd .worktrees/chart-dasha-yogini-20261021 +.venv/Scripts/python -m pip install -r requirements-reference-engines.txt # 只装进工作树 .venv +.venv/Scripts/python -c "import importlib.metadata as m; print(m.version('PyJHora'))" # 必须 4.8.7 +.venv/Scripts/python -m pytest tests/test_dasha_api_engine_parity.py tests/test_yogini_seed_profile.py -rs -q # 记基线 +grep -rn "chara" frontend/src/lib/chart-view-*.ts frontend/src/hooks/use-chart-page.ts frontend/src/lib/secondary-page-data.ts frontend/src/components/chart-page/ +grep -rln "chart-dasha-tab\|use-chart-page\|chart-view-load\|chart-view-contract\|chart-view-mapper\|CHART_VIEW_LAYERS\|dasha/chara" frontend/tests tests +git log -p origin/staging -- frontend/src/components/chart-page/chart-page-view.tsx frontend/src/hooks/use-chart-page.ts | head -200 # 看 #86/#87 改了什么 +grep -o "BUG-1[0-9]\{3\}" docs/BUG_HISTORY.md | sort -u | tail -3 +``` + +验收分工:Grok 自跑 `tsc --noEmit`、`npm run lint`、改到的前端测试文件、改到的 Python 测试文件(含 T3,`-rs` 无 skip);Claude 验收时跑前端全量 `npm test`、Python 快速门 `run_quality_gate.py --profile quick`、`npm run build`(`/` 仍 Static、首屏 gzip ±2%),并复跑 T3。本单不动数据库,不要求 `test:db`。执行方不得在真机清单里写「已通过」。 + +## 另行处理(不在本单) + +- **Ashtottari** 的起运表与「本盘是否适用」口径另立 issue,由 Claude 起草给产品负责人确认后再提。写单时已确认的问题:接口用的旧白名单起运星在 12 个月宿里有 8 个与 PyJHora 不一致、白名单索引 20 的注释错一位、三套适用口径在 10 张公开盘上结论互不相同、接口从不传规则族、现有对照测试绕过接口路径。 +- **完整解读 / 报告里的 Ashtottari 段目前可能是错的**(与接口共用同一函数、同一白名单)。本单不碰,留给上述 issue。 + +## BUG 编号 + +本单是功能缺口,原则上不新增 BUG;执行中若发现并修了 bug 才记。编号取开工时 `docs/BUG_HISTORY.md` 最大号 +1。该文件自 `400af57e8` 起整段重复了一遍,取最大号务必用 `grep -o "BUG-1[0-9]\{3\}" docs/BUG_HISTORY.md | sort -u | tail -1`,不要按行号或首次出现取。#85 占用 BUG-1404~1408。 diff --git a/docs/tasks/TASK-chart-subject-table-fix-20261022.md b/docs/tasks/TASK-chart-subject-table-fix-20261022.md new file mode 100644 index 00000000..6ff8621b --- /dev/null +++ b/docs/tasks/TASK-chart-subject-table-fix-20261022.md @@ -0,0 +1,60 @@ +# 修复单:星盘页就地编辑 / 人名切换 / 报告行星表(issue #86,第 1 次打回,2026-10-22) + +- 被验提交:`885bd5665`(分支 `codex/chart-subject-table-20261021`) +- 需求:issue #86 正文(无单独任务书) +- 执行方:Grok(Windows + Docker)。在**同一分支**上继续,只推这个分支;开工前 `git merge origin/staging`(不 rebase)。 +- BUG 编号:本分支的 BUG-1404 已被 #85 占用(#85 用 1404~1408,#90 将顺延),合入前 Claude 会统一重编;新增记录从开工时 `grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1` 的最大号 +1 起(该文件自 `400af57e8` 起整段重复,必须 `sort -u`)。 + +## 验收结论(Claude,Linux / Node 22) + +| 项 | 结论 | +| --- | --- | +| tsc / lint | 0 错;0 error(122 warning,同基线) | +| 前端全量 `npm test` | 5103 条,失败 25,名单与 staging `9812f3173` 基线逐名一致;新增 7 条 | +| build | `/` 仍 `○ Static`;首屏 gzip 644,589(基线 643,357,+0.19%) | +| 全量 pytest | 与基线一致(多出的一条 `test_active_rectification_api` 是子进程 20 秒超时,负载抖动,与本单无关) | +| 范围 | 没碰数据库、触发器、`page.tsx`、侧栏;没有删测试或弱化断言 | +| 「+ 添加一个人」满 20 人才隐藏(issue 写 5) | 判断正确:线上上限是 20(含本人)。已请产品负责人在 issue 里确认 | + +**未通过 3 项,见下。** + +## F1 · 所有报告表格都被强制单行(必修,打印退化) + +位置:`frontend/src/app/globals.css` 的 `.personal-report-table-wrap th, .personal-report-table-wrap td`(`white-space: nowrap; word-break: keep-all`)与 `table { width: max-content }`,以及 `@media print` 里同一组规则(`width: max-content !important`、`white-space: nowrap !important`,外层 `overflow: visible`)。 + +这些规则作用于**报告里的每一张 Markdown 表**,不只是行星表。瑜伽「作用说明」、主题「建议 / 说明」这类整句的表,屏幕上每格变成一条很长的横滑单行;打印时表格按内容撑宽、外层不裁切,宽表直接跑出 A4 纸。issue 要求「打印 / 导出的样式不能退化」,这里退化了(同类教训:BUG-153 打印测试须覆盖无滚动容器的宽表)。 + +修法: + +1. 只让**短数值 / 名称列**不断行。推荐:`report-table-reading.ts` 识别出行星位置这类数值表(按表头,如含「速度 / Speed / 度数」等),给该表加一个类(例如 `personal-report-table-numeric`),`nowrap` 与 `max-content` 只挂在这个类上;其他表恢复原来的自动换行。 +2. 打印:所有表 `width: 100%`、单元格可换行;数值表在打印里也允许换行(纸上没有横滑)。 +3. 测试:渲染一份含「行星原始位置」表和一张整句说明表的报告,断言数值表带新类、说明表不带;CSS 合同测试断言 `nowrap` 只出现在新类的选择器下,打印块里没有 `max-content` 与 `nowrap`。 + +## F2 · 「他人」保存逻辑是复制的,已经和档案页分叉(必修) + +位置:`frontend/src/components/chart-page/chart-birth-editor.tsx` 的保存函数,对他人直接 `fetch PUT /api/chart-profiles/:id`,与 `frontend/src/components/people/people-page.tsx` 的 `saveOther` 是两份代码。已经分叉:编辑器保存他人后清了星盘 / 星历缓存,档案页的 `saveOther` 没清——档案页改完别人,再去星盘页仍是旧盘。(本人一路已经共用 `lib/self-profile-save.ts`,是对的。) + +修法:把「保存他人」抽成 `frontend/src/lib/` 下的一个函数(请求、409 `reported_birth_time_is_immutable` 等错误映射、保存成功后的缓存清理都在里面),编辑器与档案页**都调它**。档案页因此也补上缓存清理,在 BUG 记录里写明这是顺带修复的旧缺陷。 + +测试:两处调用同一个函数(源码断言或注入替身);档案页保存他人后 `invalidateChartPage` / `invalidateEphemerisPage` 被调用。 + +## F3 · 点编辑框内边距会关掉编辑框、丢失未保存的修改(必修) + +位置:同文件 `` 里的 `if (event.target === event.currentTarget) onClose();`。`.chart-birth-dialog` 有 `padding: var(--space-5)`,点在内边距上、或在输入框里拖选文字松手落在内边距上,事件目标都是 dialog 本身,于是关闭并丢掉已填内容。 + +修法(二选一):内容包一层内部容器、内边距挪到容器上,dialog 自身不留可点的内边距;或按点击坐标判断是否落在 dialog 矩形之外才关闭。Esc 关闭保持现状(已正确)。 + +测试:模拟点击 dialog 内边距(目标是 dialog、坐标在矩形内)不关闭;点击遮罩(坐标在矩形外)关闭。 + +## 顺手改(小) + +- 测试文件 `frontend/tests/issue-86-chart-subject-table.test.tsx` 改名为按内容命名(例如 `chart-birth-editor-and-report-table.test.tsx`),仓库里没有按 issue 号命名的测试文件。 +- 他人锁定出生时间时,「填报的出生时间保存后不能再改」在表单说明里与锁定提示各出现一次,留一处即可。 + +## 交回前必跑 + +`tsc --noEmit`、`npm run lint`、改到的前端测试文件(含 `chart-page-view.test.tsx`、改名后的本单测试、档案页相关测试、CSS 合同测试)、`pytest tests/test_report_chart_blank_columns.py`。全量 `npm test`、build、gzip 由 Claude 验收时跑。 + +## 让步顺序 + +F1、F2、F3 都不可让步。F1 若「按表头识别数值表」不稳,可以退一步:`nowrap` 只给数字单元格(内容全是数字 / 度数 / 符号的格),整句单元格一律可换行;但打印那一条(`width:100%`、可换行)不让。 diff --git a/docs/tasks/TASK-full-data-parity-1-20261021.md b/docs/tasks/TASK-full-data-parity-1-20261021.md new file mode 100644 index 00000000..4d6a9568 --- /dev/null +++ b/docs/tasks/TASK-full-data-parity-1-20261021.md @@ -0,0 +1,203 @@ +# 任务书:完整数据版补齐(一)——年运、大运表与原始数据附录对齐上游交付版(agent 循环 issue #91,2026-10-21) + +## 基线 + +- 写作基线:`origin/staging` = `bd3ce0e7b`。**开工时以 #89(英文漏译,BUG-1272 复发)合入后的 `origin/staging` 为准**:#89 改的是同一组渲染文件(`scripts/pl9_full_data_export.py`、`scripts/pl9_reader_english*.py`),并带来 200 张固定种子虚构盘的英文汉字回归测试,本单复用它。#89 未合入不得开工。#90(导出弹窗)也动 `frontend/src/lib/report-export-*`,本单 T3 改同一组文件,与 #90 串行,后开工者先 `git merge origin/staging`。 +- 分支:`codex/full-data-parity-1-20261021`;工作树:`.worktrees/full-data-parity-1-20261021`。执行方 **Grok(Windows + Docker)**,只推这个分支。 +- 本单分 **A 段(只动渲染)、B 段(动引擎)**,同一分支、按第 6 节顺序提交(见第 5 节)。 +- 上游参照(只读,放仓库外): + - 交付版渲染:`https://github.com/732642856/yinduzhanxing.git` 分支 `codex/customer-export-frozen-20261016`(`1d549fe9`)的 `customer-export/registry/`。只读 `delivery_*.py`、`ashtakavarga_delivery.py`、`render_customer_snapshot.py` 的渲染函数。**不得复制** `subjects.json`、`customer_case.py`、`subject_guard.py`、`export_customer_bilingual.py`、`source_*` 审计文件;该分支 README 写明冻结快照含真实个案资料,冻结引擎本体未公开。 + - 计算模块与解释生成器:同仓库 `main`(`258e08e3`,10-18 起无新提交)。 +- 关联:BUG-1272、BUG-1275(真恒星口径)、BUG-1210(不回本地加减分)、BUG-1312(中文残留英文)、BUG-1393 / 1283(Niryaana Shoola)、BUG-1205(落陷取消不叫 Raja)、BUG-1282(年运卡 `sr_chart`);`TASK-report-full-data-fix-20261008`、`TASK-report-full-data-fix2-20261008`(F5 被本单推翻,见 D4);`TASK-rashi-dashas-native-20261008`;后续单 #92 `TASK-full-data-parity-2-20261021`、#93(独立年度方法参考)。 + +## 1. 现状实证 + +### 1.1 量测(Claude,Linux,屏蔽 PyJHora 模拟生产,`bd3ce0e7b`) + +虚构盘(1985 年,北京,非真人),走 `build_professional_report_reference_packet` → `render_full_data_markdown`,与线上同一路径: + +| 项 | 中文 | 英文 | +| --- | --- | --- | +| Markdown 字符数 | 411,454 | 820,922 | +| UTF-8 字节 | 846,885 | 821,647 | +| 渲染耗时 | 7~15 s(首次含导入) | 1~2 s | +| 排盘 + 资料包 | 3.3~7 s | 同一份 | + +另 8 张随机虚构盘(固定种子)只出中文,端到端每张 10.1~11.6 s。上游英文交付版(产品 10-21 上传,真实个人资料,只统计字符数,不入库)共 **4,687,328** 字符;本单范围约 0.9 M,#92 范围约 3.1 M。 + +### 1.2 小附录:74 节,不是 66 + +上游英文版在「Research High-Division Raw Data」之后,除 8 个大章外有 **74** 个二级标题、38.8 万字符(66 是把十来个大运附属节并进「大运附表」后的口径)。试跑方法:把我方资料包补上 `raw_full_reading.modules`(= 我方 full-reading 的 86 个模块),直接调用上游 registry 的渲染函数。上游读到的 35 个模块名我方全部存在。 + +| 类 | 含义 | 节数 | 本单处理 | +| --- | --- | --- | --- | +| A | 数据我方已算好,渲染器没出 | 60 | 做(T2) | +| B | 数据在,字段名或形状不同 | 6 | 做(T2) | +| C | 我方没算 | 5 | 做(T6) | +| X | 即 BUG-1210 删掉的本地加减分(Bhava Bala Relative Score Components / Score Extremes) | 2 | **不做**(D6) | +| E | Goel Jaimini Strength and Special-Point Evidence,源码不在归档 | 1 | **不做**(D7) | + +另:「Alternative Divisional Calculation Profiles」「Research High-Division Raw Data」(D81 / D108 / D144 等)**移到 #92**(D11)。 + +B 类明细: + +| 上游节 | 我方现状 | +| --- | --- | +| Navamsha Dasha AD Duration Contract | 我方 `navamsha_dasha.periods`,上游读 `major[].antardasha[].duration_months` | +| Current Dasha Convergence by Domain | 我方 `dasa_convergence.domain_activations`,上游读 `domain_evaluations` | +| Yoga Participants and Timing Links | 上游读 `advanced_systems.yoga_interpretation_pack.pages`;我方在渲染时由 `pl9_interpretation_packs.attach_full_data_interpretation` 挂上 | +| Bhava Chalit Planet Placements | 路径不同(`d1_rasi_bhava.bhava_chalit`) | +| Bhava Bala Three-Component Alternative | 我方正式三分量在 `modules.bhava_bala.houses`(BUG-1210 / 1385) | +| House Components and Physical Coordinates | 三分量同上;Kranti / 速度上游读 `kranti_local` / `speed_display_local`,我方 `pl9_p33_declination_contract` 在资料包里为空,先查 BUG-1199 修好的 Kranti 列取自哪里 | + +C 类明细: + +| 上游节 | 缺什么 | +| --- | --- | +| Jaimini Yogi and Ava Yogi | **潜在缺陷**:`scripts/jyotish_engine.py` 组装 jaimini 时 `from yogi_sphuta import build_yogi_sphuta_local`,我方**没有这个文件**,`ModuleNotFoundError` 被包成 `status: blocked`,每张盘都如此(上游 main 有,195 行,不导入 jhora) | +| Annual Return House Framework | `solar_return.annual_houses` 不产出 | +| Tripataki Chakra Detail | 只有 `solar_return.tri_pataka` 三旗摘要;上游 `tripataki_chakra.py`(108 行)出星座格与 vedha 行 | +| Natal / Annual Saham Formula Trace | `sahams.formula_traces` 不产出 | + +上游 `structural_markdown` 英文在我方数据上报 `Untranslated structural field: verdict_label`:我方有上游词表没覆盖的字段,必须补全中英字段名。 + +### 1.3 主章与大章 + +| 上游章节(字符) | 我方(字符) | 根因(按符号定位) | +| --- | --- | --- | +| Annual Varshaphala and Tajika(131,265) | 41,654 | 我方渲染器 `pl9_reader_export` 已会读 `monthly_chart_pack`、`lifetime_annual_chart_pages`、`annual_chart_series_pack`、`pl9_annual_dasha_results`、`normalized_tables`,但我方 `scripts/annual_tajika_pack.py`(780 行)**不产出这些键**,节被静默跳过。上游同名文件 2,788 行,多 40 个函数:`_monthly_chart_pack`(月返照=月段)、`_lifetime_annual_chart_pages`(年龄段)、`_annual_chart_series_pack` / `_local_eight_year_overview`、`_pl9_annual_dasha_results`、`_normalized_tables`、`_native_patyayini_dasha`、`_native_tithi_pravesh`(依赖 `tithi_pravesh.py`,236 行,我方没有)、`_annual_visual_panel` 等。`_normalize_pyjhora_*`、`external_engine_comparison` 来自 PyJHora 回放,不搬 | +| Annual Chart Numeric Data(189,395) | 试跑 1,075 | 依赖 `pl9_annual_visual_panels`,我方没有 | +| Standard Dasha Tables(127,598 / 1,792 表行) | 52,410 / 894 | `dasha_master_pack.families` 15 个体系**全部只有主运**(逐体系核对,子运 0 条)。Narayana、Ashtottari、`source_bounded_child_periods` 已有子运规则,没进 master pack。Kalachakra:我方 `scripts/kalachakra_dasha.py` 是旧版,只有主运;上游有 `_build_subperiods`、`_attach_recursive_subperiods` | +| Chara Dasha Period Durations(111,160) | 无 | A 类:`modules.jaimini.chara_dasha` 有 12 / 144 / 1,728 行,试跑 110,198 | +| Narayana Method Comparison(28,876) | 无 | B 类:`narayana_dasha.method_comparison` 在,上游函数报 `KeyError: 'direction'` | +| Three-Year Predictive Ephemeris(56,132) | 无 | `three_year_predictive_ephemeris_pack.months` 只有慢星,且从目标年 1 月起;`kp_monthly_report.months` 有 9 星但为 KP 口径。已有 `scripts/kp_monthly_transits.py` 可按报告口径逐月算 | +| Saturn and KP Tables(7,687) | 2,522 | 缺 Dhayya / Laghu Kalyani / Kantaka 结果段与「KP 原版细格」;数据 `sade_sati`、`kp` 已有 | +| Planets, Nakshatras and Houses(8,970) | 英 4,608 / 中 1,708 | `pl9_interpretation_packs.build_graha_interpretation_pack` 每星只出 6 句固定骨架(如「六维力量合计为未列出」——合计取不到);上游逐星有传统段落 | +| Bhavesh 宫主逐宫解释 | 附录 6,352(英) | `build_bhavesh_interpretation_pack` 同样只出骨架句 | +| Independent Annual Method Reference(16,004) | 无 | 内容是 PyJHora 回放(上游写明 `Source: PyJHora`)。**本单不做,另立 #93**(D5) | +| Goel Jaimini Strength(48,057) | 无 | 冻结引擎专有,**不做**(D7) | + +### 1.4 中文乱句、英文丢否定、空表与英文星座名 + +| 现象 | 位置 | 根因 | +| --- | --- | --- | +| 「时蛇煞: 第5宫条件7planet in」 | 附录「Dosha 与行运摘要」 | 源字段 `yogas_doshas.kaal_sarp_dosha.summary` 是中文「部分Kaal Sarp Dosha——5/7颗行星在轴线之间」。英文附录 `_render_pl9_ai_density_raw_data_markdown_en`(Dosha 表)原样印 `summary`,英文清理器删汉字后剩 `houses 5/7 condition planet in`;**中文附录不是从中文源字段出的**:`render_full_data_markdown` 中文分支把**英文附录**交给 `_sanitize_pl9_ai_density_markdown` 短词回译。原意「7 颗中 5 颗」变成「第5宫条件7」 | +| 「火星 in 12 巨蟹座, : low」 | 同表 Mangal 行 | 同一回路。**更严重**:无火星煞的盘源文「无Mangal Dosha(Mars不在煞宫)」,英文印 `Mangal Dosha(Mars in )`,中文印「火星婚姻煞(火星 in )」,**「无」丢了,读起来像有煞**。「Detected / phase」列取 `present` / `detected` / `status`,真实键是 `has_dosha` / `is_active`,整列空 | +| 「Raja瑜伽 … 火星 and 太阳 together in 第10」 | 附录「可用瑜伽」,及正文个别瑜伽组合句 | 同一回路;8 张随机虚构盘每张 31~46 行,265 行在「可用瑜伽」 | +| 回译之外的现成实现没用上 | `pl9_full_data_export.py` | `_render_pl9_ai_density_raw_data_markdown_zh` **从未被调用** | +| 「行星友敌关系矩阵」只有标题、没有表(中英都是) | 附录 | `_render_pl9_ai_density_raw_data_markdown_en` 遍历 `planetary_friendship` 的键当行星,实际结构是 `{status, method, rows: [...]}`,行全被跳过 | +| 中文导出里「第 1 宫 · Leo:…」 | 导出 Markdown 的「D1 北印度盘」「月亮参考盘」文字列表(排在最后一章「Dosha 与行运摘要」之后,所以产品看到的是这一章) | `frontend/src/lib/report-export-blocks.ts::houseLine` 中文分支直接用 `house.sign`;`toNorthIndianChart`(`report-chart-block.ts`)给出的是英文 `SIGNS`。引擎正文本身(图盘围栏外)9 张虚构盘英文星座名为 0 | + +## 2. 根因 + +1. 完整数据版只移植了上游 `pl9_ai_density` 主体(`23be1807`),没有移植上游交付版在主体之后追加的 registry 附录;我方年运资料包停在旧版,渲染器读的键没人产出,节被静默跳过。 +2. 中文附录用「英文附录 → 短词回译」生成,源字段本身是中文时两次转换丢字、丢否定;附录渲染对资料形状的假设与实际不符时(友敌矩阵)静默出空表。 +3. `yogi_sphuta.py` 漏搬,导入失败被包成 `blocked`,测试只看有没有这个键。 +4. 导出的图盘文字列表在中文版没有用中文星座名。 + +## 3. 决策记录(产品负责人 2026-10-21) + +| # | 决定 | +| --- | --- | +| D1 | issue 列出的各章补到与上游交付版同等展开,范围以本表为准 | +| D2 | **数值一律用我方引擎(BUG-1275 之后的真恒星口径),不搬上游视位置算法**;与上游数值不同的,在 PROGRESS 逐项列原因 | +| D3 | 中英两版都要有,**英文汉字为 0**(复用 #89 的 200 盘扫描) | +| D4 | **推翻 `TASK-report-full-data-fix2-20261008` F5**「Patyayini、Tripataki、月返照、八年总览、生命周期年度返照若我方无数据……不补算」,以及 `TASK-report-full-data-fix-20261008` 第 22 行「Patyayini 不补算」。本单用我方原生计算补上;PyJHora 回放部分仍不搬 | +| D5 | 「独立年度方法参考」**本单不做**,已另立 #93(照上游调用 PyJHora,排在本单之后);本单不写占位 | +| D6 | BUG-1210 对应的两节(Bhava Bala Relative Score Components / Score Extremes)**不做** | +| D7 | Goel Jaimini Strength and Special-Point Evidence **不做** | +| D8 | 年运:**当年 + 后两年,三年全部展开**,每一年都有:年盘与年主依据、Muntha、Mudda 与 Patyayini(原生)、月返照(月段)、Tithi Pravesha、Sahams(含公式轨迹)、Tajika 相位 / 瑜伽 / 力量 / Tripataki、年盘整宫框架、年度大运结果、年度图盘数值;年龄段(生命周期年返)与出生地八年总览各出一次,现居地总览只在有现居地时出 | +| D9 | 三年逐月星历:**从报告日期所在月起 36 个月**,报告口径 9 星;另列 KP 口径表与五级 Vimshottari 月采样 | +| D10 | 74 个小附录归为 **6 章**(第 6 节 T2 的分组表),中英文章节一一对应、顺序一致 | +| D11 | D81 / D108 / D144 研究型分盘与「其他分盘口径的计算数据」**移到 #92**,同样标研究口径;本单不动已有的分盘数值表 | +| D12 | 逐星解释与宫主逐宫解释补到上游篇幅,按 10-08 的写法:传统段落用「可能」口吻,叠加我方功能吉凶 v2 | +| D13 | Kalachakra 子运明细**移入本单**,标注「简化模型」(英文 Simplified model),不改现有主运 | +| D14 | **不设大小上限**,只记录字符数、字节数、生成时长;碰到前端等待(`ENGINE_TIMEOUT_MS` 180 s)或存储 / 接口硬上限才停下回报,不得截断 | +| D15 | 顺带修:空的行星友敌关系矩阵;中文附录改用现成的 `_render_pl9_ai_density_raw_data_markdown_zh`,不再回译;搬 Yogi 点模块 `yogi_sphuta`;中文导出图盘列表的英文星座名改中文 | + +## 4. 硬红线 + +1. **口径**:所有新经度一律经我方排盘函数取得(与 `tests/test_sidereal_nutation_frame.py` 同框架:Swiss Ephemeris 星历 + 真岁差,等价 `FLG_SIDEREAL`)。上游模块里的 `FLG_MOSEPH`、`FLG_TRUEPOS`、`longitude_profile` / `position_mode='apparent'`、交点偏移一律改走我方函数。新增测试:每个新计算模块在出生时刻取到的日、月经度与 `core_chart.planets.*.degree_raw` 差 ≤ 1e-6°。 +2. **PyJHora**:运行时不得导入 `jhora`(`tests/test_runtime_jhora_import_scan.py`);`*_pyjhora_*`、`external_engine_comparison`、`pyjhora_canonical_adapter.py`、`specialized_dasha_external_replay.py` 不搬。 +3. **增长冻结**:不改 `scripts/jyotish_api_server.py`(报告仍走 `/api/professional_report_reference`);`tests/test_api_server_growth_contract.py` 不放宽。 +4. **根目录文件数已满**:`scripts/*.py` 现为 448 个 = `tests/test_python_hygiene_budget.py` 的 `ROOT_PY_BUDGET`。新渲染代码进 `scripts/report_render/`;新计算模块(`yogi_sphuta`、`tithi_pravesh`、`tripataki_chakra` 等)进新子包(例如 `scripts/annual_native/`),引擎里的导入跟着改。不得调高预算。开工时若数字已降,仍优先放子包。 +5. **不新增静默吞错**:`SILENT_EXCEPT_BUDGET = 1` 只给 `classical_astrology.py`。失败记日志并带具体原因,不得把 `ImportError` 包成 `blocked` 了事。 +6. **引擎输出会变**:验收必须**全量 pytest 与开工基线逐条比对**;改既有断言写「原值 / 新值 / 原因」三栏。 +7. **我方报告口径全部保持**:`_apply_local_report_conventions`、BUG-1199~1229、BUG-1205、BUG-1210、BUG-1282、BUG-1312 卫生门、BUG-1393。搬来的模板逐条核:不断言死亡、寿命、疾病诊断、离婚、流产、不孕;不写「再婚 / 几段关系 / 早夭」;吉凶以我方功能吉凶 v2 为准;主题写「可能突出」,不写必然。 +8. **中英各自从源字段生成**:中文不得由英文回译,英文不得靠删汉字;每个字段名、单位、枚举值都有中英两套标签,缺标签测试失败,不得印原始键名。 +9. **对话与校正不受影响**:`read_full_data_domain` 经卡片选择后的对话卡对 3 张固定虚构盘逐字节不变;生时校正打分 golden 不变。 +10. **不截断(D14)**:没有大小上限,但碰到 180 s 前端等待或存储 / 接口报错,就停下推分支、PROGRESS 写实测,不得删行、抽样或降精度。 +11. 不动数据库结构与迁移;不改 `.gitea/`;不推 `main`。 +12. **隐私**:只用虚构盘与公开名人盘;不复制上游个案文件;PROGRESS 与 Bug 历史实测段只写「虚构盘」。 + +## 5. 分段与提交顺序(一个分支) + +- **A 段(只动渲染与前端导出,引擎数值不变)**:T1 → T2 → T3,一条任务一个提交。做完推分支,PROGRESS 标「A 段完成」,Claude 验收后合入 staging。 +- **B 段(动引擎)**:先 `git merge origin/staging`(不 rebase 已验收的提交),再 T4 → T5 → T6 → T7 → T8 → T9 → T10,一条任务一个提交。 + +## 6. 任务分解 + +| # | 段 | 内容 | 验收标准 | +| --- | --- | --- | --- | +| T0 | A | 盘点:上游 82 个二级标题(74 小节 + 8 大章)及标准大运表、Saturn/KP、年运章、逐星与宫主解释的三级标题,逐一对应我方「已有 / A / B / C / X / E / 移 #92 / 移 #93」与源字段路径,核对第 1 节分类 | PROGRESS 一张表;与本任务书不同的行单列并写原因 | +| T1 | A | D15 的渲染部分:中文附录改用 `_render_pl9_ai_density_raw_data_markdown_zh`(补齐到与英文附录同节同序),删去回译路径;Dosha 摘要、可用瑜伽表中英文都从**结构化字段**出(`has_dosha` / `type` / `rahu_house` / `ketu_house` / `is_active` / `phase` 等),没有就写「未判定 / Not determined」;正文瑜伽组合句中文取中文源;行星友敌矩阵读 `rows`(天然友 / 敌 / 中性、临时、复合)。中文卫生门加两条:图盘围栏外英文星座名为 0;汉字紧邻 `in / and / or / from / planet / lord` 以及 `, :`、`in )` 为 0 | 200 盘扫描:中文两条新门均为 0,英文汉字 0;无火星煞的盘中英都写「无 / No」;部分时蛇煞的盘写出「7 颗中 5 颗」或等价英文;友敌矩阵 9 行非空;BUG_HISTORY 新编号 | +| T2 | A | 小附录 71 节(A 类 60 + B 类 6 + C 类 5 的渲染位,C 类数据到 T6 才有)+ Chara 时长 + Narayana 对照 + Bhrigu 推进序列,按我方字段重写渲染(`scripts/report_render/`),按 D10 归 6 章,见下方分组表。C 类在 T6 之前不出现,不写占位 | 3 张固定虚构盘中英文:6 章标题中英一一对应、顺序相同;A、B 类每节出现且非空,每节一个结构测试(表头 + 至少一行);字段标签全覆盖测试;Narayana 两种口径分列不拼接 | +| T3 | A | D15 前端:`report-export-blocks.ts::houseLine` 中文用中文星座名(`SIGN_ZH`),英文不变;顺查导出与阅读页其他中文出口有无同类问题 | `report-export-blocks.test.ts` 加断言:中文图盘列表不含任何英文星座名,英文含;`tsc`、`lint` 过;PROGRESS 记 A 段生成时长与中英大小 | +| — | — | **A 段交付点**:推分支,PROGRESS 标明 | — | +| T4 | B | 搬 `yogi_sphuta` 进子包并接线(修潜在缺陷) | 虚构盘 `jaimini.yogi_avayogi_local.status` 不是 `blocked`;红线 1 经度测试;「Jaimini Yogi and Ava Yogi」中英出现;BUG_HISTORY 新编号 | +| T5 | B | 标准大运表全量:凡我方模块已有出处子运规则的体系(Narayana、Ashtottari、`source_bounded_child_periods` 覆盖的体系)把子运写进 `dasha_master_pack` 并出表;**Kalachakra 子运与次小运**(D13)搬上游 `_build_subperiods` / `_attach_recursive_subperiods`,挂在我方现有主运上,表头与说明标「简化模型 / Simplified model」;若离不开上游新版主运就停下回报,不换主运。没有出处规则的体系写「本体系未提供子运」。Ashtottari 的起运表与适用口径和 PyJHora 不一致(#88 写单时查出,另立单处理),本单只把引擎现有子运原样出表,不改其起运与主运 | 各体系主运逐行不变;子运条数写进 PROGRESS;Vimshottari 三层不变;Kalachakra 表含「简化模型」 | +| T6 | B | C 类:搬 `tripataki_chakra`、本命与年度 Saham `formula_traces`、年盘整宫 `annual_houses`,进子包;T2 预留的四节出表 | 红线 1 经度测试;四节中英出现 | +| T7 | B | 年运(D4、D8):把上游 `annual_tajika_pack.py` 缺的原生部分搬进我方(月返照、生命周期年返、八年总览、年度大运结果、`normalized_tables` 原生行、原生 Patyayini、原生 Tithi Pravesha 连带 `tithi_pravesh.py`、`pl9_annual_visual_panels`);当年 + 后两年每年按 D8 清单全部展开;出「年度图盘数值数据」 | 三年每年 D8 各项中英文都出现(缺项必须在 PROGRESS 写原因);已有年运字段(年主、Muntha、Mudda、Sahams、Tajika)对 3 张虚构盘逐值不变,只多新键;对话年运卡不变 | +| T8 | B | 三年逐月星历(D9):用 `kp_monthly_transits` 按报告口径算报告月起 36 个月 × 9 星,另列 KP 口径表与五级 Vimshottari 月采样 | 每个口径 36 × 9 行,首月 = 报告日期所在月;任取一采样点月亮经度与我方排盘函数同刻结果差 ≤ 1e-6° | +| T9 | B | 解释补长(D12):`build_graha_interpretation_pack`、`build_bhavesh_interpretation_pack` 按上游 main 对应生成器补传统段落,「可能」口吻,叠加功能吉凶 v2;修「六维力量合计为未列出」;Saturn / KP 表补 Dhayya、Laghu Kalyani、Kantaka 结果段与 KP 细格 | 「行星、星宿与宫位解释」与宫主逐宫解释中英文字数不低于上游同盘同章(英文按上游 8,970 字符量级,中文按比例),PROGRESS 列逐章字数;红线 7 模板口径测试(中英全文) | +| T10 | B | 量测与文档(D14):Linux(Claude 复测)与 Windows 生成时长、中英 Markdown 字符与字节、快照 JSON 大小、`.md` 导出大小、阅读页章数与最大一章;与上游数值不同的项列表并写原因(预期:真恒星 vs 视位置、Swiss vs Moshier)。BUG_HISTORY、CHANGELOG、PROGRESS、`docs/testing/full-data-parity-1-20261021.md`(真机:生成新报告、中英切换、逐章翻到底、导出中文 `.md` 检查图盘列表、旧报告仍能打开) | 文档齐;中英合计生成在 180 s 以内,否则按红线 10 停下 | + +T2 分组表(中英一一对应,章内按此顺序): + +| 章 | 中文标题 | 英文标题 | 包含 | +| --- | --- | --- | --- | +| 1 | 大运附表 | Dasha Appendix | Vimshottari Birth-Balance、Kalachakra Dasha(主运层)、Yogini 周期、Tribhagi Profile、Tribhagi 周期、Navamsha AD Duration、Nakshatra Dasha Context、Bhrigu Cycle、Bhrigu Approximate Progression、Conditional Dasha Applicability、Dasha Boundary Scan、Current Dasha Convergence、Chara Dasha Period Durations、Narayana Method Comparison | +| 2 | 年运附表 | Annual Appendix | Annual Return House Framework、Annual Return Calculation Contract、Tajika Aspect and Yoga Candidates、Tripataki Chakra Detail、Annual Tajika Strength、Annual Saham Formula Trace、Annual Chart Numeric Data(T7) | +| 3 | 力量与八分法 | Strength and Ashtakavarga | Shadbala Subcomponent、Shadbala Result、House Components and Physical Coordinates、Bhava Bala Three-Component、Vimsopaka Contribution、AV Contribution and Reduction、AV Yoga Pinda、AV Sign Assessments、Prastara Totals、Avastha Inputs、D30 States、D30 Source Input、Pushkara Basis、Lagna Vargottamamsa | +| 4 | 关系、相位与瑜伽 | Relationships, Aspects and Yogas | Neutral and Compound Relationships、Relationship Conventions、Dispositor Terminals、Dispositor Chain、Aspect Geometry、Argala、Congregation、Bhrigu Relationship Geometry、Yoga Participants、Canonical Yoga Rows、Whole-Sign Occupants | +| 5 | 星宿、特殊点与行运 | Nakshatras, Special Points and Transits | Nakshatra Reference、Tara-Chandra Matrix、Natal Tithi Lord、Birth Calendar、Pancha Pakshi、Natal Vivah Saham、Natal Saham Formula Basis、Natal Saham Formula Trace、Arudha / UL / A10、Jaimini Calculation Details、Graha Pada、Yogi and Ava Yogi(T4)、Karakamsha、Darakaraka、Rashi Tulya Navamsa、Moon Chart Reference、Sudarshana Points、Dated Transit、Multi-Reference Transit、Double Transit PAC、Lagna / 7th Lord Transit、三年逐月星历(T8) | +| 6 | 计算口径 | Calculation Conventions | Natal Calculation Conventions、KP Calculation Conventions、D9 Dignity、Divisional Mapping、Bhava Chalit Planet Placements、Bhava Chalit Centres、Bhava Chalit Convention | + +合计 71 个小节(74 − X 2 − E 1)+ Chara、Narayana 两个大章 + 两项新数据。 + +## 7. 验收分工 + +| 检查 | Grok 自跑 | Claude 验收 | +| --- | --- | --- | +| 改到的与新增的 Python 测试文件(至少:`test_report_english_edition`、`test_report_full_data_fix`、`test_full_data_domain_reader`、`test_python_hygiene_budget`、`test_api_server_growth_contract`、`test_runtime_jhora_import_scan`、`test_sidereal_nutation_frame`、`test_rashi_dashas_native`、`test_bhava_bala_formal`、`test_year_lord_*`)及 #89 的 200 盘扫描 | 必跑(工作树 `.venv`),屏蔽 PyJHora 再跑一次 | 复验 | +| `tsc --noEmit`、`npm run lint`、改到的前端测试文件(`report-export-blocks.test.ts` 等) | 必跑(T3 动了前端) | 复验 | +| Python 快速门 `run_quality_gate.py --profile quick` | 不跑 | 以 Claude 为准 | +| **Python 全量 pytest 与开工基线逐条比对**(A、B 段各一次) | 不跑 | 以 Claude 为准 | +| 前端全量 `npm test`、`npm run build`(`/` 仍 Static,首屏 gzip ±2%) | 不跑 | 以 Claude 为准 | +| `test:db` | 不动数据库,不要求 | — | + +## 8. 让步顺序 + +不让步:T1、T3、红线全部、英文汉字 0、对话与校正不变。时间不够时依次让出:T9 的 Saturn / KP 结果段 → T8 → T5 的 Kalachakra 次小运 → T7 的生命周期年返与八年总览。让出的写进 PROGRESS 留给下一单。A 段不等 B 段。 + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git log origin/staging --oneline | grep -iE "#89|han_leak|1272" | head # 确认 #89 已合入,否则不开工 +git worktree add -b codex/full-data-parity-1-20261021 .worktrees/full-data-parity-1-20261021 origin/staging +cd .worktrees/full-data-parity-1-20261021 +# 上游参照放仓库外,只读 +git clone --filter=blob:none https://github.com/732642856/yinduzhanxing.git ../yinduzhanxing-ref +git -C ../yinduzhanxing-ref fetch origin codex/customer-export-frozen-20261016 +git -C ../yinduzhanxing-ref ls-tree --name-only 1d549fe9 customer-export/registry/ +ls scripts/*.py | wc -l # 448 = ROOT_PY_BUDGET +grep -n "yogi_sphuta" scripts/jyotish_engine.py +grep -n "_render_pl9_ai_density_raw_data_markdown_zh\|Planetary Friendship Matrix" scripts/pl9_full_data_export.py +grep -n "function houseLine" -A4 frontend/src/lib/report-export-blocks.ts +grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1 +``` + +## 10. BUG 编号 + +写作时 `docs/BUG_HISTORY.md` 最大号 BUG-1403。文件里有重复段落,按上面命令 `sort -u` 后取最大号,**开工时最大号 + 1 起**(#88~#90 可能先占号)。预计至少三条:中文附录乱句与英文丢否定(T1,含空友敌矩阵);中文导出图盘列表英文星座名(T3);`yogi_sphuta` 漏搬致 Yogi / Ava Yogi 恒为 blocked(T4)。 diff --git a/docs/tasks/TASK-full-data-parity-3-20261022.md b/docs/tasks/TASK-full-data-parity-3-20261022.md new file mode 100644 index 00000000..72fb4e9b --- /dev/null +++ b/docs/tasks/TASK-full-data-parity-3-20261022.md @@ -0,0 +1,198 @@ +# 任务书:完整数据版补齐(三)——「独立年度方法参考」照上游调用 PyJHora,其余输出一字不变(agent 循环 issue #93,2026-10-22) + +## 基线 + +- 写作基线:`origin/staging` = `f90020efd`(#91 任务书刚推上去,#91 代码未合入)。**开工以 #91 合入后的 `origin/staging` 为准**;#91 未合入不得开工。#91、#92、#93 改同一批渲染文件(`scripts/pl9_full_data_export.py`、`scripts/report_render/`):#92 若已开工,后开工的一方先 `git merge origin/staging`,在 PROGRESS 写明依赖的 staging SHA。 +- 分支:`codex/full-data-parity-3-20261022`;工作树:`.worktrees/full-data-parity-3-20261022`。执行方 **Grok(Windows + Docker)**,只推这个分支。 +- 上游参照(只读,放仓库外,同 #91):`https://github.com/732642856/yinduzhanxing.git`,分支 `codex/customer-export-frozen-20261016`(`1d549fe9`): + - 渲染:`customer-export/registry/delivery_timing_supplement.py` 的 `_annual_observation_lines`、`_annual_anchor_label`、`annual_method_observations`; + - 调用:`scripts/annual_pyjhora_replay.py` 的 `_isolated_annual_worker`、`_call_mudda`、`_call_mudda_with_annual_return_moon`、`_call_patyayini`、`_pyjhora_annual_return_jd`、`_probe_tajika_yogas`、`_probe_pyjhora_per_yoga_callables`、`_annual_return_marker`; + - 整理:`scripts/annual_tajika_pack.py` 的 `_normalize_mudda_replay_rows`、`_normalize_patyayini_replay_rows`、`_normalize_pyjhora_tajika_yoga_rows`、`_pyjhora_dasha_lord_label`、`PYJHORA_DASHA_LORDS`、`_PYJHORA_TAJIKA_YOGA_NAMES`。 + - **不得复制** `subjects.json`、`customer_case.py`、`subject_guard.py`、`export_customer_bilingual.py`、`source_*` 审计文件(含真实个案资料)。 +- 关联:BUG-1273(本单推翻其「PyJHora 不进生产」约定,见 D1)、BUG-1265、BUG-1275(真恒星口径)、BUG-1272 / #89(英文汉字 0)、ERR-110(冻结身份);`TASK-full-data-parity-1-20261021`(#91,D5 把本节移到本单)、`TASK-full-data-parity-2-20261021`(#92)、`TASK-api-chart-subprocess-gate-20261013`(#64,`_jhora_importable` 短路与 `sitecustomize` 挡导入法)、`TASK-special-lagnas-native-20261008`。 + +## 1. 现状实证 + +### 1.1 上游这一节怎么来的 + +`_annual_observation_lines` 本身不调 PyJHora,只读资料包里 `annual_tajika_pack.external_engine_comparison.pyjhora`(以及 `annual_tajika_series.years` 里另外两年的同名字段)。这些字段由上游 `jyotish_engine._build_pl9_full_annual_section` → `annual_pyjhora_replay.replay_pyjhora_annual` → `_run_isolated_annual_replay` **起子进程**(`sys.executable -c "...from scripts.annual_pyjhora_replay import _isolated_annual_worker..."`,超时 30 s)产出。子进程里: + +| 步骤 | 调用(PyJHora 4.8.7) | 参数 | +| --- | --- | --- | +| 设岁差 | `drik.set_ayanamsa_mode(ayanamsa.upper())`,再回读 `const._DEFAULT_AYANAMSA_MODE`,不一致整节 blocked | 资料包 `settings.ayanamsa` | +| 地点 | `drik.Place(name, lat, lon, tz, elevation)` | 出生地经纬度、出生时区 | +| Mudda(出生时月亮) | `mudda.varsha_vimsottari_dasha_start_date(jd, place, age)`、`mudda.mudda_dhasa_bhukthi(jd, place, age)` | `jd` = 出生 JD,`age` = 目标年 − 出生年 | +| Mudda(年盘月亮) | `tajaka.annual_chart(jd, place, years=age)` 取回归时刻 → `utils.julian_day_number(...)` → `mudda_dhasa_bhukthi(annual_jd, place, 0)` | 同上 | +| Patyayini | `_pyjhora_annual_return_jd`(同一个 `annual_chart`)→ `patyayini.get_dhasa_bhukthi(annual_jd, place)` | 同上;取不到回归时刻退回 `jd + age × 365.256364` | +| Tajika 瑜伽 | `tajaka.annual_chart(jd, place, years=age)[0]` 当位置表,逐个调 `tajaka_yoga.get_ithasala / eesarpha / manahoo / kamboola / gairi_kamboola / khallasara / radda _yoga_planet_pairs`、`get_nakta / yamaya _yoga_planet_triples` | 年盘位置 | + +整理后三张表的列:Mudda `order, main_lord, sub_lord, start_display, duration_raw`(天,无结束边界,时区未定);Patyayini `order, main_code, sub_code, start_display, end_display, duration_raw`(年;结束 = 下一段开始,末段未知);Tajika `yoga, observation_status, raw_relationships`(行星编号 JSON)。表头写 `Source: PyJHora`,并印两条实际回归锚点(Patyayini、Tajika)。 + +### 1.2 我方现状(`f90020efd`,Linux,本机装有 PyJHora 4.8.7 + pyswisseph 2.10.3.2) + +- 我方已有旧版 `scripts/annual_pyjhora_replay.py`(1,035 行,`336286e42` 起加了 `_jhora_importable()` 短路:找不到 `jhora` 就不起子进程,直接返回与子进程失败时相同的字典,golden 在 `tests/fixtures/api-chart-subprocess-gate/annual-no-jhora.json`、`tajaka-no-jhora.json`)。它比上游旧:`_call_mudda` 没有「年盘月亮」口径,`_call_patyayini` 没有精确回归锚点,`_probe_tajika_yogas` 没有逐个瑜伽的调用。 +- **它已经接在每次全盘上**:`scripts/jyotish_engine.py::_build_pl9_full_annual_section` 每年调一次 `replay_pyjhora_annual`,`_attach_annual_tajika_pack` 对目标年 + 后两年各调一次。生产镜像没有 PyJHora,所以今天全部短路成 blocked。**一旦镜像装上 PyJHora,这条旧路会真跑,结果写进 `annual_tajika_pack.external_engine_comparison.pyjhora`,被下列现有渲染读出来**(全部按符号定位): + - `jyotish_engine._build_pl9_professional_support_section`(`patyayini_replay`); + - `jyotish_engine.render_pl9_markdown` 内 `_year_replay_subblocks`(替代 Patyayini 行、Sahams 值)、年运小节的 `replay_patyayini_*` / `replay_sahams*`、Yogi 行的 `pyjhora_behavior_only` 分支、Patyayini 状态行; + - `annual_tajika_pack._external_engine_comparison` / `_audit`; + - `pl9_reader_export` 读 `normalized_tables.pyjhora_tajika_yoga_rows` 的两处。 + 这就是红线 1 的头号风险:不关掉,装包本身就会改年运章、附录和资料包。 +- 进程全局状态实测(同一进程内):先按我方 Raman 设好 `swe.set_sid_mode`,调一次 `drik.set_ayanamsa_mode("LAHIRI")` + `mudda.mudda_dhasa_bhukthi` 之后,同一 JD 的岁差从 22.207588° 变成 23.653890°,月亮恒星经度从 23.718016° 变成 22.271715°(差 1.45°)。另外 `import jhora.const` 在导入时就执行 `swe.set_ephe_path(/data/ephe)`(该目录只有 txt 星表,没有 `.se1`);`drik.set_ayanamsa_mode` 还会改写 `const._DEFAULT_AYANAMSA_MODE`(PyJHora 默认 `TRUE_PUSHYA`);交点默认真交点(`const._use_true_nodes_for_rahu_ketu = True`,我方默认平交点)。**PyJHora 只能在子进程里跑,不得在 API 进程内导入。** +- 子进程实测(虚构盘 1985 年北京,Raman,三年一个子进程,按上游调用次数):导入 0.3~0.6 s,每年 1.9~2.3 s,合计约 **7 s**,峰值 RSS 约 **60 MB**。生产 2 核,预计 10~14 s,由 T6 实测。 +- 上游两处输入口径与 PyJHora 文档不符(实测,见 D6): + 1. 上游传入的 `jd` 是 **UT** JD(`swe.julday(..., hour − tz)`),PyJHora 约定是**当地时** JD(`drik` 内部 `jd_utc = jd − place.timezone/24`,`annual_chart` 文档也这样写)。同一虚构盘(东八区):回归时刻 08:34:53 vs 16:35:04,正好差 8 小时;Mudda 首段起点差约 8.5 天。我方 `tajika.py` 的 PyJHora sidecar 已按当地时传(`annual_jd + tz/24`)。 + 2. `years=age` 时 `annual_chart`(Patyayini、Tajika 用它)落在**目标年前一年**(请求 2026 返回 2025-06-15),Mudda 却落在 2026。上游没修,只在表头写「请求年份不等于实际返回年份」。`years=age+1` 才落在目标年。 +- PyJHora 4.8.7 自身问题:`get_radda_yoga_planet_pairs` 抛 `NameError`;`gairi_kamboola`、`khallasara` 返回 `None`。上游把它们分别印成「未获得结果」「未知」。 + +### 1.3 运行时会因「装了 PyJHora」而换路的地方(全部要保持今天的结果) + +| # | 位置(按符号) | 今天(生产无 PyJHora) | 装上后若不改 | 处理 | +| --- | --- | --- | --- | --- | +| S1 | `annual_pyjhora_replay._jhora_importable` → `_run_isolated_annual_replay`、`_run_isolated_tajaka_year_lord`(经 `replay_pyjhora_annual`、`probe_pyjhora_year_lord`,由 `jyotish_engine._build_pl9_full_annual_section` 每年调用) | 短路,返回 golden | 每年起 2 个子进程、旧口径数据进年运章(见 1.2) | 产品路径一律走短路,返回与今天逐字节相同的 golden;只有研究脚本显式传参才起子进程 | +| S2 | `unified_consultation_orchestrator._external_engine_cross_validation` → `diagnose_pyjhora_adapter.build_report()` | `find_spec` 为空,`missing_dependency` | **在 API 进程内** `import_module("jhora.utils" / ".horoscope.chart.charts" / ".panchanga.drik")`(导入即改星历路径);`dependency_available`、`missing_dependency` 变;仓库里 `benchmarks/.../run_pyjhora_compare.py` 存在时(测试环境)`status` 从 `missing_dependency` 变 `available` | 产品调用改为不探测、不导入,返回今天的「缺依赖」字典;`build_report()` 默认行为留给命令行诊断 | +| S3 | `rectification_three_engine_packet._pyjhora_d1`(`/api/rectification…` 高严谨分支 `build_packet`) | `ModuleNotFoundError`,该引擎行为错误 | **在 API 进程内**导入 `pyjhora_rectification_d1_reference` → `jhora`,三引擎包多出 PyJHora 星座行 | 产品路径固定返回今天的错误行,不导入;不改 `jyotish_api_server.py` | +| S4 | `tajika._external_pyjhora_panchavargiya_score`(进程内 `pyjhora_panchavargiya_reference`,失败再走 `_lookup_external_pyjhora_python` 外部解释器) | 只在 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1` 时由 `tajika_year_lord._select` 传入 `annual_jd`,否则不触发 | 不变(仍由环境变量控制);但镜像里 `python3` ≠ `sys.executable` 的字面路径,变量一旦被设就会真跑 | 不改代码;加测试:部署文件(`deploy/**`)不得设置该变量;变量未设时装了 PyJHora 年主结果不变 | +| S5 | `jyotish_engine` 里 `from pyjhora_canonical_adapter import …`(Chara `pyjhora_blackbox` 口径)、`from pyjhora_yogi_replay import replay_pyjhora_yogi`(Yogi 外部回放) | 两个模块仓库里都**不存在**,走桩函数 / blocked | 不变 | 不得新增这两个模块(红线) | +| S6 | `ephemeris_backend_probe._python_module_status("jhora")`、`diagnose_external_engine_adapters`、`scripts/_compute_one_chart.py` | 开发诊断、离线基准工具 | 输出变(显示可用) | 不在产品路径(T1 核实 `ephemeris_backend_probe` 只被 `run_quality_gate.py`、测试引用),不改;PROGRESS 记一句 | +| S7 | 测试:`test_ashtakavarga_sodhita_chain.py::test_trikona_step_matches_pyjhora_when_installed`、`test_dasha_api_engine_parity.py` 里经 `_pyjhora_rows`(`pytest.importorskip("jhora")`)的用例 | CI `.venv` 无 PyJHora,跳过 | 门禁 `.venv` 装 `requirements.txt` 后会真跑 | 允许「跳过 → 通过」,必须通过;列进 PROGRESS | + +`tests/test_runtime_jhora_import_scan.py`(BUG-1273,快速门内)按 AST 从全盘、报告、对话卡入口走导入图,**文件名含 `pyjhora` 的模块和 `scripts/research` 不进图**。S2、S3 不在它的入口里,所以今天没被拦。 + +### 1.4 依赖 + +| 项 | 实测 / 现状 | +| --- | --- | +| 上游固定版本 | `containers/pyjhora-observation/Dockerfile`:`PyJHora==4.8.7`、`pyswisseph==2.10.3.2`;`requirements-dev.txt`:`PyJHora>=4.8` | +| 我方 | `requirements-reference-engines.txt` 已写 `PyJHora==4.8.7`、`geocoder==1.38.1`、`geopy==2.4.1`,注释写「AGPL,不得并入生产 requirements」;`requirements.txt` 里 `pyswisseph` **不带版本号** | +| PyJHora 4.8.7 元数据 | `Requires-Dist` 为空(pip 不会因为它动 `pyswisseph`);但 `jhora.utils` 导入 `geocoder`、`geopy`、`timezonefinder`、`numpy`、`pytz`、`dateutil`、`requests`。分类器写 MIT,包内 LICENSE 是 AGPL-3.0 | +| 安装体积 | 约 94~97 MB:`data/` 56 MB(`geonames_places_5k_IN.csv` 37 MB、`geonames_places_5k.csv` 7 MB、`data/ephe` 10 MB txt)、`images/` 15 MB、`horoscope/` 9 MB、`tests/` 5 MB、`lang/` 5 MB | +| 镜像 | `deploy/railway-api.Dockerfile` 只 `pip install -r requirements.txt`,不需要改 Dockerfile;`requirements*.txt` 在 `deploy/gated-paths.txt` 内,推上去会触发门禁、构建新镜像 | +| 门禁 `.venv` | `.gitea/workflows/backend-quality-gate.yml` 安装 `requirements.txt` + `requirements-dev.txt` → 合入后门禁环境也有 PyJHora(S7)。**不改 workflow** | + +### 1.5 渲染出口的现有拦截 + +- `pl9_full_data_export._sanitize_pl9_ai_density_markdown_en` 与 `pl9_reader_english.render_pl9_user_markdown_en` 的 `drop_fragments` 含 `"PyJHora"`:**任何含 PyJHora 的行会被整行删掉**。 +- `tests/test_report_reader_main.py::LEAK_PATTERNS` 含 `PyJHora`、`\bparity\b`、`_unavailable`、`\bschema\b`、`\bsource_path\b`;`test_reader_main_hides_audit_vocabulary` 对完整数据版全文断言零命中。 +- 所以本节必须在两个清理器和 `_apply_local_report_conventions` **之后**拼接,并且不得印出任何内部失败原因、键名。 + +## 2. 根因 / 背景 + +1. 上游交付版「独立年度方法参考」是 PyJHora 子进程回放的产物;我方 BUG-1273 起约定 PyJHora 只作离线对照,生产镜像不装,所以这节在我方一直缺。 +2. 我方代码里早已散落「检测到 PyJHora 就换路」的开关(1.3 的 S1~S5),以前靠「镜像里没有」兜底。装包之后这些兜底全部失效,其中 S1~S3 会在 API 进程内导入 PyJHora 或把旧口径数据印进现有章节。 +3. PyJHora 改的是 Swiss Ephemeris 的**进程级全局状态**(岁差模式、星历路径),与我方引擎共用一个 `swisseph` 模块,进程内调用必然串味。 + +## 3. 决策记录 + +| # | 决定 | 来源 | +| --- | --- | --- | +| D1 | **推翻** BUG-1273 / `TASK-special-lagnas-native-20261008` 的约定「PyJHora 只作外部对照、不进生产」:正式环境安装 PyJHora,**仅**用于本节。AGPL 许可证风险由产品负责人承担,已知情。`requirements-reference-engines.txt` 与 `diagnose_pyjhora_adapter` 里「不得进生产」的文字随之过时:前者注释改写;后者属于对话输出,按红线 1 不改 | 产品 10-21 | +| D2 | 照上游 `_annual_observation_lines` 出三张表:Mudda(各起点口径,即出生时月亮 / 年盘月亮)、Patyayini、Tajika 瑜伽;表头注明「来源:PyJHora」;与前文我方年运分开读,不替换、不合并、不比对 | 产品 10-21 | +| D3 | 中英两版都要有;英文汉字为 0(#89 的扫描);中文用中文标签(上游中文版列名是英文,不照搬) | 产品 10-21 + 本单 | +| D4 | 某张表调用失败(含 PyJHora 不可用、超时、岁差不支持),该表写「未获得结果 / Result unavailable」,报告照常生成;其他表照常出 | 产品 10-21 | +| D5 | 年份范围:与上游 `annual_method_observations` 一致,目标年 + 后两年(与 #91 D8 的三年对应),每年一组三张表 | 照上游 | +| D6 | **输入口径(待产品确认,默认如下,Grok 可按默认开工)**:上游两处与 PyJHora 文档不符(1.2)。默认:`jd` 传**当地时** JD(PyJHora 文档口径,与我方 `tajika.py` sidecar 一致);`annual_chart` 用 `years = age + 1`,让 Patyayini、Tajika 的回归锚点落在请求年,Mudda 仍按上游 `years = age`(其起点本来就落在请求年)。实际锚点照上游印出。若产品要求与上游逐字一致,改回 UT JD 与 `years = age`,其余不变。两种做法的差异(回归时刻、首段起点)在 PROGRESS 用 3 张虚构盘列表 | 本单(Claude) | +| D7 | 岁差:按资料包 `settings.ayanamsa` 映射到 PyJHora 名称(lahiri→LAHIRI、raman→RAMAN、kp / krishnamurti→KP、fagan_bradley→FAGAN、true_citra→TRUE_CITRA、true_pushya→TRUE_PUSHYA);djwhal_khul、sassanian 等 PyJHora 不支持的写三张表「未获得结果」,**不得**让 PyJHora 静默退回它的默认 TRUE_PUSHYA。设置后回读 `const._DEFAULT_AYANAMSA_MODE` 核对 | 照上游 + 本单 | +| D8 | 交点:上游不设(标「适配器不支持」)。本单在子进程里按资料包 `node_mode` 调 `const.set_node_mode(node_mode == "true")`,**在导入 `drik` 之前**设,并在子进程里用一颗交点经度回读核对;核对不过就不设,表头写「交点:PyJHora 默认(真交点)」 | 本单 | +| D9 | 我方 BUG-1275 的真恒星口径不套用到 PyJHora:本节数值就是 PyJHora 自己算的,不与我方年运比对、不标一致 / 不一致 | 本单 | +| D10 | 位置:完整数据版最后一个二级标题章(在 #91 的六章附录与「结构化资料附录 / Structured Data Appendix」之后),与上游「正文之后追加」一致;只出现在完整数据版,标准版不出 | 本单 | +| D11 | 生时校正冻结身份、对话卡、标准版报告、星盘接口不碰本节,也不因装包变化 | 产品 10-21 红线 1 | + +## 4. 硬红线 + +1. **装 / 不装逐字节一致(产品红线 1)**:除新增这一节以外,现有所有输出不得变化。验收:同一批盘「装 / 不装」两套全量 pytest 逐条一致(S7 的「跳过 → 通过」除外,必须通过);完整数据版中英 Markdown 切掉本章后逐字节相同;资料包 JSON 去掉本节新键后逐字节相同;校正冻结身份(`scripts/research/sealed_holdout_rerun.py::implementation_identity`)与封存回放(`tests/test_sealed_holdout_contract_freshness.py`、`tests/test_rectification_validation_integrity_gate.py`)逐项相同;对话卡(`read_full_data_domain`)3 张虚构盘逐字节相同。 +2. **API 进程内不得导入 `jhora`**:PyJHora 只在一个子进程 worker 里导入。生成报告、全盘、对话、校正之后,父进程 `sys.modules` 里不得有 `jhora`;`swisseph` 的岁差(`get_ayanamsa_ut`)与同刻月亮经度在调用本节前后不变。 +3. **版本固定(产品红线 2)**:`PyJHora==4.8.7`(与上游观测容器、我方 reference 文件一致);`geocoder==1.38.1`、`geopy==2.4.1` 照 `requirements-reference-engines.txt`。**不得改 `requirements.txt` 里 `pyswisseph` 那一行**,不得升级或更换 `pyswisseph`;新旧两个本地镜像里 `pip show pyswisseph` 必须相同,星位一律不变。 +4. **只有本节显式调用 PyJHora**:S1~S3 的产品路径保持不导入、不起子进程,结果与今天逐字节相同;不得新增 `pyjhora_canonical_adapter.py`、`pyjhora_yogi_replay.py`;不得把 PyJHora 数据写进 `annual_tajika_pack.*`、`normalized_tables`、`external_engine_comparison` 等现有键(现有渲染会读它们)。 +5. **增长冻结**:不改 `scripts/jyotish_api_server.py`;`tests/test_api_server_growth_contract.py` 不放宽。`scripts/*.py` 根目录已满 448 个(`tests/test_python_hygiene_budget.py::ROOT_PY_BUDGET`),新文件进 `scripts/report_render/`,不得调高预算。 +6. **不新增静默吞错**:`SILENT_EXCEPT_BUDGET = 1` 不放宽。子进程失败、超时、JSON 不合法都记日志(含原因),表里只写「未获得结果」。 +7. **报告卫生**:本节不得出现内部键名、`blocked`、`partial_verified`、`_unavailable`、`schema`、`source_path`、Python 异常名;英文汉字 0;中文不出现英文列名(行星编号、瑜伽专名、PyJHora 名称除外)。 +8. 不动数据库结构与迁移;不改 `.gitea/`、Dockerfile;不推 `main`。 +9. **冻结文件不碰**:`sealed_holdout_rerun.PRODUCTION_FILES` 所列 11 个文件(`scripts/rectification/*` 10 个 + `scripts/functional_benefics.py`)本单一行不改。 +10. **隐私**:只用虚构盘与公开名人盘;不复制上游个案文件;PROGRESS 与 Bug 历史实测段只写「虚构盘」。 + +## 5. 任务分解(一条任务一个提交,按顺序) + +| # | 内容 | 验收标准 | +| --- | --- | --- | +| T0 | **先量风险,再动代码**:在 #91 合入后的 `origin/staging` 上,用两套环境(见第 6 节「装 / 不装」做法)对 3 张固定虚构盘跑完整数据版中英 Markdown 与资料包 JSON,`diff` 两套;全量 pytest 两套逐条比对。预期今天就有差异(S1、S2、S7),逐条归到 1.3 的 S 编号;本地构建基线镜像,记 `docker image inspect --format '{{.Size}}'` 与 `pip show pyswisseph` | PROGRESS 有一张「装 / 不装差异清单」,每条落到 S 编号;出现 S 表以外的差异,**先补进 S 表再继续**(新增条目也要在 T1 关掉);基线镜像大小与 pyswisseph 版本已记 | +| T1 | **关掉现有开关(红线 1、4)**:S1 产品路径固定走短路(例如 `replay_pyjhora_annual` / `probe_pyjhora_year_lord` 增加只有研究脚本才传的显式参数,产品调用不传就返回现有 golden,不看 `find_spec`);S2 产品调用改为不探测、不导入,返回今天无 PyJHora 时的字典(`diagnose_pyjhora_adapter` 命令行默认行为不变);S3 产品路径固定返回今天的错误行;S4 加部署文件不设变量的测试。改动只在 `annual_pyjhora_replay.py`、`unified_consultation_orchestrator.py` / `diagnose_pyjhora_adapter.py`、`rectification_three_engine_packet.py`、`tajika_year_lord.py`(如需) | 装了 PyJHora 的环境下:`tests/fixtures/api-chart-subprocess-gate/*.json` 两个 golden 仍逐字节相等、`subprocess.run` 一次都不调;orchestrator 交叉验证段与无 PyJHora 时逐字节相同;三引擎包 PyJHora 行与无 PyJHora 时相同;父进程 `sys.modules` 无 `jhora`。T0 清单里 S1~S3 的差异全部消失。改到的既有断言写三栏(至少下表 A1) | +| T2 | **依赖**:`requirements.txt` 加 `PyJHora==4.8.7`、`geocoder==1.38.1`、`geopy==2.4.1`,注释写明「仅供完整数据版『独立年度方法参考』子进程使用,AGPL,产品 10-21 决定」;`pyswisseph` 行不动;`requirements-reference-engines.txt` 注释改成与 D1 一致;不改 Dockerfile、workflow。本地用 `deploy/railway-api.Dockerfile` 构建新镜像 | 新镜像构建成功;`docker run --rm <新镜像> python -c "import jhora, swisseph, geocoder, geopy; ..."` 能导入;新旧镜像 `pip show pyswisseph` 相同;镜像大小增量写 PROGRESS;`pip check` 无新冲突 | +| T3 | **子进程 worker**:新增 `scripts/report_render/pyjhora_annual_reference_worker.py`(文件名必须含 `pyjhora`,只在子进程里 `python -m` 运行,**任何产品模块不得 import 它**)与父进程侧 `scripts/report_render/annual_method_reference.py`(不导入 `jhora`、不导入 worker;用 `sys.executable -m scripts.report_render.pyjhora_annual_reference_worker`、`cwd` = 仓库根、stdin 传 JSON、stdout 收 JSON、PyJHora 的打印重定向到 stderr、超时 45 s 起步按 T6 实测调整)。一个子进程算完三年。子进程内按 1.1 表格的调用顺序、D6~D8 的口径;`annual_chart` 每年可只调一次复用结果(须在 3 张虚构盘上证明与逐次调用结果相同)。整理函数(`_normalize_mudda_replay_rows`、`_normalize_patyayini_replay_rows`、`_normalize_pyjhora_tajika_yoga_rows`、锚点、主星编号映射)搬到父进程侧,纯函数。资料包挂在**新键** `worksheets.timing_and_predictive_systems.pyjhora_annual_method_reference`(每年三表 + 实际锚点 + 实际岁差 / 交点 + 每表成功与否;失败原因只进日志)。挂载点放在报告资料包组装处(`professional_report_reference` 或 `jyotish_engine._attach_annual_tajika_pack` 之后,只加一行调用);中英两次渲染共用同一份资料包时不得算两遍,若两种语言各自组装资料包,加按请求哈希的进程内小缓存 | 3 张虚构盘:三年 × 三表都有行;Mudda 两个起点口径各一张;Tajika 9 个瑜伽各一行(`radda` 为「未获得结果」);岁差回读与请求一致;D6 两种口径的差异表进 PROGRESS;父进程 `sys.modules` 无 `jhora`;挡住 `jhora` 时三年 × 三表全部「未获得结果」、报告照常生成;超时与非零退出各有一个单元测试(替身子进程) | +| T4 | **渲染**:照 `_annual_observation_lines` 出「独立年度方法参考 / Independent Annual Method Reference」二级标题(D10 位置),章首一句「本节与前面的原生周期分开使用……」;每年一个三级标题「年度方法补充数据(请求年份 2026)/ Supplementary Annual Method Data (requested year 2026)」;紧接「来源:PyJHora。……」/「Source: PyJHora. ……」与实际岁差、交点一行,再印 Patyayini、Tajika 两条实际回归锚点;四个四级标题:Mudda(出生时月亮起算)/ Mudda (Moon at birth)、Mudda(年盘月亮起算)/ Mudda (Moon in annual chart)、Patyayini、Tajika 瑜伽 / Tajika Yoga;每表上方照上游的单位与边界说明(中英);列名中英两套(序号 / No.、大运主星 / MD lord、小运主星 / AD lord、开始 / Start、结束 / End、时长(天)/ Duration (days)、时长(年)/ Duration (years)、瑜伽 / Yoga、结果 / Result、行星关系 / Planet relationships);主星中文用我方中文星名,`L` 写「上升 / Lagna」;Tajika 结果三态照上游(已返回组合,需核对条件 / 检测为空 / 未获得结果);某表失败只印说明行「未获得结果 / Result unavailable」,不印空表。拼接点在 `render_full_data_markdown` 中英两支的 `_apply_local_report_conventions` **之后** | 3 张虚构盘中英:本章是最后一个二级标题;三年各有四个四级标题;「来源:PyJHora / Source: PyJHora」各出现三次;英文全文汉字 0(#89 扫描);中文本章无英文列名;红线 7 卫生扫描对本章零命中;切掉本章后与 T1 完成时的输出逐字节相同 | +| T5 | **红线测试**:①新增动态测试:装了 PyJHora 时生成一份完整数据版(中英)、跑一次全盘与一次对话卡,前后比较父进程 `swe.get_ayanamsa_ut(jd)`、同刻月亮经度、`sys.modules` 中无 `jhora`;②`tests/test_runtime_jhora_import_scan.py` **只加不减**:入口加 `scripts/unified_consultation_orchestrator.py`、`scripts/rectification_three_engine_packet.py`、`scripts/report_render/annual_method_reference.py`,并新增断言「产品导入图里没有任何模块 import `pyjhora_annual_reference_worker`」、「`annual_method_reference.py` 以 `-m` 字符串引用该 worker」;③完整数据版 golden / 快照类测试改为切掉本章再比(三栏);④批量生成报告的测试(如 #89 的 200 盘扫描)注入替身 runner,不真起 200 个子进程,本节自己的测试用真 PyJHora 跑 3 张盘 | 变异检查:在 `annual_method_reference.py` 加一行 `import jhora` 后①或②失败;把 S1 的强制短路去掉后 T1 的 golden 测试失败;快速门新增耗时写 PROGRESS | +| T6 | **量测与文档**:①生产镜像大小(新旧 `docker image inspect` 与压缩后层大小);②单份报告生成时长增量(中、英各 5 次取中位,Linux 由 Claude 复测,Windows 照报);③内存:子进程峰值 RSS、API 容器在生成报告时的 `docker stats` 峰值;④本章中英字符数;⑤全量 pytest 耗时变化。文档:`docs/BUG_HISTORY.md`(BUG-1273 记录末尾追加「10-22 产品决定推翻『不进生产』,见 #93」,S2 / S3 进程内导入隐患按新编号记)、`CHANGELOG.md`、`deploy/README.md`(镜像含 PyJHora、仅子进程使用、AGPL 由产品决定)、PROGRESS、`docs/testing/full-data-parity-3-20261022.md`(真机:生成完整数据版、翻到最后一章、中英切换、导出 `.md`、旧报告仍能打开、标准版无本章) | 文档齐;中英合计生成仍在 180 s(`ENGINE_TIMEOUT_MS`)以内,超了停下回报 | + +需要改的既有断言(三栏写进 PROGRESS 与测试注释): + +| # | 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | --- | +| A1 | `tests/test_api_chart_subprocess_gate.py::test_jhora_available_still_uses_subprocess` | `_jhora_importable` 为真时两个隔离函数各起一次子进程(`calls == 2`) | 产品调用不传研究参数时,即使可导入也不起子进程、返回 golden;传研究参数时仍起子进程(保留 `calls == 2`) | #93 红线 1:装包后旧回放不得进年运章 | +| A2 | `tests/test_report_reader_main.py::test_reader_main_hides_audit_vocabulary` | 完整数据版全文 `LEAK_PATTERNS`(含 `PyJHora`)零命中 | 切掉「独立年度方法参考 / Independent Annual Method Reference」一章后零命中;本章内必须含「来源:PyJHora」或「Source: PyJHora」,且本章对其余 `LEAK_PATTERNS` 零命中 | D1、D2:产品要求表头注明来源 | +| A3 | 完整数据版 golden / 快照 / 逐字节比较类测试(T0 列出具体文件) | 全文比较 | 切掉本章后全文比较;本章另有结构测试 | 本章内容依赖是否装 PyJHora | +| A4 | `tests/test_runtime_jhora_import_scan.py` | 现有断言 | 现有断言全部保留,只新增入口与断言(T5②) | 加严,不是放宽 | + +## 6. 验收分工 + +「装 / 不装」两套环境的做法: + +- **Windows(Grok)**:两个虚拟环境——`.venv`(`requirements.txt` 全装,含 PyJHora)与 `.venv-nojhora`(同一份 requirements 去掉三行新依赖后安装);两者 `pip show pyswisseph` 必须相同。 +- **Linux(Claude)**:系统 `python3` 已装 PyJHora 4.8.7 + pyswisseph 2.10.3.2;「不装」用 #64 的做法:只含下面 `sitecustomize.py` 的目录放在 `PYTHONPATH` 最前(子进程会继承,worker 里同样导入失败)。 + +```python +# <临时目录>/nojhora/sitecustomize.py +import sys +class _Block: + def find_spec(self, name, path=None, target=None): + if name.split('.')[0] in {'jhora'}: + raise ModuleNotFoundError(f"No module named '{name}'") + return None +sys.meta_path.insert(0, _Block()) +``` + +| 检查 | Grok 自跑 | Claude 验收 | +| --- | --- | --- | +| T0 差异清单(3 盘中英 Markdown + 资料包 + 全量 pytest,装 / 不装) | 必跑 | 复跑一次(Linux) | +| 改到与新增的 Python 测试文件(至少 `test_api_chart_subprocess_gate`、`test_runtime_jhora_import_scan`、`test_report_reader_main`、`test_report_english_edition`、`test_report_full_data_fix`、`test_full_data_domain_reader`、`test_python_hygiene_budget`、`test_api_server_growth_contract`、`test_special_lagnas_birth_instant`、`test_year_lord_*`、`test_tajika_*`、`test_pyjhora_adapter_diagnostics`、本单新增测试)及 #89 的 200 盘扫描,**装、不装各一次** | 必跑 | 复验 | +| 本地构建新旧 API 镜像:大小、`pip show pyswisseph`、`pip check`、容器内生成一份完整数据版并量 `docker stats` | 必跑 | 无 Docker,环境缺口,以 Grok 数据为准;部署后看门禁镜像发布日志 | +| Python 快速门 `run_quality_gate.py --profile quick` | 不跑 | 以 Claude 为准(装、不装各一次) | +| **Python 全量 pytest 与开工基线逐条比对,装 / 不装两套**(红线 1) | 不跑 | 以 Claude 为准 | +| 3 盘中英完整数据版切掉本章后逐字节比对(分支装 vs 分支不装 vs 基线不装) | 必跑 | 以 Claude 为准 | +| 封存回放与冻结身份(`test_sealed_holdout_contract_freshness`、`test_rectification_validation_integrity_gate`、`implementation_identity()` 输出),装 / 不装 | 必跑 | 复验 | +| 生成时长(中、英各 5 次中位) | Windows 数照报 | 以 Claude 的 Linux 数判是否碰 180 s | +| 前端 | 本单不改前端,不要求 | — | +| `test:db` | 不动数据库,不要求 | — | +| 部署后 | — | `/api/health` 的 `deployment.gitCommit` 等于合入 SHA;`swisseph_available = true`;真机清单留给产品 | + +## 7. 让步顺序 + +不让步:红线全部(尤其 1~4)、T0、T1、T5、D4 的逐表失败、英文汉字 0。时间不够时依次让出:T3 的 `annual_chart` 复用优化(照上游逐次调用)→ D8 的交点设置(照上游不设,表头写 PyJHora 默认)→ T6 的 `docker stats` 内存峰值(只报子进程 RSS)。让出的写进 PROGRESS。 + +## 8. 开工前置命令 + +```bash +git fetch origin --prune +git log origin/staging --oneline | grep -iE "#91|full-data-parity-1" | head # 确认 #91 已合入,否则不开工 +git log origin/staging --oneline | grep -iE "#92|full-data-parity-2" | head # #92 若已合入或在途,记进 PROGRESS +git worktree add -b codex/full-data-parity-3-20261022 .worktrees/full-data-parity-3-20261022 origin/staging +cd .worktrees/full-data-parity-3-20261022 +# 上游参照放仓库外,只读(#91 已 clone 的可复用) +git clone --filter=blob:none https://github.com/732642856/yinduzhanxing.git ../yinduzhanxing-ref +git -C ../yinduzhanxing-ref fetch origin codex/customer-export-frozen-20261016 +git -C ../yinduzhanxing-ref show 1d549fe9:customer-export/registry/delivery_timing_supplement.py | grep -n "_annual_observation_lines" +git -C ../yinduzhanxing-ref show 1d549fe9:scripts/annual_pyjhora_replay.py | grep -n "_isolated_annual_worker\|_call_mudda\|_call_patyayini\|_probe_tajika_yogas" +# 现有开关(1.3) +grep -n "_jhora_importable\|def replay_pyjhora_annual\|def probe_pyjhora_year_lord" scripts/annual_pyjhora_replay.py +grep -n "replay_pyjhora_annual\|pyjhora_yogi_replay\|pyjhora_canonical_adapter" scripts/jyotish_engine.py +grep -n "build_pyjhora_adapter_report" scripts/unified_consultation_orchestrator.py +grep -n "_pyjhora_d1" scripts/rectification_three_engine_packet.py +grep -rn "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE" deploy/ scripts/ | head +grep -n '"PyJHora"' scripts/pl9_full_data_export.py scripts/pl9_reader_english.py +grep -n "PyJHora" tests/test_report_reader_main.py +grep -n "^pyswisseph\|PyJHora" requirements.txt requirements-reference-engines.txt +ls scripts/*.py | wc -l # 448 = ROOT_PY_BUDGET +grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1 +``` + +## 9. BUG 编号 + +写作时 `docs/BUG_HISTORY.md` 最大号 **BUG-1403**。该文件自 `400af57e8` 起有整段重复,必须用上面命令(`sort -u` 后取最大)核对,**开工时最大号 + 1 起**(#89~#92 可能先占号)。预计至少两条新编号:S2 对话交叉验证在 API 进程内导入 PyJHora 的隐患(装包即触发);S3 校正三引擎包在 API 进程内导入 PyJHora 的隐患。BUG-1273 只追加「决策推翻」一段,不改原文、不改状态。 diff --git a/docs/tasks/TASK-goel-jaimini-strength-20261022.md b/docs/tasks/TASK-goel-jaimini-strength-20261022.md new file mode 100644 index 00000000..7c0676d7 --- /dev/null +++ b/docs/tasks/TASK-goel-jaimini-strength-20261022.md @@ -0,0 +1,201 @@ +# 任务书:搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra 计算(agent 循环 issue #94,2026-10-22) + +## 基线 + +- 写作基线:`origin/staging` = `f90020efd`。开工以当时的 `origin/staging` 为准。 +- 分支:`codex/goel-jaimini-strength-20261022`;工作树:`.worktrees/goel-jaimini-strength-20261022`。执行方 **Grok(Windows + Docker)**,只推这个分支。 +- 顺序:**可与 #89(英文漏译)并行**,#89 只改渲染文件,不碰本单的文件。**必须在 #91 B 段印「Goel Jaimini Strength」这一章之前合入**。#91 的 T4(`yogi_sphuta`)改的是 `scripts/jyotish_engine.py` 里 full-reading 的同一段 jaimini 组装代码,与本单 T3 相邻;后开工的一方先 `git merge origin/staging`,不 rebase。 +- 上游:`https://github.com/732642856/yinduzhanxing.git` `main` 上的提交 `5fb8379c`(2026-10-21,作者应我方请求公开)。只读,放在仓库外。 +- 关联:#84 / BUG-1393、BUG-1283(Niryaana Shoola,PVR Ayur 口径);#81 决策 A(上游原样文件放根目录,预算按文件数调高);BUG-1275(真恒星口径);ERR-110、ERR-115(冻结身份);#91 `TASK-full-data-parity-1-20261021`(D7 当时定为「不做」,因为源码未公开;本单公开后由 #91 或其后续单印这一章)。 + +## 1. 现状实证(Claude,Linux,只读) + +### 1.1 上游 `5fb8379c` 改了什么 + +共 6 个文件,+522 / −1: + +| 文件 | 变化 | 行数 | blob 哈希(上游 `5fb8379c`) | +| --- | --- | --- | --- | +| `scripts/goel_jaimini_strength.py` | 新增 | 137 | `2170f781740affbad7b8ff4e048b88d87f997bcc` | +| `tests/test_goel_jaimini_strength.py` | 新增 | 134 | `e29c47182c03c2277cff46e271a7f664c3349601` | +| `docs/research/goel_public_calculation_module_2026_10_21.md` | 新增 | 204 | `c0bef9d9ad5f708093589ce852db20f9e2613995` | +| `scripts/jyotish_engine.py` | +26 / −1 | — | 新增 `_goel_jaimini_evidence`;`cmd_jaimini` 在 `mode in ('all','special')` 时并入;`_cmd_full_reading` 在 `report['modules']['jaimini'] = jaimini_result` 前并入;另改 `render_pl9_markdown` 里 p89 一行说明文字 | +| `tests/test_bphs_validation_runtime.py` | +5 | — | 在 `test_full_reading_exposes_lagna_vargottamamsa_receipt` 末尾加 Goel 四条断言 | +| `docs/research/pre_work_error_ledger.md` | +16 | — | 上游自己的台账条目(Goel 首/七强度 ≠ PVR Niryaana) | + +新模块依赖的上游文件(**这次提交之前就在上游,我方都没有**): + +| 文件 | 行数 | blob 哈希(上游 `5fb8379c`) | 为什么需要 | +| --- | --- | --- | --- | +| `scripts/trikona_raman.py` | 233 | `1fc9283396acba98b9113bde7af0e27a80841a30` | Goel 取 `CLASSICAL`、`DIGNITY_PROFILE`、`_dignity`、`_validate_positions` | +| `scripts/sthira_brahma_goel_short_method.py` | 112 | `6e010b54f5ba480db3e6d8d79e711a1cc2d59fbf` | Goel 取 `SIGNS`、`SIGN_LORDS`(按整数索引的表) | +| `scripts/trikona_dasha.py` | 166 | `e6c8a27cf5e23e5c686154406ba5183226d12d79` | **issue 没列,但必需**:`trikona_raman.py` 模块顶层 `from scripts.trikona_dasha import RAMAN_AD_PROFILE, RAMAN_SOURCE, build_trikona_raman_antardasa_report`,不搬就导入失败 | + +依赖我方已有的两个文件,逐项核对与上游相同,**不用改**: + +| 我方文件 | 用到的符号 | 结论 | +| --- | --- | --- | +| `scripts/narayana_dasha.py` | `_rasi_aspects`、`SIGNS`、`SIGN_LORDS`、`_NARAYANA_DIGNITY` | 四个定义与上游逐字相同(整文件不同,不影响) | +| `scripts/jaimini.py` | `FRIENDSHIP` | 逐字相同 | + +### 1.2 口径与安全检查 + +| 检查 | 结果 | +| --- | --- | +| 位置框架 | 四个上游文件都**不算位置**:没有 `swe`、`FLG_MOSEPH`、`FLG_TRUEPOS`、视位置。只接受调用方给的恒星黄道经度(`[0,360)`,九星齐全,罗睺计都必须对冲,误差 1e-7)。位置由我方引擎提供即为真恒星口径 | +| PyJHora | 四个文件都不导入 `jhora`;`trikona_dasha.py` 只有一句字符串提到 PyJHora,`test_runtime_jhora_import_scan` 不把字符串算导入 | +| 隐私 | 测试只有虚构位置(`SYNTHETIC`)和 Goel 书第 15 页公开例题(只有度数,无出生资料);文档无个案资料。**不搬**上游台账条目里关于客户导出 pin 的段落 | +| 静默吞错 | 四个文件没有宽泛 `except`;`_goel_jaimini_evidence` 捕获的是具名异常元组,不计入 `SILENT_EXCEPT_BUDGET` | +| 来源字符串 | `goel_jaimini_strength.SOURCE` 是上游本地书稿路径(含 `Z-Library` 字样),我方仓库没有这份书稿。**这一行是唯一允许的改动**(Claude 10-22 定):公开仓库不出现盗版书库字样与他人本地路径,改成书名与作者的纯引用:`SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'`;**不得**搬入书稿 | +| 已有同名概念 | 我方 `scripts/goel_short_strength.py` 是 Goel **第 5 章简法**(供 `rashi_dashas_native` 的 Sthira / Niryaana 等用),与本单的**第 1 章完整法**不是一套,互不替换 | + +### 1.3 试跑(Claude,临时目录,未改仓库) + +把四个上游文件放进临时包,`narayana_dasha.py` / `jaimini.py` / `rashi_dashas_native.py` / `goel_short_strength.py` 用我方 `f90020efd` 的版本: + +- `tests/test_goel_jaimini_strength.py` 中**不涉及引擎**的 16 条全过(含第 15 页例题逐值、虚构盘五分量、交点两种口径、度数相同 → blocked、非法输入)。 +- 涉及引擎的 3 条需要本单接线后才能跑。其中 `test_legacy_lagna_seventh_alias_does_not_replace_pvr_second_eighth` 从 `scripts.niryaana_shoola_dasha` 导入 `select_niryaana_shoola_start_sign`;**我方这个函数在 `scripts/rashi_dashas_native.py`**(#84)。用我方函数跑虚构盘:选中 Taurus(`selected_sign_idx == 1`),`profile == 'pvr_niryaana_shoola_ayur_rasi_strength_v1'`,与上游断言一致。 +- Linux 验收机未装 swisseph,没跑真实排盘;真实盘上的结果由 T5 验证。 + +### 1.4 我方接线点(按符号定位) + +| 位置 | 现状 | +| --- | --- | +| `scripts/jyotish_engine.py::cmd_jaimini` | 除上游新增那一行外与上游逐字相同。`planet_lons` 取 `pd['degree']`(**四舍五入到 4 位小数**) | +| `scripts/jyotish_engine.py::cmd_full_reading`(Step 6: Jaimini 系统) | `planet_lons` 取 `degree_raw`(未舍入),`asc_deg` 取上升 `lon`;jaimini 组装末尾 `report['modules']['jaimini'] = jaimini_result`,整段包在 `try … except Exception` 里,任何未捕获异常会让**整个** jaimini 模块丢失 | +| API | `/api/jaimini`(`_compute_jaimini`,mode=all)走 `cmd_jaimini`;咨询与报告走 full-reading。**不需要改 `scripts/jyotish_api_server.py`** | +| 报告 | `pl9_reader_export.py` 按键名取 jaimini 子项,不会自动印新键;`build_pl9_style_export_packet` 会把整个 `modules.jaimini` 放进资料包 JSON(体积略增,不出正文)。`jyotish_engine.py` 里受限资料 `jaimini_special_points.reason` 仍写「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」——本单不改(属报告文字,交 #91) | +| 关键路径快照 | `tests/golden/consultation_contract_keypaths_v1.json` 只记两层(`modules.jaimini` 为止),新键在第三层,**不变** | +| 键名冲突 | 我方没有任何代码读 `niryaana_shoola_strength`、`lagna_seventh_strength`、`goel_full_strength`、`goel_special_points`;我方 Niryaana 读的是 `dasha_families['niryaana_shoola']` 等精确键 | + +### 1.5 冻结身份(ERR-110 / ERR-115) + +冻结身份按**文件字节哈希**核对三份清单:`scripts/research/sealed_holdout_rerun.py` 的 `PRODUCTION_FILES`(11 个,校正与 `functional_benefics.py`)、`RESEARCH_FILES`(7 个),以及 `references/real_case_calibration/minute_rectification_holdout_v3.json` 的 `frozen_scoring.files`(12 个,**含 `jaimini.py`、`narayana_dasha.py`**)。 + +- 本单新增的四个文件、`jyotish_engine.py`、两个测试文件都**不在**清单里。 +- 本单只**导入** `jaimini.py`、`narayana_dasha.py`,不改它们。 +- 校正打分不调用 full-reading,也不读 `modules.jaimini`。 + +结论:按设计冻结身份**不会变**。若执行中发现必须改清单里的文件,或下面第 9 节的检查命令非空 / 四个身份测试转红,按红线 4 停下回报。 + +### 1.6 根目录文件预算 + +`scripts/*.py` 现为 **448** = `tests/test_python_hygiene_budget.py` 的 `ROOT_PY_BUDGET`,已满。先例:**#81 决策 A(产品 2026-10-18)**——「**上游原样文件**可以放在 `scripts/` 根目录(与上游同路径,方便以后同步),预算为这几个文件相应调高;我们自己写的文件仍不许增加」,当时 446 → 448,常量旁写了三栏。#91 把 `yogi_sphuta` 等放子包,是因为那些模块要改位置口径,不算原样文件;本单四个文件**一字不改**,正好落在 #81 A 的范围内。 + +## 2. 背景 + +Goel《Predicting through Jaimini Astrology》第 1 章(印刷页 10–17)的完整强度:行星五分量(尊贵、Karaka 名次分、从 AK 起的 Kendra 分、Kartari、Adhipati),星座五分量(固定分、所含 Karaka 分、Sthana、木星 / 水星 / 宫主三角色相位、宫主强度),再据此选 Brahma、Maheshwara、Rudra。上游交付版的「Goel Jaimini Strength and Special-Point Evidence」一章(约 4.8 万字符)就印这些数,我方此前因源码未公开没法做(#91 D7)。 + +## 3. 决策记录(产品负责人,issue #94,2026-10-21) + +| # | 决定 | +| --- | --- | +| D1 | 搬 `goel_jaimini_strength.py` 及其依赖 `trikona_raman.py`、`sthira_brahma_goel_short_method.py`;`narayana_dasha._rasi_aspects` 两边已相同,不动。按 blob 哈希锁定上游版本,写进进度记录。(Claude 核实:`trikona_raman.py` 还硬依赖 `trikona_dasha.py`,一并按原样搬,见 1.1) | +| D2 | 按上游 `jyotish_engine.py` 的改动,把 `goel_full_strength`、`goel_special_points` 接进我方 jaimini 与 full-reading 输出 | +| D3 | 搬上游测试 `tests/test_goel_jaimini_strength.py`(书中公开数值表 + 虚构盘),必须全过;`test_bphs_validation_runtime.py` 的相应改动一并对照 | +| D4 | 口径照上游文档:交点默认计入 Sthana(遵循正文,`include_nodes_text`);排除交点的示例表口径也保留(`worked_example_node_exclusion`);七个 Karaka 度数相同时为「无法判定」(`status: blocked`),**不得自造排序** | +| D5 | 上游新增的 `lagna_seventh_strength` 与我方 #84 的 Niryaana Shoola(PVR Ayur)不是同一套:只搬字段本身,**不改**我方 Niryaana Shoola 的算法 | +| D6 | 校正打分不读这两个新字段;封存回放、偏移扫描前后逐项相同。若只是因为 full-reading 多了字段导致冻结身份变化,**停下来报告,不得自行重冻** | +| D7 | 位置一律用我方引擎口径(真恒星位置) | +| D8 | 本单只做计算,不改报告渲染;报告里印这一章由 #91 负责 | +| D9 | 可与 #89 并行;必须在 #91 印这一章之前合入 | +| D10 | 根目录预算:按 **#81 决策 A** 先例,四个上游原样文件放 `scripts/` 根目录,`ROOT_PY_BUDGET` 只为这四个文件调高(448 → 452)。这是对先例的适用,不是新决定;若产品另有意见,按第 8 节让步改放子包 | + +## 4. 硬红线 + +1. **上游四个文件一字不改**(唯一例外:`goel_jaimini_strength.py` 的 `SOURCE` 一行改为书目引用,见 1.2):其余三个文件落地后 `git hash-object` 必须等于 1.1 表里的 blob 哈希;`goel_jaimini_strength.py` 与上游 blob 的 `diff` 只能有 `SOURCE` 这一行(Windows 注意 `core.autocrlf=false`,LF 换行)。需要的适配一律写在调用方(`jyotish_engine.py`)或测试里。 +2. **不改我方 Niryaana Shoola 与简法强度**:`scripts/rashi_dashas_native.py`、`scripts/niryaana_shoola_dasha.py`、`scripts/goel_short_strength.py` 零改动;`tests/test_rashi_dashas_native.py`、`tests/test_niryaana_shoola_dasha.py` 不改断言且全绿。新增代码不得把 `lagna_seventh_strength` 或 `niryaana_shoola_strength` 接到任何 Niryaana 选择或大运计算上。 +3. **不改冻结清单里的文件**:`jaimini.py`、`narayana_dasha.py`、`ashtakavarga.py` 等 `frozen_scoring.files` 与 `PRODUCTION_FILES`、`RESEARCH_FILES` 中的任何文件零改动;校正代码(`scripts/rectification/**`、`minute_rectification_*`、`active_rectification_*`)不得读新字段。 +4. **冻结身份变化即停**:第 9 节检查命令输出非空,或 `test_sealed_holdout_contract_freshness`、`test_rectification_validation_integrity_gate`、`test_reported_offset_research`、`test_rectification_engine_memoization` 任一条转红——停下,推分支,PROGRESS 写清哪个文件、哪条测试,**不得重新冻结、不得改冻结记录或 golden**。 +5. **口径**:Goel 输入的经度一律取我方 `compute_chart_data` 的结果(Swiss Ephemeris + 真岁差,BUG-1275 之后),full-reading 用 `degree_raw`;不引入任何新的位置计算。 +6. **PyJHora**:运行时不得导入 `jhora`(`tests/test_runtime_jhora_import_scan.py`)。 +7. **增长冻结**:不改 `scripts/jyotish_api_server.py`;`tests/test_api_server_growth_contract.py` 不放宽。 +8. **静默吞错**:`SILENT_EXCEPT_BUDGET = 1` 不变(只给 `classical_astrology.py`)。Goel 失败必须 `logger.warning` 记具体异常类与信息,返回的 `reason` 带同样信息;不得让 Goel 失败把整个 `modules.jaimini` 带掉。 +9. **不改报告渲染**(D8):`pl9_*`、`scripts/report_render/**`、`professional_report_reference.py`、前端一概不动。3 张固定虚构盘的中英完整数据版 Markdown 改前改后逐字节相同(见 T5)。 +10. **对话不受影响**:`read_full_data_domain` 经卡片选择后的对话卡,对 3 张固定虚构盘逐字节不变。 +11. 不动数据库与迁移;不改 `.gitea/`;不推 `main`。 +12. **隐私**:只用虚构盘与书中公开例题;不搬上游书稿、客户导出文件、上游台账里的客户 pin 段落。PROGRESS 实测段只写「虚构盘」。 + +## 5. 任务分解 + +| # | 内容 | 验收标准 | +| --- | --- | --- | +| T1 | **原样搬四个模块**到 `scripts/` 根目录(`goel_jaimini_strength.py`、`trikona_raman.py`、`sthira_brahma_goel_short_method.py`、`trikona_dasha.py`),用 `git -C <上游> show 5fb8379c:<路径> > <路径>` 取,不经编辑器。`tests/test_python_hygiene_budget.py`:`ROOT_PY_BUDGET` 448 → 452,常量旁写三栏——原值 448 / 新值 452 / 原因「产品 2026-10-18 #81 决策 A 先例,issue #94:上游原样文件 goel_jaimini_strength、trikona_raman、sthira_brahma_goel_short_method、trikona_dasha 按上游路径放根目录;本地新写的文件仍不得增加」。合并时若根目录计数已因别的单变化,按合并后实数重算,**只为这四个文件加名额** | 三个文件 `git hash-object` 与 1.1 表逐一相等,`goel_jaimini_strength.py` 与上游的 diff 只有 `SOURCE` 一行(贴进 PROGRESS);仓库内 `grep -rn "Z-Library" scripts tests docs` 无命中;`ls scripts/*.py \| wc -l` = 新预算;`test_python_hygiene_budget` 全绿(静默处理仍只有 `classical_astrology.py:12558`) | +| T2 | **搬测试**:`tests/test_goel_jaimini_strength.py` 按原样取,**只改一行**:`from scripts.niryaana_shoola_dasha import select_niryaana_shoola_start_sign` → `from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign`(我方 #84 的位置);PROGRESS 记上游 blob 与这一行改动。**对照** `test_bphs_validation_runtime.py`:我方这个文件没有上游的 `test_full_reading_exposes_lagna_vargottamamsa_receipt`,新增一条测试 `test_full_reading_exposes_goel_jaimini_strength`,用文件里已有的 `BASE_BIRTH_ARGS`(虚构盘)与 `_run_engine("full-reading", …)`,断言上游那四条:`goel_full_strength.status == 'executed'`、`rashi_strengths` 12 行、`goel_special_points` 键集合为 Brahma / Maheshwara / Rudra、`niryaana_shoola_strength.deprecated is True` | `tests/test_goel_jaimini_strength.py` 全部 19 条通过(含 3 条引擎接线条);新增 full-reading 测试通过;屏蔽 PyJHora 再跑一次同样通过 | +| T3 | **接线**(`scripts/jyotish_engine.py`):照上游加 `_goel_jaimini_evidence(planet_longitudes, ascendant_longitude)`,返回 `goel_full_strength`、`goel_special_points`,以及有就带上的 `lagna_seventh_strength` 与弃用别名 `niryaana_shoola_strength`(上游模块原样产出,带 `deprecated: true`、`replacement`、`not_for` 标注,保留以便与上游测试一致)。与上游的两处差异,写进 PROGRESS:(a) 捕获异常时 `logger.warning` 记异常类与信息(红线 8),`reason` 写成 `f'{exc.__class__.__name__}: {exc}'`;(b) `cmd_jaimini` 传给 Goel 的经度用 `pd.get('degree_raw', pd['degree'])`——我方 `degree` 舍入到 4 位小数,罗睺计都对冲检查容差 1e-7,用舍入值可能误判为不对冲而整组 blocked(上游测试的模拟盘只有 `degree`,回退后仍过)。`cmd_jaimini` 在 `mode in ('all','special')` 时并入;full-reading 在 `report['modules']['jaimini'] = jaimini_result` 之前并入,**不得**放在会被外层 `except Exception` 吞掉整个模块的位置之外再抛异常 | `cmd_jaimini` 与 full-reading 对 3 张固定虚构盘都输出四个新键;`modules.jaimini` 原有各键逐值不变(改前改后 JSON 去掉四个新键后逐字节相同);`git diff` 只动 `jyotish_engine.py` 的这三处 | +| T4 | **口径与「无法判定」**:确认 D4 的三件事在接线后的输出里成立:默认 `node_occupancy_policy == 'include_nodes_text'`;`goel_full_strength.worked_example_node_exclusion` 存在且 `node_occupancy_policy == 'exclude_nodes_worked_example'`;七星度数相同时 `status == 'blocked'`、三个特殊点全部 `blocked`,不出 AK、不出排序。另加一条测试:full-reading 里喂给 Goel 的经度与 `modules.chart.planets.*.degree_raw` 差 ≤ 1e-6° | 测试覆盖三件事与经度一致;不新增任何排序兜底 | +| T5 | **实盘回归与不变性**:(1) 200 张固定种子随机虚构盘(可复用 #89 的生成器,未合入则自写固定种子)跑 full-reading 的 jaimini 段:`goel_full_strength.status` 全部 `executed`(若有 `blocked`,逐张列原因,只允许七星度数相同一种);`goel_full_strength.atmakaraka` 与我方 `chara_karaka_7` 的 AK 一致(不一致逐张列出并停下回报,不改任何一方);mean / true 交点各跑一遍。(2) 3 张固定虚构盘:中英完整数据版 Markdown 改前改后逐字节相同;对话卡(`read_full_data_domain`)逐字节相同。(3) 冻结身份检查(第 9 节命令)输出为空 | 三项结果写进 PROGRESS(盘数、blocked 数、AK 一致率、耗时增量);任一不满足按红线停下 | +| T6 | **文档**:上游 `docs/research/goel_public_calculation_module_2026_10_21.md` 原样放到同路径(docs 不占根目录名额;文中唯一一处书稿本地路径同样改成书目引用,`grep -n "Z-Library"` 无命中),开头加一行我方说明:「上游 `5fb8379c` 原文;我方 Niryaana 选择器在 `scripts/rashi_dashas_native.py`;书稿未入库」。`docs/research/pre_work_error_ledger.md` 加一条我方台账(新 ERR 号):Goel 首/七强度 ≠ PVR Niryaana 二/八,弃用别名不得接入 Niryaana;Goel 完整法 ≠ `goel_short_strength` 简法。`CHANGELOG.md` 一行(引擎新增 Goel 强度与三个特殊点,报告暂不显示;Skill 版本不 bump)。PROGRESS 记:四个 blob、测试改动的那一行、接线与上游的两处差异、T5 数据、交给 #91 的事项(见下) | 文档齐;`.venv/bin/python -m pytest tests/test_repo_privacy_markers.py -q` 通过 | + +交给 #91(写进 PROGRESS「移交」段,本单不做): + +- 报告印「Goel Jaimini 强度与特殊点」章,中英标签齐全;`blocked` 的中文写「无法判定」、英文写「Not determinable」,并注明原因(七星度数相同 / 强度相同无唯一选择)。 +- `jyotish_engine.py` 受限资料 `jaimini_special_points.reason` 里「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」随印章一并改。 +- 交点两种口径并列印出,标明默认遵循正文。 + +## 6. 验收分工 + +| 检查 | Grok 自跑 | Claude 验收 | +| --- | --- | --- | +| 改到与新增的 Python 测试文件:`test_goel_jaimini_strength`、`test_bphs_validation_runtime`、`test_python_hygiene_budget`、`test_rashi_dashas_native`、`test_niryaana_shoola_dasha`、`test_jaimini`、`test_runtime_jhora_import_scan`、`test_api_server_growth_contract`、`test_consultation_contract_golden`、四个冻结身份测试(红线 4)、`test_repo_privacy_markers` | 必跑(工作树 `.venv`),屏蔽 PyJHora 再跑一次 | 复验 | +| T5 的 200 盘与 3 盘逐字节比对 | 必跑,结果写 PROGRESS | 抽 3 盘复跑 | +| Python 快速门 `run_quality_gate.py --profile quick` | 不跑 | 以 Claude 为准 | +| **Python 全量 pytest 与开工基线逐条比对**(引擎输出有新增键) | 不跑 | 以 Claude 为准;屏蔽 PyJHora 模拟生产 | +| 前端 | 不动,不要求 | — | +| `test:db` | 不动数据库,不要求 | — | + +## 7. 提交顺序(一个分支) + +T1 → T2 → T3 → T4 → T5 → T6,一条任务一个提交。T1、T2 的提交里不得夹带对上游文件的任何格式化。 + +## 8. 让步顺序 + +不让步:红线全部;T1 的 blob 一致;T2 上游测试全过;T3 两个输出入口;冻结身份不变。 + +时间不够时依次让出:T5 的 true 交点那一遍 → T5 的 200 盘缩到 50 盘(写明)→ T6 的上游文档原样副本(台账与 CHANGELOG 不让)。 + +若产品不同意按 #81 A 调预算:四个文件改放 `scripts/goel_jaimini/`(子包,含 `__init__.py`),把模块内 `from scripts.trikona_raman` / `scripts.sthira_brahma_goel_short_method` / `scripts.trikona_dasha` 三处导入改成子包路径,测试里的 `scripts.goel_jaimini_strength` 跟着改;PROGRESS 记上游 blob + 本地导入改动的 diff,此后上游同步需人工比对。默认不走这条。 + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/goel-jaimini-strength-20261022 .worktrees/goel-jaimini-strength-20261022 origin/staging +cd .worktrees/goel-jaimini-strength-20261022 +git config core.autocrlf false + +# 上游参照放仓库外,只读 +git clone --filter=blob:none https://github.com/732642856/yinduzhanxing.git ../yinduzhanxing-ref # 已有则 git -C ../yinduzhanxing-ref fetch origin +git -C ../yinduzhanxing-ref show --stat 5fb8379c +for f in scripts/goel_jaimini_strength.py scripts/trikona_raman.py scripts/sthira_brahma_goel_short_method.py scripts/trikona_dasha.py tests/test_goel_jaimini_strength.py docs/research/goel_public_calculation_module_2026_10_21.md; do + echo "$f $(git -C ../yinduzhanxing-ref rev-parse 5fb8379c:$f)"; done + +ls scripts/*.py | wc -l # 期望 448(= ROOT_PY_BUDGET) +grep -n "ROOT_PY_BUDGET\|SILENT_EXCEPT_BUDGET" tests/test_python_hygiene_budget.py +grep -n "^def cmd_jaimini\|report\['modules'\]\['jaimini'\] = jaimini_result" scripts/jyotish_engine.py +grep -n "^def select_niryaana_shoola_start_sign" scripts/rashi_dashas_native.py +grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1 +grep -oE "^## ERR-[0-9]+" docs/research/pre_work_error_ledger.md | sort -u | sort -t- -k2 -n | tail -1 +``` + +冻结身份检查(T5 与交回前各跑一次,输出必须是 `[]`): + +```bash +git diff --name-only origin/staging...HEAD > /tmp/goel_changed.txt +.venv/bin/python - <<'EOF' +import json, re +src = open("scripts/research/sealed_holdout_rerun.py", encoding="utf-8").read() +listed = set(re.findall(r'"(scripts/[^"]+\.py)"', src)) # PRODUCTION_FILES + RESEARCH_FILES +ds = json.load(open("references/real_case_calibration/minute_rectification_holdout_v3.json", encoding="utf-8")) +listed |= set(ds["frozen_scoring"]["files"]) +changed = {l.strip() for l in open("/tmp/goel_changed.txt") if l.strip()} +print(sorted(listed & changed)) +EOF +.venv/bin/python -m pytest -q tests/test_sealed_holdout_contract_freshness.py tests/test_rectification_validation_integrity_gate.py tests/test_reported_offset_research.py tests/test_rectification_engine_memoization.py +``` + +非空或转红 → 红线 4:停下、推分支、PROGRESS 写明,不重冻。 + +## 10. BUG 编号 + +本单是上游功能移植,预计**不新增** BUG 记录。若执行中确认了缺陷(例如 `cmd_jaimini` 舍入经度致对冲检查误判、Goel 失败带掉整个 jaimini 模块),按 `docs/BUG_HISTORY.md` 全文 `sort -u` 后的最大号 **+1** 起编(写作时最大号 BUG-1403;#88~#93 可能先占号,以开工时为准)。台账新 ERR 号同样开工时取最大号 +1。 diff --git a/docs/tasks/TASK-report-english-han-leak-fix-20261022.md b/docs/tasks/TASK-report-english-han-leak-fix-20261022.md new file mode 100644 index 00000000..2b64ba48 --- /dev/null +++ b/docs/tasks/TASK-report-english-han-leak-fix-20261022.md @@ -0,0 +1,51 @@ +# 修复单:英文版漏译(issue #89,第 1 次打回,2026-10-22) + +- 被验提交:`ba1ffeb35`(分支 `codex/report-english-han-leak-20261021`) +- 原任务书:`TASK-report-english-han-leak-20261021.md` +- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase),只推这个分支。合并只会在 `CHANGELOG.md`、`docs/BUG_HISTORY.md`、`docs/tasks/README.md` 上冲突(都是追加,两边都留);`pl9_reader_export.py` 与 #86(速度 4 位)、#87(上升「/」)能自动合并,合并后两处都要在。 +- BUG 编号:本分支的 BUG-1410、1411 与 staging 上 #86 的 1410~1412 撞号。合并 staging 后改成当时最大号 +1 起(按现在是 1413、1414),CHANGELOG、PROGRESS 同步改。 + +## 验收结论(Claude 逐行审阅) + +做对了的:四张末尾词表一条没加;中文字段只新增 `*_en` 孪生字段,中文渲染分支不变;`_english_edition`、`acceptEnglishEdition`、`_retain_remaining_cjk_lines` 与前端都没动;Rath 新函数放在 `narayana_legacy_label.py`,`narayana_rath` 没被第二个文件导入;`yoga_rules.json` 只改了一条英文模板;测试只用虚构盘。 + +全量 pytest、快速门、前端判定这次**没跑**(下面 H1 已足以判未通过,H2 会让测试结果不可信),下一轮一并跑。 + +## H1 · 改了冻结的校正打分文件(必修,红线 4) + +`scripts/jaimini.py` 在 `references/rectification_sealed_holdout.v1.json` 的 `production_scoring_files` 里(三处),钉死 sha256 `6b88658da540…`。本分支给 `calc_chara_karaka_7`、`calc_chara_karaka_8` 加了 `KARAKA_DOMAINS_EN` / `domain_en`,哈希变成 `cb32081951d5…`,`tests/test_sealed_holdout_contract_freshness.py` 会红,也违反「不得碰冻结打分文件」(ERR-110 / ERR-115)。 + +修法:`scripts/jaimini.py` **逐字节恢复**成 `origin/staging` 版本(`git checkout origin/staging -- scripts/jaimini.py`)。Karaka 职责的英文改在渲染侧按 karaka 名查表(放在 `pl9_reader_export.py` 或一个新的非冻结模块,例如 `scripts/report_render/` 下)。 + +验收:`git diff origin/staging -- scripts/jaimini.py` 为空;`pytest tests/test_sealed_holdout_contract_freshness.py` 通过;开工前把本分支改动的文件清单与该 JSON 里所有 `scripts/*` 路径求交集,结果为空,写进 PROGRESS。 + +## H2 · 测试里的 PyJHora 屏蔽没传到子进程(必修,红线 5) + +`scripts/annual_pyjhora_replay.py` 的 `_run_isolated_tajaka_year_lord`、`_run_isolated_annual_replay` 每份报告都另起 `sys.executable -c …` 子进程(`timeout=30`)。`tests/report_english_fictional.py` 的 `block_jhora` 只改了本进程的 `builtins.__import__`,子进程不受影响:装了 PyJHora 的机器上子进程会真跑 PyJHora,返回生产上根本没有的年运数据(Claude 实测 1.4 秒拿到真实 `annual_chart`)。所以 200 张与 12 张两份测试渲染的不是生产那份报告——测出来汉字为 0 不能说明生产为 0。 + +这也是测试慢的原因:每张盘多起两个跑真 PyJHora 的子进程,Windows 上 4 进程并发时有的撞上 30 秒超时。 + +修法(二选一,推荐第一种): +1. 在 `render_english` / 进程池初始化里把这两个 `_run_isolated_*` 替换成直接返回「blocked」的桩,与生产无 PyJHora 时的返回形状一致; +2. 或用环境变量 + `sitecustomize` 让子进程同样屏蔽 `jhora`。 + +验收:加一条断言——测试渲染时这两个函数返回的是 blocked 形状,且整个测试过程中没有启动 PyJHora 子进程(例如给 `subprocess.run` 计数);在装了 PyJHora 的机器上 smoke 与 200 张重跑仍全过;PROGRESS 记下新的耗时(Windows 串行 / 进程池各一次)。smoke 已在 `CORE_PYTEST_TARGETS` 里,改完后若 Windows 上仍超过 90 秒,写明实测数字,由 Claude 在 Linux 上复测后决定。 + +## H3 · 缺英文时现在是「留空」,等于把漏洞藏起来(必修) + +`render_pl9_parity_markdown` 的瑜伽、Dosha、Sade Sati、Jaimini 表,以及 `_render_pl9_ai_density_raw_data_markdown_en` 的 `_english_plain`,遇到组合 / 作用 / 强度 / 摘要 / 职责里有汉字时直接输出空格。任务书 T2 写的是「两者都没有时不得回落到中文——让 200 张盘测试暴露出来」:留空既不回落中文,也让测试看不见缺的 `combination_en`。 + +修法:留空的地方改成写一个固定标记(例如 `[translation missing: <字段名>]`),并在 200 张与 smoke 测试里断言这个标记出现次数为 0;出现的就去生产者补英文孪生字段。若某处确实补不了,PROGRESS 列出字段、盘数和源头位置,交 Claude 判断。 + +## 顺手改(小) + +- `tests/test_narayana_legacy_label.py`:新的三栏说明覆盖掉了原来 BUG-1218 那一段,改成追加在下面,旧的保留。 +- `tests/report_english_fictional.py` 的 `chart_flags` 与已有 `_flags_from_packet` 重复,能复用就复用。 +- `yogas_doshas.calc_raj_yogas` 遍历 `set`,结果顺序随 `PYTHONHASHSEED` 变(改前就有)。不在本单修,但 BUG_HISTORY 新增一条 `investigating` 记下现象与位置,便于以后排序固定。 + +## 交回前必跑 + +- `pytest tests/test_sealed_holdout_contract_freshness.py tests/test_report_english_sweep_smoke.py tests/test_report_english_sweep.py tests/test_narayana_legacy_label.py tests/test_report_english_han_sources.py` 及原单列出的既有测试文件。 +- **Python 快速门** `python scripts/run_quality_gate.py --profile quick`(工作树 `.venv`),回报退出码与通过 / 失败数。 +- 中文逐字节对比(30 张,`PYTHONHASHSEED=0`,对照合并后的 `origin/staging`)。 +- 全量 pytest、前端英文判定由 Claude 验收时跑。 diff --git a/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md b/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md new file mode 100644 index 00000000..4a75dd7a --- /dev/null +++ b/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md @@ -0,0 +1,45 @@ +# 修复单:婚恋合盘 T7~T11 验收遗留(issue #85,第 1 次打回,2026-10-22) + +- 被验提交:`57ee79cc5`(分支 `codex/synastry-staging-fix-20261021`) +- 原任务书:`TASK-synastry-staging-fix-20261021.md`(T7~T11) +- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase),只推这个分支。 +- BUG 编号:本分支已用 1404~1408,保持不变(其他分支撞号由 Claude 合入时重编)。 + +## 验收结论(Claude,Linux / Node 22) + +| 项 | 结论 | +| --- | --- | +| tsc / lint | 0 错;0 error | +| 前端全量 `npm test` | 5101 条,失败 25,名单与 staging `9812f3173` 基线逐名一致 | +| 逐行审阅 T7~T11 | 通过:范围干净;新迁移只删一个唯一索引,对已部署代码向后兼容,真库测试覆盖「两份 ready 并存」「生成中复用不扣点」「ready 后再建扣点」;改动的断言都有三栏;双击不会重复扣点(`busy` + 每人一份生成中的索引,输方 409 并释放预留);新地址的归属校验仍在服务端 | +| 全量 pytest | **多 1 条失败**,见 G1 | +| Python 快速门 | **pytest 一步失败**(同 G1)。进度记录写了「没有跑快速门」,正是这条漏掉的原因 | +| build | 本机内存不足被系统杀掉,Claude 另行重跑,不算本单问题 | + +## G1 · 新页面没登记进路由清单(必修) + +`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources` 失败: + +``` +At index 34 diff: 'synastry/[reportId]' != 'terms' +``` + +该用例断言 `audit['surfaces']['app_routes']` 等于一张写死的前端路由表。T7 新增了 `app/(app)/synastry/[reportId]/page.tsx`,表里没加。按 AGENTS §7.8 同轮改:在表中按字母序加入 `'synastry/[reportId]'`,旁边写三栏(原值:`synastry` 后面直接是 `terms` / 新值:加入 `synastry/[reportId]` / 原因:issue #85 T7 合盘报告独立地址)。 + +## G2 · 文档与 T11 矛盾(必修,纯文字) + +`CHANGELOG.md` BUG-1407 那条(「合盘两人顺序跟选人页一致」)和 `PROGRESS-synastry-staging-fix-20261021.md` T10 节都写着「同一对已经有报告时,仍打开原来那份」。T11 之后这句只对**正在生成**的报告成立。改成「同一对正在生成时,仍打开那份」。 + +## G3 · 侧栏点亮的测试是空断言(必修,小) + +`frontend/tests/synastry-report-surface.test.ts`「the synastry route reuses the reader and the sidebar stays on 婚恋合盘」里 `assert.equal("/synastry/abc".startsWith("/synastry/"), true)` 永远成立,测的不是页面。任务书要求的是:渲染侧栏(`pathname = "/synastry/"`),断言当前项(`aria-current="page"` 或现有的选中类)是「婚恋合盘」、不是「我的报告」。照现有侧栏渲染测试的写法补一条,删掉这句空断言(三栏)。 + +## 交回前必跑 + +- `PATH` 用工作树 `.venv`:`python scripts/run_quality_gate.py --profile quick`,回报里写退出码与 pytest 通过 / 失败数(Windows 已知失败按名单列出)。 +- `tsc --noEmit`、`npm run lint`、`synastry-report-surface.test.ts`。 +- 全量 `npm test`、build 由 Claude 复验。 + +## 不用改的(记录在案) + +`stripLeadingSectionTitle` 在保存路径上基本走不到(质检已拦)、质检不认「标题:」紧跟标点的写法、`synastry_order` 行出现在进度列表里、后续对话的人物 id 仍按排序后的顺序——都不影响用户,本轮不动。 diff --git a/docs/testing/report-export-dialog-20261021.md b/docs/testing/report-export-dialog-20261021.md index 41e57b22..24fd5399 100644 --- a/docs/testing/report-export-dialog-20261021.md +++ b/docs/testing/report-export-dialog-20261021.md @@ -2,6 +2,8 @@ 环境:`https://staging.jyotisha.chat`,用受控测试账号。需要三类报告各一份:**有英文版**的新报告、**英文版没生成成功**的报告(如有)、**英文版上线前**生成的旧报告。没有某一类就写「无样本」,不算通过。每步在**浅色**、**深色**各走一遍;第 8 步用手机(或桌面浏览器窄到约 390px)。 +2026-10-21 执行核对:步骤没有改。浮层固定 id 是 `report-export-english`,同一次打开只出一次。选中描边、圆点和勾选框用主题色,语言分段的选中文字也用主题色,底色保持中性。 + | # | 操作 | 期望 | 浅色 | 深色 | | --- | --- | --- | --- | --- | | 1 | 打开旧报告(英文版上线前),点「导出」 | 屏幕**顶部中间**出浮层「这份报告生成于英文版上线前,重新生成即可获得中英两版。」,约 3 秒自动消失;浮层在弹窗之上、没被遮罩盖住;弹窗里**没有**这句灰字,也没有中文 / English 选项 | | | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d6af4424..18caeaf7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -88,7 +88,7 @@ Bhava Bala 保留完整十二宫力量表;Score / Weight 等领域表头不单 ## 报告核对层与独立附录(2026-09-23) -个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。 +个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Markdown 表里,只有表头含速度、Speed、度数或黄经的数值表(`.personal-report-table-numeric`)单行并按内容撑宽,放不下时在表自己的框里横滑;作用说明、建议这类整句表在页内换行。打印时所有报告表都是页宽,单元格可以换行,纸上不横滑。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。 事实表用中文列名和领域行列呈现,不把引擎路径当行名。主运、分运与小运、SAV 与 BAV、年度位置与 Saham 分为同组子表;缺失的年度行星位置明确说明,不用本命盘替代。机器溯源路径保留在数据中、不渲染。打印或保存 PDF 前自动展开此核对层全部折叠组,完成或取消后恢复原状态;打印解除表格横向滚动裁剪、重复表头,宽 BAV 表仍保留十二星座列。不新增打印入口或等待动画。 @@ -461,10 +461,11 @@ The birth-time rectification session is the consultation transcript plus a house ### Chat header - One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`. -- Ordinary consultation: the person is a quiet button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. +- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 添加一个人`, which opens `/people`. At 20 people that row is absent. - Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends. - Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner. -- Rectification, reports, and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The consultation button stays on narrow screens and ellipsizes so the header remains one line. +- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line. +- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). A click on the dialog's padding, or releasing a text selection there, does not close the dialog or drop unsaved edits. Escape still closes it, and so does a click on the backdrop outside the dialog box. Below 768px that row wraps onto a second line and the chart header grows to fit it. - With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px. - On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring. - Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen. @@ -661,7 +662,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/ ### Personal report reader - **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode). -- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** native modal ``, bottom sheet below 860px (up to 85dvh) and a centred `min(520px, 92vw)` panel at ≥860px, app colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); when the report has an English edition, a two-way segment 中文 / English (starts on the edition being read, does not switch the page) with one caption 「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」, otherwise one caption saying why there is no English; two radio cards 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」; only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The file size appears once, on the footer button: 「导出整篇 · 约 195 KB」 / 「导出 6 项 · 约 80 KB」 / disabled 「先勾选要导出的部分」. Selected states use `--color-canvas-muted` + `--color-ink`, never `--color-action-soft` (BUG-1078). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch. +- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** native modal ``, bottom sheet below 860px (up to 85dvh) and a centred `min(520px, 92vw)` panel at ≥860px, app colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); when the report has an English edition, a two-way segment 中文 / English (starts on the edition being read, does not switch the page) with one caption 「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」, otherwise no caption: opening the sheet raises one info toast (id `report-export-english`) saying why; two radio cards 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」; only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The file size appears once, on the footer button: 「导出整篇 · 约 195 KB」 / 「导出 6 项 · 约 80 KB」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch. - **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign. - **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. @@ -1160,7 +1161,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 - **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。 - **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。 - **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。 -- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。 +- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。 - **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。 - **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。 - **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。 @@ -1184,7 +1185,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | pada | 1–4,居中 | | 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 | -第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「不适用」(2026-10-15,与分盘表、报告同一口径;不再写「—」)。数字列 `tabular-nums`,符号列与宿主列居中。 +第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「/」(2026-10-21,推翻 2026-10-15 的「不适用」;与分盘表、报告同一口径)。这一格是 `chart-page-asc-status`,鼠标悬停、键盘聚焦或手机点一下,用盘面同一套气泡显示「上升点是命盘的起点,不是行星,不判断入旺或落陷」。读屏读到的是这一句。数字列 `tabular-nums`,符号列与宿主列居中。 本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。 @@ -1193,7 +1194,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | 盘型 | 列 | 说明 | | --- | --- | --- | | 本命 | 上面八列 | 不变(与基线渲染逐字节相同) | -| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示此列,其他分盘不显示。2026-10-13:同座写「是」,不同座写「否」,表头仍是「同位」(不再用横杠,避免看成没算)。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | +| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格写「/」,悬停或点按同一句「上升点是命盘的起点,不是行星,不判断入旺或落陷」(2026-10-21,推翻 2026-10-15)。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示此列,其他分盘不显示。2026-10-13:同座写「是」,不同座写「否」,表头仍是「同位」(不再用横杠,避免看成没算)。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | | 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) | | Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) | | 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index be7f7c22..6385ccb4 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -210,7 +210,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 图标说明只写名称和已有事实:上升、行星、逆行、星座名称/编号、宫位、ASC/MC 等。没有可靠度数就不写度数,不加吉凶、性格或个人运势解释。 - 鼠标悬停、键盘聚焦、手机轻点展示同一份说明,以标记旁的小气泡出现,不遮住被说明的标记。关闭说明不改变西洋相位选择。2026-09-29 晚起不再有默认提示句(原「停在或轻点盘内标记可查看说明。」已删,BUG-1101)。 - 「我的报告」生成卡片:标题写得到什么「完整本命报告」,说明一句「星盘、力量、大运、年运和瑜伽的完整数据,中英两版;生成后可离开,完成时下方自动出现。」,按钮写动作「生成报告」。标题与按钮不说同一件事(BUG-1103)。完整数据版超过约 20 万字符时,阅读页一次只画一章,目录和「上一章 / 下一章」换章;不另做全文搜索。导出仍是原来的「导出」按钮,下载 Markdown。 -- 分盘庙旺仅指入旺、自宫、落陷三档;未命中写「—」,上升写「不适用」。同位仅 D9 展示,其他分盘不留空列。缺能力声明不装作已支持。 +- 分盘庙旺仅指入旺、自宫、落陷三档;未命中写「—」。上升不是行星,状态格写「/」,说明是「上升点是命盘的起点,不是行星,不判断入旺或落陷」。同位仅 D9 展示,其他分盘不留空列。缺能力声明不装作已支持。 ## 星盘空状态、0 年大运、星历切日期 @@ -239,10 +239,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。 - 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。 +## 星盘页人名与就地改资料(2026-10-21) + +- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。 +- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。 +- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。 + ## 报告分块导出与删除 - 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。 -- 导出面板(2026-09-30 重设计):标题「导出报告」;语言「中文 / English」下一句「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」;范围「整篇报告」(副行如「7 章正文 + 22 张分盘 + 8 张核对表」)/「自选章节」(副行「只带走需要的部分」);分组名「正文 / 分盘 / 核对表」带「n/N」;按钮「导出整篇 · 约 X KB」「导出 n 项 · 约 X KB」,一项没选时「先勾选要导出的部分」。成功提示「已导出」。没有英文版时只写一句:「这份报告生成于英文版上线前,重新生成即可获得中英两版。」或「这份报告的英文版没有生成成功,只能导出中文版。」 +- 导出面板(2026-09-30 重设计):标题「导出报告」;语言「中文 / English」下一句「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」;范围「整篇报告」(副行如「7 章正文 + 22 张分盘 + 8 张核对表」)/「自选章节」(副行「只带走需要的部分」);分组名「正文 / 分盘 / 核对表」带「n/N」;按钮「导出整篇 · 约 X KB」「导出 n 项 · 约 X KB」,一项没选时「先勾选要导出的部分」。成功提示「已导出」。没有英文版时弹窗内不写字,打开时顶部浮层提示一次:「这份报告生成于英文版上线前,重新生成即可获得中英两版。」或「这份报告的英文版没有生成成功,只能导出中文版。」 - 删除确认:「确定删除这份报告?」;按钮:「删除 / 取消」;请求中:「删除中…」;失败:「报告没能删除,请稍后重试。」失败保留原行。 - 日期中文全写;深度「简版 / 标准深度 / 深度 / 研究级」;主题复用现有中文映射,未知 id 原样回退,不造含义。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7b1e3cce..7f4501cb 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -706,8 +706,10 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 [data-sidebar="content"] { -webkit-overflow-scrolling: touch; } .chat-header > div { min-width: 0; flex: 1; } .chat-header { padding: 0 var(--space-3); } - .chat-header-chart { display: none; } + .chat-header-chart:not(:has(.chart-birth-line)) { display: none; } button.chat-profile-picker-trigger { display: inline-flex; } + .chat-header-title:has(.chart-birth-line) { flex-wrap: wrap; row-gap: 2px; } + .chat-header-chart:has(.chart-birth-line) { display: flex; flex: 1 0 100%; max-width: 100%; overflow: hidden; } .onboarding-card { padding: 16px; } .message-assistant .message-content { max-width: 100%; } @@ -967,9 +969,21 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .chat-header-title { min-width: 0; display: flex; align-items: center; gap: var(--space-3); } .chat-header strong { min-width: 0; overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-md); font-weight: 500; letter-spacing: -.2px; } .chat-header-chart { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; } -.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; margin: 0; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out; } +.chart-birth-line { min-width: 0; max-width: 100%; min-height: 44px; display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: inherit; line-height: inherit; text-align: left; } +.chart-birth-line > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chart-birth-line svg { width: 14px; height: 14px; flex: 0 0 auto; } +.chart-birth-dialog { width: min(560px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 48px)); margin: auto; padding: var(--space-5); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-elevated); } +.chart-birth-dialog::backdrop { background: var(--color-scrim); } +.chart-birth-dialog .profile-form { margin-top: var(--space-3); } +.chart-birth-edit-notice { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } +.birth-time-lock { min-width: 0; margin: 0; padding: 0; border: 0; } +.birth-time-lock-note { margin: 0 0 var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.45; } +.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; gap: 4px; margin: 0; padding: 0 10px 0 12px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, border-color 120ms ease-out; } .chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; } .chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-profile-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--color-ink-tertiary); } +.chat-profile-picker-add { margin-top: var(--space-2); padding-top: var(--space-2); border-top: 1px solid var(--color-border); } +.chat-profile-picker-add a { min-height: 44px; display: flex; align-items: center; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink); font-size: var(--type-body-sm); text-decoration: none; } .chat-profile-picker-positioner { z-index: 30; outline: 0; } .chat-profile-picker-popup { width: max-content; min-width: 220px; max-width: min(320px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } .chat-profile-picker-popup[data-starting-style], .chat-profile-picker-popup[data-ending-style] { opacity: 0; transform: translateY(calc(var(--space-1) * -1)); } @@ -2168,7 +2182,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .button-primary:not(:disabled):hover, .composer .composer-row-end > button:not(:disabled):hover { background: var(--color-action-fill-hover); } .model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); } .model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); } - .chat-profile-picker-trigger:hover { background: var(--color-canvas-muted); color: var(--color-ink); } + .chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); } + .chat-profile-picker-add a:hover { background: var(--color-canvas-soft); } .chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); } .button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); } .danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); } @@ -2193,6 +2208,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; } [data-sidebar="rail"] { display: none; } .chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; } + .chat-panel:has(.chart-birth-line) { grid-template-rows: auto minmax(0, 1fr) auto; } + .chat-header:has(.chart-birth-line) { height: auto; padding-bottom: var(--space-2); } + .chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; } .chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); } .chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); } .chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; } @@ -4108,15 +4126,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-field { display: grid; gap: var(--space-2); } .report-export-segment { display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); } .report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; } -.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-ink); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent); } +.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-action); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), inset 0 0 0 1px var(--color-action-strong); } .report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; } .report-export-modes { display: grid; gap: var(--space-2); } .report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; } -.report-export-mode[aria-checked="true"] { border-color: var(--color-ink-secondary); background: var(--color-canvas-muted); } +.report-export-mode[aria-checked="true"] { border-color: var(--color-action-strong); background: transparent; } .report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; } .report-export-mode small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); } .report-export-radio { flex-shrink: 0; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 50%; } -.report-export-mode[aria-checked="true"] .report-export-radio { border: 5px solid var(--color-ink); } +.report-export-mode[aria-checked="true"] .report-export-radio { border: 5px solid var(--color-action-strong); } .report-export-list { display: grid; gap: var(--space-1); } .report-export-group + .report-export-group { border-top: 1px solid var(--color-border); padding-top: var(--space-1); } .report-export-group-head { display: flex; align-items: center; } @@ -4127,7 +4145,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-row small { margin-left: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; font-variant-numeric: tabular-nums; } .report-export-box { flex-shrink: 0; display: grid; place-items: center; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 4px; color: var(--color-canvas); } .report-export-box svg { width: 13px; height: 13px; stroke-width: 3; } -.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box { border-color: var(--color-ink); background: var(--color-ink); } +.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box { border-color: var(--color-action-strong); background: var(--color-action-strong); } .report-export-expand { flex-shrink: 0; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; } .report-export-expand svg { width: 18px; height: 18px; transition: transform 140ms ease-out; } .report-export-expand[aria-expanded="true"] svg { transform: rotate(90deg); } @@ -5125,14 +5143,28 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } line-height: 1.55; } .personal-report-key-evidence { min-width: 0; } -.personal-report-key-evidence table, -.personal-report-table-wrap table { +.personal-report-key-evidence table { width: 100%; min-width: 0; border-collapse: collapse; table-layout: fixed; text-align: left; } +/* Position tables (a header such as 速度, Speed, 度数, or 黄经) use the + numeric class: one line, as wide as the words, scrolling inside the wrap. + Other report tables stay within the page and wrap. Print forces every + report table to the page width and lets cells wrap. */ +.personal-report-table-wrap table { + width: 100%; + min-width: 0; + border-collapse: collapse; + table-layout: auto; + text-align: left; +} +.personal-report-table-numeric table { + width: max-content; + min-width: 100%; +} .personal-report-key-evidence th, .personal-report-key-evidence td, .personal-report-table-wrap th, @@ -5140,8 +5172,24 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } padding: 10px 8px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); vertical-align: top; +} +.personal-report-key-evidence th, +.personal-report-key-evidence td { overflow-wrap: anywhere; } +.personal-report-table-wrap th, +.personal-report-table-wrap td { overflow-wrap: anywhere; } +.personal-report-table-numeric th, +.personal-report-table-numeric td { + overflow-wrap: normal; + word-break: keep-all; + white-space: nowrap; +} +.report-nakshatra-en { + color: var(--color-ink-tertiary); + font-size: 0.85em; + font-weight: 400; +} .personal-report-key-evidence thead th, .personal-report-table-wrap thead th { color: var(--color-ink-secondary); @@ -5188,7 +5236,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } .personal-report-claim-status.is-success { background: var(--color-success-muted); color: var(--color-success); } .personal-report-claim-status.is-warning { background: var(--color-action-soft); color: var(--color-warning); } .personal-report-claim-status.is-danger { background: var(--color-danger-muted); color: var(--color-danger); } -.personal-report-table-wrap { max-width: 100%; overflow-x: auto; } +.personal-report-table-wrap { max-width: 100%; min-width: 0; overflow-x: auto; } .personal-report-planet-table { margin-top: var(--space-4); font-size: var(--type-caption); } .personal-report-planet-table th, .personal-report-planet-table td { padding-inline: 5px; } @@ -5424,17 +5472,17 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } .personal-report-chart-grid, .personal-report-chart-grid.is-single { grid-template-columns: 1fr; } .personal-report-chart-grid.is-single { max-width: 118mm; } - .personal-report-table-wrap { max-width: none !important; overflow: visible !important; } + .personal-report-table-wrap { max-width: 100% !important; min-width: 0 !important; overflow: visible !important; } /* Lighter on paper; the print block already forces exact colour. */ .personal-report-table-wrap thead th, .personal-report-fact-tables thead th { background: color-mix(in srgb, var(--color-ink) 5%, transparent) !important; } .personal-report-table-wrap tbody tr:nth-child(even), .personal-report-fact-tables tbody tr:nth-child(even) { background: color-mix(in srgb, var(--color-ink) 2.5%, transparent) !important; } - .personal-report-table-wrap table, + .personal-report-table-wrap table { width: 100% !important; min-width: 0 !important; table-layout: auto !important; font-size: 8pt; } .personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; } .personal-report-table-wrap th, - .personal-report-table-wrap td, + .personal-report-table-wrap td { overflow-wrap: anywhere !important; word-break: break-word !important; white-space: normal !important; } .personal-report-key-evidence th, .personal-report-key-evidence td { overflow-wrap: anywhere !important; word-break: break-word; } .personal-report-theme { padding: 6mm; margin-top: 4mm; } @@ -5846,6 +5894,26 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } outline: 2px solid var(--color-focus); outline-offset: 2px; } +/* The ascendant status cell is "/" plus the same bubble. Padding moves onto the + button so the tap target fills the cell without a second chrome. */ +.chart-page-planet-table td.chart-page-asc-status { padding: 0; } +.chart-page-asc-status-mark { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + min-height: 44px; + margin: 0; + padding: var(--space-3); + border: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + text-align: inherit; + cursor: help; +} +.chart-page-planet-table td.chart-page-planet-mid .chart-page-asc-status-mark { justify-content: center; } /* Only the small type is lifted for the chart page's narrow stage. Raising every `text` would also resize the planet symbols, which are already sized for this width. */ diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 0b12ffe0..63ffd73a 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -17,6 +17,8 @@ import { type BirthTimeIntakeProps = { readonly value: BirthTimeDraft; readonly onPatch: (patch: BirthTimeDraftPatch) => void; + /** Others cannot change a birth time that has already been saved. */ + readonly lockReportedTime?: boolean; }; @@ -76,7 +78,11 @@ function BirthClockSelect({ ); } -export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) { +export function BirthTimeIntakeFields({ value, onPatch, lockReportedTime = false }: BirthTimeIntakeProps) { + const emitTime = (patch: BirthTimeDraftPatch) => { + if (lockReportedTime) return; + onPatch(patch); + }; const groupId = useId(); const source = value.birthTimeSource; const isConfirmed = value.birthTimeStatus === "confirmed"; @@ -127,6 +133,8 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) disabled={isConfirmed} onChange={(date) => onPatch({ date })} /> + {lockReportedTime ?

填报的出生时间保存后不能再改

: null} +
{source === "legacy_import" && (

@@ -148,7 +156,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) name={`birth-time-source-${groupId}`} type="radio" value={option.value} - onChange={() => onPatch({ + onChange={() => emitTime({ birthTimeSource: option.value, birthTimeStatus: "reported", time: "", @@ -171,7 +179,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) onPatch({ reportedTime })} + onChange={(reportedTime) => emitTime({ reportedTime })} /> {source === "hospital_record" && ( @@ -186,7 +194,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) className={`birth-time-uncertainty-option ${approximateRadius === option.minutes ? "is-selected" : ""}`} key={option.minutes} type="button" - onClick={() => onPatch( + onClick={() => emitTime( option.minutes === 15 && source === "family_exact" ? { uncertaintyBeforeMinutes: 0, @@ -217,7 +225,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) ariaLabel="选择开始时间" hourLabel="开始小时" minuteLabel="开始分钟" - onChange={(declaredWindowStart) => onPatch({ declaredWindowStart })} + onChange={(declaredWindowStart) => emitTime({ declaredWindowStart })} />

); } diff --git a/frontend/src/components/chart-page/chart-birth-editor.tsx b/frontend/src/components/chart-page/chart-birth-editor.tsx new file mode 100644 index 00000000..5bd69c43 --- /dev/null +++ b/frontend/src/components/chart-page/chart-birth-editor.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import { ChartProfileForm } from "@/components/chart-profile-form"; +import { OrbitWaiting } from "@/components/orbit-waiting"; +import { useSessionList } from "@/hooks/use-session-list"; +import { + invalidateSubjectCatalog, + loadSubjectCatalog, + readCurrentSubjectAccountId, + subscribeSubjectCatalog, +} from "@/lib/current-subject"; +import { missingOtherProfileStep } from "@/lib/home-profile"; +import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import type { ChartLibraryRecord, Profile } from "@/lib/home-types"; +import { notifyError } from "@/lib/notify"; +import { saveOtherProfile } from "@/lib/other-profile-save"; +import { reportedTimeStored } from "@/lib/reported-birth-time"; +import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data"; +import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save"; + +export { reportedTimeStored }; + +export const CHART_BIRTH_EDIT_NOTICE = "改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。"; + +/** Close only when the click lands on the backdrop, outside the dialog box. Padding is inside the box. */ +export function chartBirthDialogShouldClose( + target: unknown, + currentTarget: unknown, + clientX: number, + clientY: number, + rect: { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number }, +): boolean { + if (target !== currentTarget) return false; + const inside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; + return !inside; +} + +export function birthFactsChanged(before: Profile, after: Profile): boolean { + return before.date !== after.date + || before.time !== after.time + || before.reportedTime !== after.reportedTime + || before.birthTimeSource !== after.birthTimeSource + || before.birthTimePeriod !== after.birthTimePeriod + || before.declaredWindowStart !== after.declaredWindowStart + || before.declaredWindowEnd !== after.declaredWindowEnd + || before.uncertaintyBeforeMinutes !== after.uncertaintyBeforeMinutes + || before.uncertaintyAfterMinutes !== after.uncertaintyAfterMinutes + || before.countryCode !== after.countryCode + || before.provinceCode !== after.provinceCode + || before.cityCode !== after.cityCode + || before.districtCode !== after.districtCode + || before.birthPlaceLabel !== after.birthPlaceLabel + || before.latitude !== after.latitude + || before.longitude !== after.longitude; +} + +function findRecord(library: readonly ChartLibraryRecord[], subjectId: string): ChartLibraryRecord | null { + if (subjectId === "self") return library.find((row) => row.role === "self") ?? null; + return library.find((row) => row.id === subjectId) ?? null; +} + +export function ChartBirthEditorForm({ + role, + baseline, + draft, + saving, + onChange, + onCancel, + onSubmit, +}: { + readonly role: "self" | "other"; + readonly baseline: Profile; + readonly draft: Profile; + readonly saving: boolean; + readonly onChange: (profile: Profile) => void; + readonly onCancel: () => void; + readonly onSubmit: (event: FormEvent) => void; +}) { + const self = role === "self"; + return ( + onChange(typeof next === "function" ? next(draft) : next)} + nameInputId="chart-birth-edit-name" + showAyanamsa + showGender + lockReportedBirthTime={!self && reportedTimeStored(baseline)} + notice={birthFactsChanged(baseline, draft) ? CHART_BIRTH_EDIT_NOTICE : null} + onSubmit={onSubmit} + onCancel={onCancel} + submitLabel={saving ? "保存中" : "保存"} + submitDisabled={saving || Boolean(missingOtherProfileStep(draft))} + /> + ); +} + +export function ChartBirthEditor({ + subjectId, + onClose, + onSaved, +}: { + readonly subjectId: string; + readonly onClose: () => void; + readonly onSaved?: () => Promise | void; +}) { + const dialogRef = useRef(null); + const { account, setAccount } = useSessionList(); + const [library, setLibrary] = useState(null); + const [failed, setFailed] = useState(false); + const [draft, setDraft] = useState(null); + const [baseline, setBaseline] = useState(null); + const [saving, setSaving] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + dialog.showModal(); + return () => { + dialog.close(); + }; + }, []); + + useEffect(() => { + let cancelled = false; + const refresh = () => { + void loadSubjectCatalog() + .then((next) => { + if (cancelled) return; + setLibrary(next); + setFailed(false); + const found = findRecord(next, subjectId); + if (!found) return; + setDraft((current) => current ?? { ...found.profile }); + setBaseline((current) => current ?? { ...found.profile }); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + }; + refresh(); + const unsubscribe = subscribeSubjectCatalog(refresh); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [subjectId, attempt]); + + const record = library ? findRecord(library, subjectId) : null; + + async function save(event: FormEvent) { + event.preventDefault(); + if (!record || !draft || !baseline || missingOtherProfileStep(draft) || saving) return; + setSaving(true); + try { + if (record.role === "self") { + const saved = await saveSelfProfile(draft); + const gender = draft.gender ?? null; + if (gender !== (baseline.gender ?? null)) await saveSelfGender(gender); + const profile: Profile = { ...saved.profile, gender }; + const accountId = account?.user.id || readCurrentSubjectAccountId(); + if (saved.fingerprint) pinChartSnapshotIdentity({ accountId, fingerprint: saved.fingerprint }); + clearHomeWarmSnapshot(); + invalidateEphemerisPage(); + setAccount((current) => current ? { + ...current, + profile, + ...(saved.fingerprint ? { chartBirth: { fingerprint: saved.fingerprint } } : {}), + } : current); + } else { + await saveOtherProfile(draft, { id: record.id }); + } + invalidateSubjectCatalog(); + await onSaved?.(); + onClose(); + } catch (caught) { + notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试"); + } finally { + setSaving(false); + } + } + + return ( + { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (chartBirthDialogShouldClose( + event.target, + event.currentTarget, + event.clientX, + event.clientY, + event.currentTarget.getBoundingClientRect(), + )) onClose(); + }} + > + {failed ? ( +

+ 人物列表暂时读不出来 + +

+ ) : library === null ? ( + + ) : !record || !draft || !baseline ? ( +

这份资料暂时读不出来

+ ) : ( + void save(event)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index f7717f6a..e399da3f 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -1,8 +1,10 @@ "use client"; import { useState } from "react"; +import { Pencil } from "lucide-react"; import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry"; +import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover"; import { useChartPage } from "@/hooks/use-chart-page"; @@ -24,7 +26,7 @@ import { ChartVedicTab } from "./chart-vedic-tab"; import { ChartWesternTab } from "./chart-western-tab"; export function ChartPageRoute() { - const { view, pendingLayers, layerFailures, requestLayer } = useChartPage(); + const { view, pendingLayers, layerFailures, requestLayer, reload } = useChartPage(); const ok = view?.status === "ok" ? view : null; // 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3). const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null); @@ -34,6 +36,7 @@ export function ChartPageRoute() { pendingLayers={pendingLayers} layerFailures={layerFailures} onNeedLayer={requestLayer} + onBirthSaved={reload} birthSky={birthSky.cover} birthSkyPending={birthSky.pending} /> @@ -76,6 +79,7 @@ export function ChartPageView({ initialTab = "vedic", birthSky = null, birthSkyPending = false, + onBirthSaved, }: { view: ChartViewResponse | null; pendingLayers?: ReadonlySet; @@ -86,9 +90,11 @@ export function ChartPageView({ birthSky?: BirthSkyCover | null; /** The cover is still painting: the button already shows and opens when it lands. */ birthSkyPending?: boolean; + onBirthSaved?: () => Promise | void; }) { const [tab, setTab] = useState(initialTab); const [vargaId, setVargaId] = useState("D1"); + const [editingBirth, setEditingBirth] = useState(false); function selectTab(id: ChartViewTabId) { setTab(id); @@ -105,15 +111,42 @@ export function ChartPageView({ const vedicLayer = chartTypeLayer(vargaId); const birthline = view != null && view.status === "ok" - ? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}` + ? ( + + ) : null; + async function afterBirthSaved() { + await onBirthSaved?.(); + if (tab === "dasha") onNeedLayer?.("chara"); + if (tab === "western") onNeedLayer?.("western"); + if (tab === "vedic") { + const layer = chartTypeLayer(vargaId); + if (layer) onNeedLayer?.(layer); + } + } + return ( : undefined} > + {editingBirth && view?.status === "ok" ? ( + setEditingBirth(false)} + onSaved={afterBirthSaved} + /> + ) : null} {view != null && view.status !== "ok" ? (
{view.status === "birth_profile_incomplete" ?

{CHART_VIEW_COPY.profileEntryTitle}

: null} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 596bbdb9..feffb016 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -77,10 +77,29 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) { ); } +/** The ascendant is the chart's starting point, not a planet, so it has no dignity. */ +const ASCENDANT_STATUS_HELP = "上升点是命盘的起点,不是行星,不判断入旺或落陷"; + +function AscendantStatusCell({ className }: { className?: string }) { + return ( + + + + ); +} + /** * The ascendant is a chart point: nakshatra, lord and pada come from the - * engine (BUG-1201); it has no dignity, so the status cell says 不适用. - * `house` is 1 except on the Moon chart, which counts from the Moon. + * engine (BUG-1201). It is not a planet, so the status cell is "/" and + * ASCENDANT_STATUS_HELP. `house` is 1 except on the Moon chart, which counts + * from the Moon. */ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) { return ( @@ -96,7 +115,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; {row.nakshatraLord ? : "—"} {row.pada ?? "—"} - 不适用 + ); } @@ -112,6 +131,7 @@ function NatalTable({ planets, ascendant, ascendantHouse, caption }: { caption: string; }) { return ( +
@@ -135,6 +155,7 @@ function NatalTable({ planets, ascendant, ascendantHouse, caption }: {
{caption}
+
); } @@ -166,7 +187,7 @@ function VargaAscendantRow({ row, supportsDignity, supportsVargottama }: { row: {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} 1宫 {row.natalSignLabel} - {supportsDignity && 不适用} + {supportsDignity && } {supportsVargottama && {row.vargottama ? "是" : "否"}} ); @@ -183,6 +204,7 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { supportsVargottama: varga.supportsVargottama === true, }; return ( +
@@ -202,6 +224,7 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
{varga.id} · {varga.meaning}
+
); } diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index c920826f..78606ec7 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Dispatch, FormEvent, SetStateAction } from "react"; +import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react"; import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; import { ProfileFields } from "@/components/profile-fields"; import { ProfileGenderField } from "@/components/profile-gender-field"; @@ -21,6 +21,9 @@ export type ChartProfileFormProps = { readonly cancelLabel?: string; readonly submitLabel: string; readonly submitDisabled: boolean; + /** Chart-page editor: the saved birth time of someone else cannot be changed. */ + readonly lockReportedBirthTime?: boolean; + readonly notice?: ReactNode; }; export function ChartProfileForm({ @@ -36,11 +39,18 @@ export function ChartProfileForm({ cancelLabel = "取消", submitLabel, submitDisabled, + lockReportedBirthTime = false, + notice, }: ChartProfileFormProps) { return (
{title}{description}
- + {showGender ? ( onChange((current) => ({ ...current, ayanamsa }))} /> ) : null} + {notice ?

{notice}

: null}
{onCancel ? : null} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index 1d6e00c9..c7482320 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -10,6 +10,8 @@ import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; import { fetchAccount } from "@/lib/home-cloud-sync"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import { saveOtherProfile } from "@/lib/other-profile-save"; +import { reportedTimeStored } from "@/lib/reported-birth-time"; import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data"; import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save"; import { notifyError, notifyInfo } from "@/lib/notify"; @@ -198,25 +200,16 @@ export function PeoplePage() { setSaving(true); try { const editingOther = selectedId && selected?.role === "other" ? selected.id : ""; - const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", { - method: editingOther ? "PUT" : "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ role: "other", profile: draft }), - }); - const payload = await response.json().catch(() => null) as { error?: string; profile?: { id?: string } } | null; - if (!response.ok) { - notifyError(payload?.error || "保存失败,请重试"); - return; - } + const saved = await saveOtherProfile(draft, { id: editingOther }); setEditing(false); invalidateSubjectCatalog(); await load(); - if (payload?.profile?.id) { - setSelectedId(payload.profile.id); - replaceDetailParam(payload.profile.id); + if (saved.id) { + setSelectedId(saved.id); + replaceDetailParam(saved.id); } - } catch { - notifyError("保存失败,请重试"); + } catch (caught) { + notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试"); } finally { setSaving(false); } @@ -395,6 +388,9 @@ export function PeopleArchiveView({ }: PeopleArchiveViewProps) { const remaining = MAX_SAVED_SUBJECTS - people.length; const editingSelf = editing && selectedId !== "" && selected?.role === "self"; + const lockReportedBirthTime = Boolean( + editing && selectedId !== "" && selected && selected.role === "other" && reportedTimeStored(selected.profile), + ); return (
@@ -437,12 +433,13 @@ export function PeopleArchiveView({ {editing ? ( void (editingSelf ? saveSelf(event) : saveOther(event))} onCancel={onCancelEdit} submitLabel={saving ? "保存中" : "保存"} diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index 68f0abc9..2eb9888f 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { Fragment, isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { flushSync } from "react-dom"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -23,6 +23,7 @@ import { type LongformHeading, type LongformSection, } from "@/lib/personal-report-longform-outline"; +import { formatReportSpeedColumns, reportTableWrapClass, splitNakshatraLabel } from "@/lib/report-table-reading"; function flattenText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") return String(node); @@ -38,6 +39,41 @@ function reportUrlTransform(url: string): string { return url.startsWith("#") ? url : ""; } +function tableHeaderLabels(node: ReactNode): string[] { + const labels: string[] = []; + const visit = (current: ReactNode) => { + if (Array.isArray(current)) { + current.forEach(visit); + return; + } + if (!isValidElement(current)) return; + const props = current.props as { children?: ReactNode }; + if (current.type === "th") { + labels.push(flattenText(props.children).replace(/\s+/g, " ").trim()); + return; + } + visit(props.children); + }; + visit(node); + return labels; +} + +function renderNakshatraLabel(node: ReactNode): ReactNode { + if (typeof node === "string") { + const pieces = splitNakshatraLabel(node); + if (pieces.length === 1 && !pieces[0].english) return node; + return pieces.map((piece, index) => ( + piece.english + ? {piece.text} + : {piece.text} + )); + } + if (Array.isArray(node)) { + return node.map((child, index) => {renderNakshatraLabel(child)}); + } + return node; +} + function markdownComponents( headings: readonly LongformHeading[], language: ReportLanguage, @@ -63,10 +99,11 @@ function markdownComponents( }, img: () => null, table: ({ children }) => ( -
+
{children}
), + td: ({ children }) => {renderNakshatraLabel(children)}, h1: ({ children }) => { const title = flattenText(children).trim(); const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language }); @@ -137,7 +174,7 @@ function renderMarkdown( unwrapDisallowed urlTransform={reportUrlTransform} > - {markdown} + {formatReportSpeedColumns(markdown)} ); } diff --git a/frontend/src/components/personal-report/report-export-drawer.tsx b/frontend/src/components/personal-report/report-export-drawer.tsx index 3b90612e..c0aa7af4 100644 --- a/frontend/src/components/personal-report/report-export-drawer.tsx +++ b/frontend/src/components/personal-report/report-export-drawer.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronRight, Minus, X } from "lucide-react"; import { detectPrintRestriction } from "@/lib/client-report-export"; import { downloadMarkdownReport } from "@/lib/consultation-report-export"; -import { notifyError, notifySuccess } from "@/lib/notify"; +import { notifyError, notifyInfo, notifySuccess } from "@/lib/notify"; import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks"; import { Button } from "@/components/ui/button"; import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language"; @@ -52,11 +52,22 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd const element = dialog.current; const previous = document.activeElement; element?.showModal(); + // Once per open. The sheet remounts every time it opens, so this mount effect + // is the open. Later picks and language switches must not raise it again. + // Same id collapses React Strict Mode's double invoke into one toast. + if (!hasEnglish) { + notifyInfo( + englishEdition === "unavailable" + ? "这份报告的英文版没有生成成功,只能导出中文版。" + : "这份报告生成于英文版上线前,重新生成即可获得中英两版。", + { id: "report-export-english" }, + ); + } return () => { element?.close(); if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); }; - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- notify once per open, after showModal function switchLanguage(next: ReportLanguage) { if (next === language) return; @@ -119,11 +130,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd

拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。

- ) : ( -

- {englishEdition === "unavailable" ? "这份报告的英文版没有生成成功,只能导出中文版。" : "这份报告生成于英文版上线前,重新生成即可获得中英两版。"} -

- )} + ) : null}
) : null} + {showAddPerson ? ( + + ) : null} diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 2bced290..9c1a7694 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -167,5 +167,20 @@ export function useChartPage() { }); }, [subjectId, subjectReady]); - return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer }; + const reload = useCallback(async () => { + loadedLayers.current.clear(); + inflightLayers.current.clear(); + setPendingLayers(new Set()); + setLayerFailures(new Map()); + const snapshot = await refreshChartPage(); + if (!mounted.current) return; + if (snapshot.kind === "unauthenticated") { + setView(viewFromSnapshot(snapshot)); + window.location.assign("/login"); + return; + } + setView(snapshot.view); + }, []); + + return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer, reload }; } diff --git a/frontend/src/lib/other-profile-save.ts b/frontend/src/lib/other-profile-save.ts new file mode 100644 index 00000000..c757bd71 --- /dev/null +++ b/frontend/src/lib/other-profile-save.ts @@ -0,0 +1,63 @@ +import type { Profile } from "./home-types.ts"; +import { invalidateChartPage, invalidateEphemerisPage } from "./secondary-page-data.ts"; + +const IMMUTABLE_BIRTH_TIME = "填报的出生时间不能再改"; +const SAVE_FAILED = "保存失败,请重试"; + +export type SavedOtherProfile = Readonly<{ + /** Server id when the route returns one, otherwise the id that was updated. */ + id: string | null; +}>; + +type FetchImpl = typeof fetch; + +export type SaveOtherProfileOptions = Readonly<{ + /** Stored person id updates that row (PUT). Omit or pass "" to create (POST). */ + id?: string; + fetchImpl?: FetchImpl; + /** Defaults drop the chart and ephemeris pages. Tests can pass counters. */ + invalidateChart?: () => void; + invalidateEphemeris?: () => void; +}>; + +type SavePayload = { + error?: string; + code?: string; + profile?: { id?: string }; +}; + +/** + * The one write for a person who is not the account owner. + * The chart editor and the archive page both call this. A successful save + * always drops the chart and ephemeris caches: the archive page used to + * refresh the people list only, so the chart page kept the previous chart + * (BUG-1411). + */ +export async function saveOtherProfile( + profile: Profile, + options: SaveOtherProfileOptions = {}, +): Promise { + const id = options.id?.trim() ?? ""; + const fetchImpl = options.fetchImpl ?? fetch; + const response = await fetchImpl(id ? `/api/chart-profiles/${id}` : "/api/chart-profiles", { + method: id ? "PUT" : "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ role: "other", profile }), + }); + const payload = await response.json().catch(() => null) as SavePayload | null; + if (!response.ok) throw new Error(failedSaveMessage(response.status, payload)); + (options.invalidateChart ?? invalidateChartPage)(); + (options.invalidateEphemeris ?? invalidateEphemerisPage)(); + const returned = payload?.profile?.id; + return { id: typeof returned === "string" && returned !== "" ? returned : (id || null) }; +} + +function failedSaveMessage(status: number, payload: SavePayload | null): string { + const error = payload?.error ?? ""; + const code = payload?.code ?? ""; + if (status === 409 && (code === "reported_birth_time_is_immutable" || error.includes("reported_birth_time_is_immutable"))) { + return IMMUTABLE_BIRTH_TIME; + } + return error || SAVE_FAILED; +} diff --git a/frontend/src/lib/report-table-reading.ts b/frontend/src/lib/report-table-reading.ts new file mode 100644 index 00000000..e0d4c22f --- /dev/null +++ b/frontend/src/lib/report-table-reading.ts @@ -0,0 +1,89 @@ +const NAKSHATRA_ENGLISH = /([A-Za-z][A-Za-z ]*)/g; + +export type NakshatraPiece = { readonly english: boolean; readonly text: string }; + +/** Split `阿湿毗尼(Ashwini)` so the Latin name can sit on the same line in quieter type. */ +export function splitNakshatraLabel(text: string): readonly NakshatraPiece[] { + const pieces: NakshatraPiece[] = []; + let last = 0; + for (const match of text.matchAll(NAKSHATRA_ENGLISH)) { + const index = match.index ?? 0; + if (index > last) pieces.push({ english: false, text: text.slice(last, index) }); + pieces.push({ english: true, text: match[0] }); + last = index + match[0].length; + } + if (pieces.length === 0) return [{ english: false, text }]; + if (last < text.length) pieces.push({ english: false, text: text.slice(last) }); + return pieces; +} + +function tableCells(line: string): string[] { + return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()); +} + +function isRuleRow(cells: readonly string[]): boolean { + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +/** + * Planet-position tables (a header such as 速度 / Speed / 度数 / 黄经). + * Those stay one line. Sentence tables (作用说明, 建议) do not match. + */ +const NUMERIC_REPORT_HEADER = /速度|speed|度数|degree|黄经|longitude/i; + +export function isNumericReportTable(headers: readonly string[]): boolean { + return headers.some((header) => NUMERIC_REPORT_HEADER.test(header)); +} + +export function reportTableWrapClass(headers: readonly string[]): string { + const base = "markdown-table personal-report-table-wrap"; + return isNumericReportTable(headers) ? `${base} personal-report-table-numeric` : base; +} + +/** + * Planet-position tables already stored in a report print speed to 6 places + * (`11.852534`) and the cell then breaks in half. Show 4 places. New reports + * are written with 4 places as well; this keeps older markdown in line. + */ +export function formatReportSpeedColumns(markdown: string): string { + const lines = markdown.split("\n"); + let fence = false; + let speedIndex: number | null = null; + const out: string[] = []; + for (const line of lines) { + if (line.startsWith("```")) { + fence = !fence; + speedIndex = null; + out.push(line); + continue; + } + if (fence || !line.startsWith("|")) { + speedIndex = null; + out.push(line); + continue; + } + const cells = tableCells(line); + if (isRuleRow(cells)) { + out.push(line); + continue; + } + const header = cells.findIndex((cell) => cell === "速度" || cell === "Speed"); + if (header >= 0) { + speedIndex = header; + out.push(line); + continue; + } + if (speedIndex == null || speedIndex >= cells.length) { + out.push(line); + continue; + } + const speed = cells[speedIndex]; + if (!/^-?\d+\.\d{5,}$/.test(speed)) { + out.push(line); + continue; + } + cells[speedIndex] = Number(speed).toFixed(4); + out.push(`| ${cells.join(" | ")} |`); + } + return out.join("\n"); +} diff --git a/frontend/src/lib/reported-birth-time.ts b/frontend/src/lib/reported-birth-time.ts new file mode 100644 index 00000000..64e57816 --- /dev/null +++ b/frontend/src/lib/reported-birth-time.ts @@ -0,0 +1,8 @@ +import type { Profile } from "./home-types.ts"; + +/** Someone else's reported birth time is already on file and must not be edited. */ +export function reportedTimeStored(profile: Profile): boolean { + return profile.reportedTime.trim() !== "" + || profile.declaredWindowStart.trim() !== "" + || (profile.birthTimeSource === "legacy_import" && profile.time.trim() !== ""); +} diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index b487fb3d..07bbe32d 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -265,6 +265,20 @@ export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void } } +/** Drop the chart snapshot so the next read follows a birth-data save. */ +export function invalidateChartPage(): void { + chartEpoch += 1; + chartAccountReadGeneration += 1; + const subjectId = readCurrentSubjectId(); + pinnedChartIdentity = { + accountId: readCurrentSubjectAccountId() || pinnedChartIdentity?.accountId || "", + fingerprint: "", + subjectId, + }; + chartSlot = null; + chartInflight = null; +} + let ephemerisSubject = ""; const ephemerisCache = createMemoryCache(async () => { const subjectId = readCurrentSubjectId(); diff --git a/frontend/tests/chart-birth-editor-and-report-table.test.tsx b/frontend/tests/chart-birth-editor-and-report-table.test.tsx new file mode 100644 index 00000000..41490a69 --- /dev/null +++ b/frontend/tests/chart-birth-editor-and-report-table.test.tsx @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { + ChartBirthEditorForm, + birthFactsChanged, + chartBirthDialogShouldClose, + reportedTimeStored, +} from "../src/components/chart-page/chart-birth-editor.tsx"; +import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx"; +import { emptyProfile, type Profile } from "../src/lib/home-types.ts"; +import { saveOtherProfile } from "../src/lib/other-profile-save.ts"; +import { formatReportSpeedColumns, isNumericReportTable, splitNakshatraLabel } from "../src/lib/report-table-reading.ts"; + +Object.assign(globalThis, { React }); + +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const switcher = readFileSync(new URL("../src/components/subject-switcher.tsx", import.meta.url), "utf8"); +const chartPage = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8"); +const editor = readFileSync(new URL("../src/components/chart-page/chart-birth-editor.tsx", import.meta.url), "utf8"); +const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); +const otherSave = readFileSync(new URL("../src/lib/other-profile-save.ts", import.meta.url), "utf8"); +const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); +const readerExport = readFileSync(new URL("../../scripts/pl9_reader_export.py", import.meta.url), "utf8"); + +const sample: Profile = { + ...emptyProfile, + name: "甲", + date: "1990-01-01", + time: "08:00", + reportedTime: "08:00", + birthTimeSource: "hospital_record", + birthPlaceLabel: "某市", + latitude: 31.2, + longitude: 121.5, +}; + +type Call = { url: string; init: RequestInit }; + +function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response(body == null ? "" : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +function cssRules(source: string): { selector: string; body: string }[] { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " "); + const found: { selector: string; body: string }[] = []; + for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + found.push({ selector: match[1] ?? "", body: match[2] ?? "" }); + } + return found; +} + +test("speed cells with five or more decimals become four, and longitude stays", () => { + const markdown = [ + "| 行星 | 黄经 | 速度 |", + "| --- | --- | --- |", + "| 月亮 | 123.456789 | 11.852534 |", + "| 太阳 | 10.000000 | -0.012345 |", + "", + "```", + "| 速度 |", + "| 11.852534 |", + "```", + "", + "| Planet | Speed |", + "| --- | --- |", + "| Moon | 11.852534 |", + ].join("\n"); + const formatted = formatReportSpeedColumns(markdown); + assert.match(formatted, /\| 月亮 \| 123\.456789 \| 11\.8525 \|/); + assert.match(formatted, /\| 太阳 \| 10\.000000 \| -0\.0123 \|/); + assert.match(formatted, /```\n\| 速度 \|\n\| 11\.852534 \|/); + assert.match(formatted, /\| Moon \| 11\.8525 \|/); +}); + +test("a nakshatra keeps the Chinese name and marks the Latin name", () => { + assert.deepEqual(splitNakshatraLabel("阿湿毗尼(Ashwini)"), [ + { english: false, text: "阿湿毗尼" }, + { english: true, text: "(Ashwini)" }, + ]); + assert.deepEqual(splitNakshatraLabel("Mula(根、本源)"), [ + { english: false, text: "Mula(根、本源)" }, + ]); +}); + +test("the person control is a button, and add-person follows the live cap of 20", () => { + assert.match(switcher, /ChevronDown/); + assert.match(switcher, /+ 添加一个人/); + assert.match(switcher, /href="\/people"/); + assert.match(switcher, /MAX_SAVED_SUBJECTS/); + assert.doesNotMatch(switcher, /library\.length < 5/); + assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/); + assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border:\s*1px solid var\(--color-border\)/); + assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border-radius:\s*999px/); +}); + +test("only numeric report tables stay on one line, and print wraps every table", () => { + assert.equal(isNumericReportTable(["行星", "黄经", "速度"]), true); + assert.equal(isNumericReportTable(["Planet", "Degree", "Longitude", "Speed"]), true); + assert.equal(isNumericReportTable(["落座度数"]), true); + assert.equal(isNumericReportTable(["瑜伽", "作用说明"]), false); + assert.equal(isNumericReportTable(["主题", "建议", "说明"]), false); + + const markdown = [ + "# 标题", + "", + "## 成品阅读导航", + "", + "### 行星原始位置", + "", + "| 行星 | 黄经 | 速度 |", + "| --- | --- | --- |", + "| 月亮 | 10.2500 | 13.1764 |", + "", + "### 作用说明", + "", + "| 瑜伽 | 作用说明 |", + "| --- | --- |", + "| 某组合 | 这一整句是给读者看的说明,应该在格子里换行。 |", + ].join("\n"); + const markup = renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown })); + const tables = [...markup.matchAll(/
([\s\S]*?)<\/table><\/div>/g)]; + assert.equal(tables.length, 2); + const numeric = tables.find((table) => table[2]?.includes("速度")); + const prose = tables.find((table) => table[2]?.includes("作用说明")); + assert.ok(numeric && prose); + assert.match(numeric[1] ?? "", /personal-report-table-wrap personal-report-table-numeric$/); + assert.equal(prose[1], "markdown-table personal-report-table-wrap"); + + assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*width:\s*100%/); + assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*table-layout:\s*auto/); + assert.match(css, /\.personal-report-table-numeric table\s*\{[^}]*width:\s*max-content/); + assert.match(css, /\.report-nakshatra-en\s*\{[^}]*color:\s*var\(--color-ink-tertiary\)/); + assert.match(readerExport, /_num\(row\.get\('speed'\), 4\)/); + + const regionStart = css.indexOf(".personal-report-table-wrap table {"); + const regionEnd = css.indexOf(".personal-report-chart-grid { display: grid"); + assert.ok(regionStart >= 0 && regionEnd > regionStart); + const region = css.slice(regionStart, regionEnd); + const locked = cssRules(region).filter((rule) => /nowrap|max-content/.test(rule.body)); + assert.ok(locked.length >= 2); + for (const rule of locked) { + assert.match(rule.selector, /personal-report-table-numeric/); + } + + const printStart = css.search(/@media print \{\s*\/\* Paper is always paper\./); + assert.ok(printStart >= 0); + const print = css.slice(printStart, css.indexOf("/* ---- Membership page ---- */")); + assert.doesNotMatch(print, /nowrap/); + assert.doesNotMatch(print, /max-content/); + assert.match(print, /\.personal-report-table-wrap table \{ width: 100% !important/); + assert.match(print, /white-space: normal !important/); +}); + +test("the chart page birth line drops the name and opens an editor on this page", () => { + assert.match(chartPage, /chart-birth-line/); + assert.match(chartPage, /编辑出生资料/); + assert.doesNotMatch(chartPage, /\$\{view\.profile\.name\} · \$\{view\.profile\.date\}/); + assert.match(chartPage, /onBirthSaved/); +}); + +test("someone else's saved birth time stays locked, and the lock sentence appears once", () => { + assert.equal(reportedTimeStored(sample), true); + assert.equal(reportedTimeStored({ ...emptyProfile, declaredWindowStart: "06:00" }), true); + assert.equal(reportedTimeStored({ ...emptyProfile, birthTimeSource: "legacy_import", time: "08:00" }), true); + assert.equal(reportedTimeStored(emptyProfile), false); + assert.equal(birthFactsChanged(sample, { ...sample, name: "乙" }), false); + assert.equal(birthFactsChanged(sample, { ...sample, ayanamsa: "lahiri" }), false); + assert.equal(birthFactsChanged(sample, { ...sample, date: "1991-02-02" }), true); + + const other = renderToStaticMarkup( + {}} + onCancel={() => {}} + onSubmit={() => {}} + />, + ); + assert.equal((other.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1); + assert.match(other, /birth-time-lock-note">填报的出生时间保存后不能再改/); + assert.match(other, /改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。/); + assert.match(other, /disabled/); + + const unlocked = renderToStaticMarkup( + {}} + onCancel={() => {}} + onSubmit={() => {}} + />, + ); + assert.doesNotMatch(unlocked, /填报的出生时间保存后不能再改/); + + const self = renderToStaticMarkup( + {}} + onCancel={() => {}} + onSubmit={() => {}} + />, + ); + assert.doesNotMatch(self, /填报的出生时间保存后不能再改/); + assert.doesNotMatch(self, /改了出生时间、日期或地点/); + + assert.equal((intake.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1); + assert.doesNotMatch(editor, /填报的出生时间保存后不能再改/); + assert.doesNotMatch(people, /填报的出生时间保存后不能再改/); + assert.match(people, /reportedTimeStored\(selected\.profile\)/); +}); + +test("a click on the dialog padding keeps the editor open, and the backdrop click closes it", () => { + const dialog = {}; + const rect = { left: 40, top: 30, right: 400, bottom: 500 }; + assert.equal(chartBirthDialogShouldClose(dialog, dialog, 48, 36, rect), false); + assert.equal(chartBirthDialogShouldClose(dialog, dialog, 8, 8, rect), true); + assert.equal(chartBirthDialogShouldClose({}, dialog, 8, 8, rect), false); + assert.match(editor, /chartBirthDialogShouldClose\(\s*event\.target/); + assert.match(editor, /onCancel=\{\(event\) => \{\s*event\.preventDefault\(\);\s*onClose\(\);/); + assert.doesNotMatch(editor, /if \(event\.target === event\.currentTarget\) onClose\(\)/); +}); + +test("saving someone else is one function, and a success drops the chart and ephemeris caches", async () => { + assert.match(editor, /saveOtherProfile\(draft, \{ id: record\.id \}\)/); + assert.match(people, /saveOtherProfile\(draft, \{ id: editingOther \}\)/); + assert.match(otherSave, /\(options\.invalidateChart \?\? invalidateChartPage\)\(\)/); + assert.match(otherSave, /\(options\.invalidateEphemeris \?\? invalidateEphemerisPage\)\(\)/); + + const calls: Call[] = []; + let charts = 0; + let ephemerides = 0; + const saved = await saveOtherProfile(sample, { + id: "11111111-1111-4111-8111-111111111111", + fetchImpl: fakeFetch(200, { profile: { id: "11111111-1111-4111-8111-111111111111" } }, calls), + invalidateChart: () => { charts += 1; }, + invalidateEphemeris: () => { ephemerides += 1; }, + }); + assert.equal(saved.id, "11111111-1111-4111-8111-111111111111"); + assert.equal(calls[0]?.init.method, "PUT"); + assert.equal(charts, 1); + assert.equal(ephemerides, 1); + assert.match(String(calls[0]?.url), /\/api\/chart-profiles\/11111111-1111-4111-8111-111111111111$/); + + const created: Call[] = []; + const create = await saveOtherProfile(emptyProfile, { + fetchImpl: fakeFetch(200, { profile: { id: "22222222-2222-4222-8222-222222222222" } }, created), + invalidateChart: () => { charts += 1; }, + invalidateEphemeris: () => { ephemerides += 1; }, + }); + assert.equal(created[0]?.init.method, "POST"); + assert.equal(created[0]?.url, "/api/chart-profiles"); + assert.equal(create.id, "22222222-2222-4222-8222-222222222222"); + assert.equal(charts, 2); + assert.equal(ephemerides, 2); + + await assert.rejects( + saveOtherProfile(sample, { + id: "11111111-1111-4111-8111-111111111111", + fetchImpl: fakeFetch(409, { error: "reported_birth_time_is_immutable", code: "reported_birth_time_is_immutable" }, []), + invalidateChart: () => { charts += 1; }, + invalidateEphemeris: () => { ephemerides += 1; }, + }), + /填报的出生时间不能再改/, + ); + assert.equal(charts, 2); + assert.equal(ephemerides, 2); + + await assert.rejects( + saveOtherProfile(emptyProfile, { + fetchImpl: fakeFetch(409, { error: "最多保存 20 个人的星盘", code: "subject_limit_reached" }, []), + invalidateChart: () => { charts += 1; }, + }), + /最多保存 20 个人的星盘/, + ); + await assert.rejects( + saveOtherProfile(emptyProfile, { + fetchImpl: fakeFetch(500, null, []), + }), + /保存失败,请重试/, + ); + assert.equal(charts, 2); +}); diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index 8af09300..ee321173 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -17,9 +17,15 @@ test("other chart save fails closed when cloud sync fails", () => { // `localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next))`, // delete copy "已从本地星盘库删除;云端同步失败". Cloud write failure is now failure. assert.doesNotMatch(source, /let cloudSaved = false/); - // 原值 / 新值 / 原因:旧 helper 保存 / 人物页 POST/PUT 与 catch / 死面板退役,云失败仍不得伪成功。 - assert.match(source, /method: editingOther \? "PUT" : "POST"/); - assert.match(source, /async function saveOther\([\s\S]{0,1700}catch \{[\s\S]{0,180}保存失败,请重试/); + // 原值: /method: editingOther \? "PUT" : "POST"/(写在人物页 saveOther 里) + // 新值: /method: id \? "PUT" : "POST"/(saveOtherProfile;有 id 则 PUT,否则 POST) + // 原因: 星盘编辑框和档案页共用这一处请求,页面里不再各写一份。 + const otherSave = readFileSync(new URL("../src/lib/other-profile-save.ts", import.meta.url), "utf8"); + assert.match(otherSave, /method: id \? "PUT" : "POST"/); + // 原值: /async function saveOther\([\s\S]{0,1700}catch \{[\s\S]{0,180}保存失败,请重试/ + // 新值: /async function saveOther\([\s\S]{0,1700}catch \(caught\) \{[\s\S]{0,280}保存失败,请重试/ + // 原因: 409「填报的出生时间不能再改」从共用函数抛出,档案页要显示这句,不能一律盖成保存失败。 + assert.match(source, /async function saveOther\([\s\S]{0,1700}catch \(caught\) \{[\s\S]{0,280}保存失败,请重试/); assert.doesNotMatch(source, /localStorage\.setItem\(chartLibraryStorageKey/); assert.doesNotMatch(source, /已保存到本地星盘库/); assert.match(source, /invalidateSubjectCatalog\(\);\s*await load\(\)/); diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx index 283b9014..2bac63f6 100644 --- a/frontend/tests/chart-moon-transit.test.tsx +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -92,7 +92,11 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a assert.match(ascendantRow, /Uttara Phalguni<\/td>/); assert.match(ascendantRow, /is-sun/); assert.match(ascendantRow, />1不适用不适用 { diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index 2f967c90..803a6ca5 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -186,7 +186,10 @@ test("old capability-less module cache stays hidden through direct reads and lay const d9 = view.vedic.vargas.find((item) => item.id === "D9")!; assert.equal(d9.supportsDignity, undefined); assert.equal(d9.supportsVargottama, undefined); - assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|>不适用庙旺<|>同位<|>不适用庙旺<|>同位<|chart-page-asc-status/ + // 原因:issue #87。上升状态格不再写「不适用」。旧缓存整列仍须隐藏,保护改认这个 class。 + assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|chart-page-asc-status/); assert.doesNotMatch(renderToStaticMarkup(React.createElement(ChartParamTable, { view })), /引擎|Swiss Ephemeris/); }; await h.render(null); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 6ece49c6..54a9a758 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -101,6 +101,15 @@ test("all chart types omit the engine display without removing calculation param assert.doesNotMatch(renderToStaticMarkup(), /Swiss Ephemeris|>引擎 { + const view = await okView(); + const markup = withSidebarProvider(React.createElement(ChartPageView, { view })); + assert.match(markup, /chart-birth-line/); + assert.match(markup, /1990-06-15 12:00 · 北京/); + assert.match(markup, /编辑出生资料,1990-06-15 12:00,北京/); + assert.doesNotMatch(markup, /示例 · 1990-06-15/); +}); + test("the four tabs render and can each be selected", async () => { const view = await okView(); for (const tab of CHART_VIEW_TABS) { diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx index cefabcf5..48d07b01 100644 --- a/frontend/tests/chart-varga-table.test.tsx +++ b/frontend/tests/chart-varga-table.test.tsx @@ -99,11 +99,12 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc // (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes. const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8"); const dashes = '————'; - // 原值:上升行状态格 — - // 新值:不适用 - // 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。 + // 原值:不适用 + // 新值:带 chart-page-asc-status 的按钮,可见「/」,说明句在 data-chart-help 与 aria-label + // 原因:issue #87。上升不是行星,状态格改「/」并悬停说明。基线 fixture 文件不改。 const filled = 'Uttara Phalguni☉' - + '1不适用'; + + '1' + + ''; assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row"); assert.equal(table, baseline.replace(dashes, filled)); }); @@ -117,7 +118,12 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async ( assert.match(ascendant, /Uttara Phalguni<\/td>/); assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun"); assert.match(ascendant, />1不适用不适用\/)[key]; @@ -155,7 +161,11 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co assert.match(row(markup, "火星"), />中性\(Neutral\)入敌\(Enemy Sign\)极敌\(Great Enemy\)不适用不适用([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)不适用不适用 { diff --git a/frontend/tests/report-buttons-scope-20260928.test.ts b/frontend/tests/report-buttons-scope-20260928.test.ts index cfde5db1..726f2acf 100644 --- a/frontend/tests/report-buttons-scope-20260928.test.ts +++ b/frontend/tests/report-buttons-scope-20260928.test.ts @@ -110,12 +110,41 @@ test("the BUG-1078 report-only override is gone; the selected export preset stay assert.doesNotMatch(cssNoComments, /report-center-generate/); assert.doesNotMatch(generateSource, /report-center-generate/); assert.doesNotMatch(cssNoComments, /\[class~="bg-primary"\]/); - // 原值:`.report-export-presets [aria-pressed="true"]` 用中性底 + ink,不用 action-soft - // 新值:套餐已删(2026-09-30 导出面板重设计);同一约束落到新的选中态——范围单选卡与语言分段 - // 原因:BUG-1078 的主语(导出面板选中态不得用 --color-action-soft 深色块)不变 - for (const selector of ['.report-export-mode[aria-checked="true"]', '.report-export-segment button[aria-checked="true"]']) { - const selected = rule(selector); - assert.doesNotMatch(selected, /--color-action-soft|--color-action\b/); - assert.match(selected, /--color-ink|--color-canvas/); + // 原值:范围单选卡与语言分段 doesNotMatch(/--color-action-soft|--color-action\b/) 且 match(/--color-ink|--color-canvas/) + // 新值:四条选中规则不得用 --color-action-soft / --color-action-fill;描边、圆点、勾选框和语言分段用 --color-action-strong(语言文字用 --color-action);底色只许 transparent / --color-canvas*(勾选框 18px 小方块的 background 可以是 --color-action-strong) + // 原因:issue #90 推翻 DESIGN「Selected states use canvas-muted + ink」。BUG-1078 的主语(不得用 --color-action-soft 深色块)保留。旧正则 --color-action\b 会误伤 --color-action-strong + const selected = { + mode: rule('.report-export-mode[aria-checked="true"]'), + radio: rule('.report-export-mode[aria-checked="true"] .report-export-radio'), + box: rule('.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box'), + segment: rule('.report-export-segment button[aria-checked="true"]'), + }; + for (const body of Object.values(selected)) { + assert.doesNotMatch(body, /--color-action-soft|--color-action-fill\b/); } + assert.match(selected.mode, /border-color: var\(--color-action-strong\);/); + assert.match(selected.mode, /background: transparent;/); + assert.match(selected.radio, /border: 5px solid var\(--color-action-strong\);/); + assert.match(selected.box, /border-color: var\(--color-action-strong\);/); + assert.match(selected.box, /background: var\(--color-action-strong\);/); + assert.doesNotMatch(selected.box, /(? hit[1].trim()); +} diff --git a/frontend/tests/report-english-edition.test.tsx b/frontend/tests/report-english-edition.test.tsx index 5a2c5b47..2124459a 100644 --- a/frontend/tests/report-english-edition.test.tsx +++ b/frontend/tests/report-english-edition.test.tsx @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import React from "react"; +import { toast } from "sonner"; import { ReportActions } from "../src/components/personal-report/report-actions"; import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer"; @@ -174,10 +175,9 @@ test("the language switch only exists when both editions do", async () => { }); test("the export drawer suggests English for AI only when it can switch", async () => { - // 原值:中文时出现提示条 +「切到 English」按钮(切换整页语言);英文时说明句「导出的是英文版」 - // 新值:导出面板自带「中文 / English」选择(不切换整页),下方一句「拿去问 ChatGPT、Claude 等 AI,建议选 English」; - // 选 English 后导出的就是英文块;没有英文版时只剩一句说明 - // 原因:2026-09-30 导出面板重设计(两步:语言 → 整篇 / 自选) + // 原值:没有英文版时,弹窗正文含「重新生成即可获得中英两版」 + // 新值:弹窗正文不含该句;toast.info 调用一次,文案为该句,id 为 report-export-english;仍没有 English 选项;英文可用时仍有「建议选 English」 + // 原因:issue #90 把英文不可用说明改成打开弹窗时的顶部浮层。更早的一轮(2026-09-30)已经把整页「切到 English」改成弹窗内的语言选择 const zhBlocks = buildReportExportBlocks({ markdown: zhReader.markdown }); const enBlocks = buildReportExportBlocks({ markdown: `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, language: "en" }); const h = createClientLifecycleHarness(); @@ -194,9 +194,19 @@ test("the export drawer suggests English for AI only when it can switch", async await h.render( {}} englishEdition="ready" />); assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true", "starts on the edition being read"); await h.render(null); - await h.render( {}} englishEdition="legacy" />); - assert.ok(h.container.text.includes("重新生成即可获得中英两版")); - assert.ok(!h.elements().some(node => node.getAttribute("role") === "radio" && node.text === "English")); + const legacy = "这份报告生成于英文版上线前,重新生成即可获得中英两版。"; + const infos: { message: string; id?: string | number }[] = []; + const previousInfo = toast.info; + toast.info = (message, options) => { + infos.push({ message: String(message), id: options?.id }); + return infos.length; + }; + try { + await h.render( {}} englishEdition="legacy" />); + assert.equal(h.container.text.includes(legacy), false); + assert.deepEqual(infos, [{ message: legacy, id: "report-export-english" }]); + assert.ok(!h.elements().some(node => node.getAttribute("role") === "radio" && node.text === "English")); + } finally { toast.info = previousInfo; } } finally { await h.close(); } }); diff --git a/frontend/tests/report-export-drawer.test.tsx b/frontend/tests/report-export-drawer.test.tsx index 8bf43677..d302afdc 100644 --- a/frontend/tests/report-export-drawer.test.tsx +++ b/frontend/tests/report-export-drawer.test.tsx @@ -16,6 +16,8 @@ const blocks = buildReportExportBlocks({ markdown: reader.markdown, factTables: const editions = { zh: blocks }; const source = readFileSync(new URL("../src/components/personal-report/report-export-drawer.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +// Opening a sheet without English notifies. Keep the real toaster out of these mounts. +toast.info = () => 1; type Harness = ReturnType; const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; }; @@ -211,6 +213,52 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed assert.match(css, /\.report-export-footer \{[^}]*flex-shrink: 0;/); }); +test("an unavailable English edition is one info toast per open, not a caption", async () => { + const legacy = "这份报告生成于英文版上线前,重新生成即可获得中英两版。"; + const failed = "这份报告的英文版没有生成成功,只能导出中文版。"; + const infos: { message: string; id?: string | number }[] = []; + const previous = toast.info; + const h = createClientLifecycleHarness(); + toast.info = (message, options) => { + assert.equal(h.elements().find(node => node.tagName === "DIALOG")?.open, true, "notify after showModal"); + infos.push({ message: String(message), id: options?.id }); + return infos.length; + }; + try { + await h.render( {}} englishEdition="legacy" />); + assert.deepEqual(infos, [{ message: legacy, id: "report-export-english" }]); + assert.equal(h.container.text.includes(legacy), false); + assert.equal(h.container.text.includes(failed), false); + await h.event(radio(h, "自选章节")); + const chapter = blocks.find(block => block.group === "body")!; + await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!); + await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!); + const expand = h.elements().find(node => node.getAttribute("aria-label") === "展开分盘"); + assert.ok(expand, "展开分盘"); + await h.event(expand); + assert.equal(infos.length, 1, "picking chapters does not notify again"); + await h.render(null); + await h.render( {}} englishEdition="legacy" />); + assert.equal(infos.length, 2, "opening again notifies once more"); + assert.equal(infos[1]?.id, "report-export-english"); + + await h.render(null); + await h.render( {}} englishEdition="unavailable" />); + assert.equal(infos.length, 3); + assert.deepEqual(infos[2], { message: failed, id: "report-export-english" }); + assert.equal(h.container.text.includes(failed), false); + assert.equal(h.container.text.includes(legacy), false); + + await h.render(null); + await h.render( {}} englishEdition="ready" />); + assert.equal(infos.length, 3, "a ready English edition does not notify"); + assert.ok(h.container.text.includes("建议选 English")); + } finally { + toast.info = previous; + await h.close(); + } +}); + test("the drawer shows the real download size, not a character count (BUG-1094)", async () => { const body = blocks.filter(block => block.group === "body"); const charts = blocks.filter(block => block.group === "chart"); diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index f5e7883b..89deb831 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1042,7 +1042,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: f"| {_cell(_token(planet))} | {_cell(row.get('r_c') or row.get('retrograde') and 'R' or 'C')} | " f"{_cell(_token(row.get('sign')))} | {_cell(_degree(sign_degree))} | {_cell(_degree(longitude))} | " f"{_cell(row.get('house'))} | {_cell(row.get('nakshatra'))} | {_cell(row.get('pada') or row.get('nakshatra_pada'))} | " - f"{_cell(_num(row.get('speed'), 6))} |" + f"{_cell(_num(row.get('speed'), 4))} |" ) lines.append('') lines.extend([ @@ -1054,10 +1054,9 @@ def render_pl9_parity_markdown(packet: dict) -> str: if ascendant: info = _ascendant_report_fields(ascendant) asc_label = 'Ascendant' if _lang == 'en' else '上升' - dignity = 'not applicable' if _lang == 'en' else '不适用' lines.append( f"| {_cell(asc_label)} | {_cell(_token(info['sign_lord']))} | {_cell(_token(info['nakshatra_lord']))} | " - f"- | - | {_cell(dignity)} | - |" + f"- | - | {_cell('/')} | - |" ) for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): row = _dict(planets.get(planet)) @@ -1070,6 +1069,17 @@ def render_pl9_parity_markdown(packet: dict) -> str: ) lines.extend([ '', + *( + [ + ( + 'The ascendant is the starting point of the chart, not a planet, so it has no dignity.' + if _lang == 'en' + else '上升点是命盘的起点,不是行星,不判断入旺或落陷' + ), + '', + ] + if ascendant else [] + ), ( 'Sign lord, star lord, sub lord and sub-sub lord name the ruler of the planet sign, nakshatra and KP subdivision. Strength ratio compares Shadbala with the required strength. A ratio of 1 or more meets it.' if _lang == 'en' diff --git a/tests/test_report_chart_blank_columns.py b/tests/test_report_chart_blank_columns.py index 13199233..98228688 100644 --- a/tests/test_report_chart_blank_columns.py +++ b/tests/test_report_chart_blank_columns.py @@ -189,6 +189,22 @@ def test_full_data_chart_section_draws_each_ready_chart_once(editions) -> None: assert "##### Navamsha" not in section and "##### p7 · Navamsha" not in section +def _paragraph_after_heading_table(markdown: str, heading: str) -> str: + """The first non-empty line after the heading's table.""" + lines = markdown.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("#") and heading in line) + seen_row = False + for line in lines[start + 1:]: + if line.startswith("|"): + seen_row = True + continue + if seen_row and line.strip(): + return line.strip() + if line.startswith("#"): + break + raise AssertionError(f"no paragraph after {heading}") + + def _heading_table(markdown: str, heading: str) -> list[list[str]]: """Like _table_after, but the lord table is a #### heading, not ###.""" lines = markdown.splitlines() @@ -219,12 +235,29 @@ def test_birth_position_table_starts_with_the_ascendant(editions) -> None: assert zh_rows[1][5] == "1" lords = _heading_table(zh, "行星主星与状态") assert lords[1][0] == "上升" - assert lords[1][5] == "不适用" + # 原值:lords[1][5] == "不适用" + # 新值:"/" + # 原因:issue #87。上升不是行星,状态格写「/」,说明放到表下。 + assert lords[1][5] == "/" assert lords[1][2] in {fields["nakshatra_lord"], lord_zh.get(fields["nakshatra_lord"])} + assert _paragraph_after_heading_table(zh, "行星主星与状态") == ( + "上升点是命盘的起点,不是行星,不判断入旺或落陷" + ) + en_lords = _heading_table(en, "Planet Lords and Dignity") + assert en_lords[1][0] in {"Ascendant", "Lagna", "上升"} + assert en_lords[1][5] == "/" + assert _paragraph_after_heading_table(en, "Planet Lords and Dignity") == ( + "The ascendant is the starting point of the chart, not a planet, so it has no dignity." + ) en_rows = _heading_table(en, "Planetary Raw Positions") assert en_rows[1][0] in {"Ascendant", "Lagna", "上升"} assert fields["nakshatra"] in en_rows[1][6] assert en_rows[1][7] == str(fields["nakshatra_pada"]) + for row in (*zh_rows[2:], *en_rows[2:]): + speed = row[8] + if speed == "-": + continue + assert re.fullmatch(r"-?\d+\.\d{4}", speed), speed def test_zh_hygiene_rejects_glued_english(editions) -> None: