Compare commits

...
Author SHA1 Message Date
jesse-uxGrok (agent loop)Cursor
3cbd0cbd33 fix(chart): 报告数值表才单行,保存他人共用一处,点内边距不关编辑框
F1 只有表头含速度、度数或黄经的表保持单行,其余表和打印都换行。
F2 保存他人收成 saveOtherProfile,档案页保存后也清星盘和星历缓存(BUG-1405)。
F3 编辑框只在点到框外时关闭,内边距不再丢掉未保存内容(BUG-1406)。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:13:42 +08:00
jesse-ux 8652ccf9ac merge origin/staging into codex/chart-subject-table-20261021 2026-10-22 00:41:58 +08:00
Jesse_ChenandClaude Opus 5.5 2806bd5b75 docs(tasks): full data parity part 3 brief (agent loop, issue #93)
PyJHora runs in a subprocess worker for the annual method reference only;
installing it must leave every other output byte-identical (seven switch
points pinned, parent process never imports jhora). Inputs default to
local birth time and the requested year (D6, awaiting product confirm).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-22 00:37:54 +08:00
Jesse_ChenandClaude Opus 5.5 b662bf59fc docs(tasks): chart subject/table fix brief (agent loop, issue #86 round 1)
nowrap and max-content hit every report table and break print; the other-
person save is a drifted copy of the archive page; a click on the dialog
padding closes the editor and drops edits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-22 00:34:24 +08:00
Jesse_ChenandClaude Opus 5.5 a9bf16816b docs(tasks): Goel Jaimini strength port brief (agent loop, issue #94)
Four upstream files from 5fb8379c (the issue missed trikona_dasha.py),
root budget 448 -> 452 per the #81 decision A precedent, wiring into the
jaimini and full-reading outputs only. The source string becomes a plain
book citation; frozen rectification identity must not move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-22 00:32:53 +08:00
Jesse_ChenandClaude Opus 5.5 f90020efda docs(tasks): full data parity part 1 brief (agent loop, issue #91)
Stage A renders what the engine already computes (71 appendix sections in
6 chapters, zh appendix from its own renderer, export sign names); stage B
ports the missing annual, dasha sub-period and Yogi point calculations on
our sidereal frame. No size cap per product; overturns fix2 F5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-21 23:42:05 +08:00
Jesse_ChenandClaude Opus 5.5 8c63f31f12 docs(tasks): chart dasha Yogini switch brief (agent loop, issue #88)
Product decided 1A 2A: Yogini sub-periods proportional with internal
checks (majors still matched against PyJHora); Ashtottari leaves this
ticket because its start table disagrees with PyJHora on 8 of 12
nakshatras and will get its own issue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-21 23:26:55 +08:00
jesse-uxGrok (agent loop)Cursor
885bd5665f fix(chart): 星盘页可以改出生资料,人名看得出能点,报告行星表不再挤断
报告行星表按内容定列宽,速度显示到 4 位小数,星宿英文名不再从中间拆开。星盘页出生行就地编辑;别人已保存的出生时间仍锁住。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:38:23 +08:00
31 changed files with 1885 additions and 51 deletions
+14
View File
@@ -1,5 +1,19 @@
# 印度占星 Skill 更新日志
## 2026-10-22 — 报告整句表恢复换行,档案页改别人后星盘跟着更新
- 报告里只有行星位置这类数值表保持单行,放不下时在表自己的框里横滑。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里。
- 星盘页和星盘档案保存别人,走同一处。档案页保存成功后,星盘页和星历页不再留着旧盘。
- 点编辑框内侧的空白边不会关掉框,未保存的修改还在。Esc 仍然关闭。别人已经填过的出生时间,那句「保存后不能再改」只出现在时间旁边。
- Skill 版本不变(BUG-1404 收窄,BUG-1405、BUG-1406)。
## 2026-10-21 — 星盘页能改出生资料,人名看得出能点,报告行星表不再从中间断开
- 星盘、星历、报告顶栏的人名是带箭头的圆角按钮。名单没满 20 人时,底部可以「+ 添加一个人」。
- 星盘页标题旁只留日期、时间和地点,点这一行就能改。本人都能改;别人已经保存的出生时间仍然不能改。改了日期、时间或地点会提示:之前校正采用的时间作废,星盘按新填的时间重排。
- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。
- Skill 版本不变(BUG-1404)。
## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型
- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。
+48
View File
@@ -37016,3 +37016,51 @@
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1404 | 报告行星表把星宿和速度从中间拆开
- 状态: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-1405 | 档案页改了别人的出生资料,星盘和星历仍是旧盘
- 状态: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-1404。
- 复发自:无。
- 修复版本:分支 `codex/chart-subject-table-20261021`。
## BUG-1406 | 点星盘编辑框的内边距会关掉框并丢掉没保存的修改
- 状态: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`。
@@ -0,0 +1,31 @@
# 进度:星盘页人名、就地改资料、报告行星表(issue #86)
规格是 Gitea issue #86,没有另写任务书。分支 `codex/chart-subject-table-20261021`,基线 `origin/staging` `bd3ce0e7b`。
## 改了什么
- 报告阅读页的 Markdown 表按内容定列宽。单词和数字不从中间断开。放不下时只在表格自己的框里横向滑动,页面不被撑开。星宿英文名用浅色小字,跟在中文后面同一行。速度显示并写出小数点后 4 位。打印不再改回固定列宽和任意断行。记入 BUG-1404。
- 星盘、星历、报告和对话顶栏的人名是带箭头的圆角按钮。名单没满 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` | 本机执行约定交给验收再跑 | 未跑 |
## 浏览器
本机没有对着已登录的星盘页点过这三处。验收时请看:人名按钮、弹出层里的添加入口、星盘页出生行和编辑框、一份带行星表的报告在窄屏下横向滑动。
@@ -0,0 +1,46 @@
# 进度:星盘页就地编辑 / 报告表换行(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-1405。
- **F3** 编辑框只在点击落在框外(遮罩)时关闭。点内边距、或拖选文字后松手落在内边距上,不关闭、不丢掉未保存内容。Esc 仍关闭。记为 BUG-1406。
- 测试文件改名为 `frontend/tests/chart-birth-editor-and-report-table.test.tsx`。仓库里没有别的测试按源码断言旧文件名;BUG-1404 的验证路径已改成新名字。
- 「填报的出生时间保存后不能再改」只留在时间旁边那一行。表单说明里不再写。档案页编辑已经保存过出生时间的他人时,也锁住这一项,所以同一句仍然会出现,但只从这一处来。
## 断言改动
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 本单测试里的表格 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-1405(档案页缓存)和 BUG-1406(点内边距关闭)。修复单写明 #85 占用 1404~1408,合入前会重编。没有再用 1404。
- 没有在浏览器里点已登录的星盘页、档案页和报告页。
## 未做
- 没有 push。
- 没有跑全量 `npm test`,没有 `next build`。
+6
View File
@@ -251,6 +251,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| issue #86(无独立任务书) | [PROGRESS](PROGRESS-chart-subject-table-20261021.md) | 星盘页人名看得出能点、出生资料就地改、报告行星表不再从中间断开(BUG-1404) | 待验收 | `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 一致) |
@@ -515,3 +516,8 @@
| `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-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `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 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 待验收 | `codex/chart-subject-table-20261021` |
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
@@ -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`),大运标签「<Yogini 名> / <行星中文>」(例「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<ChartViewResponse \| null>(() => 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。
@@ -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 · 点编辑框内边距会关掉编辑框、丢失未保存的修改(必修)
位置:同文件 `<dialog onClick>` 里的 `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%`、可换行)不让。
@@ -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)。
@@ -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(<jhora>/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 只追加「决策推翻」一段,不改原文、不改状态。
@@ -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。
+5 -4
View File
@@ -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.
@@ -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-1405),再打开仍是刚保存的资料。
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
+6
View File
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 详情两格时间卡写「填报时间」「排盘时间」;有校正结果时排盘卡写「排盘时间 · 已校正」。岁差写设置里的名字,例如「Raman(默认)」,不写小写键。
- 快捷入口只写「和 TA 对话 / 看星盘 / 生成报告」;他人另有「和我合盘」「编辑」。本人底部灰字「本人不能删除」(不加句号)。手机详情顶部是「‹ 返回」。
## 星盘页人名与就地改资料(2026-10-21)
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
## 报告分块导出与删除
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
+57 -9
View File
@@ -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; }
@@ -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; }
+16 -7
View File
@@ -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 ? <p className="birth-time-lock-note">填报的出生时间保存后不能再改</p> : null}
<fieldset className="birth-time-lock" disabled={lockReportedTime}>
{source === "legacy_import" && (
<p className="birth-time-legacy-note">
@@ -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)
<BirthClockSelect
key={`${source}-${value.reportedTime || value.time}`}
value={value.reportedTime || value.time}
onChange={(reportedTime) => onPatch({ reportedTime })}
onChange={(reportedTime) => emitTime({ reportedTime })}
/>
</label>
{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 })}
/>
</label>
<label>
@@ -228,13 +236,13 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
ariaLabel="选择结束时间"
hourLabel="结束小时"
minuteLabel="结束分钟"
onChange={(declaredWindowEnd) => onPatch({ declaredWindowEnd })}
onChange={(declaredWindowEnd) => emitTime({ declaredWindowEnd })}
/>
</label>
<button
className="button-secondary birth-time-skip-button"
type="button"
onClick={() => onPatch({
onClick={() => emitTime({
birthTimeSource: "unknown",
reportedTime: "",
birthTimePeriod: "",
@@ -255,6 +263,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<p>可以直接开始生时校正:先从你记得的经历比出大致时段。</p>
</div>
)}
</fieldset>
</div>
);
}
@@ -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<HTMLFormElement>) => void;
}) {
const self = role === "self";
return (
<ChartProfileForm
title={self ? "编辑我的资料" : "编辑人物"}
description="称呼和出生资料。"
value={draft}
onChange={(next) => 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> | void;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const { account, setAccount } = useSessionList();
const [library, setLibrary] = useState<ChartLibraryRecord[] | null>(null);
const [failed, setFailed] = useState(false);
const [draft, setDraft] = useState<Profile | null>(null);
const [baseline, setBaseline] = useState<Profile | null>(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<HTMLFormElement>) {
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 (
<dialog
ref={dialogRef}
className="chart-birth-dialog"
aria-label={record?.role === "other" ? "编辑人物" : "编辑我的资料"}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (chartBirthDialogShouldClose(
event.target,
event.currentTarget,
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
)) onClose();
}}
>
{failed ? (
<p>
人物列表暂时读不出来
<button type="button" onClick={() => { setFailed(false); setLibrary(null); setAttempt((current) => current + 1); }}>重试</button>
</p>
) : library === null ? (
<OrbitWaiting label="人物资料还在读" />
) : !record || !draft || !baseline ? (
<p>这份资料暂时读不出来</p>
) : (
<ChartBirthEditorForm
role={record.role}
baseline={baseline}
draft={draft}
saving={saving}
onChange={setDraft}
onCancel={onClose}
onSubmit={(event) => void save(event)}
/>
)}
</dialog>
);
}
@@ -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<ChartViewLayer>;
@@ -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> | void;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState<ChartTypeId>("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}`
? (
<button
type="button"
className="chart-birth-line"
aria-label={`编辑出生资料,${view.profile.date} ${view.profile.time},${view.profile.placeLabel}`}
onClick={() => setEditingBirth(true)}
>
<span>{`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}</span>
<Pencil aria-hidden="true" />
</button>
)
: 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 (
<SecondaryPageShell
title="星盘"
note={birthline}
actions={view?.status === "ok" && (birthSky || birthSkyPending) ? <BirthSkyEntry cover={birthSky ?? null} /> : undefined}
>
{editingBirth && view?.status === "ok" ? (
<ChartBirthEditor
subjectId={view.subjectId ?? "self"}
onClose={() => setEditingBirth(false)}
onSaved={afterBirthSaved}
/>
) : null}
{view != null && view.status !== "ok" ? (
<section className="chart-page-message">
{view.status === "birth_profile_incomplete" ? <h2>{CHART_VIEW_COPY.profileEntryTitle}</h2> : null}
+13 -2
View File
@@ -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 (
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
<ProfileFields value={value} onChange={onChange} nameInputId={nameInputId} />
<ProfileFields
value={value}
onChange={onChange}
nameInputId={nameInputId}
lockReportedBirthTime={lockReportedBirthTime}
/>
{showGender ? (
<ProfileGenderField
idPrefix={nameInputId}
@@ -54,6 +64,7 @@ export function ChartProfileForm({
onChange={(ayanamsa) => onChange((current) => ({ ...current, ayanamsa }))}
/>
) : null}
{notice ? <p className="chart-birth-edit-notice" role="status">{notice}</p> : null}
<div className="dialog-actions">
{onCancel ? <button className="button-secondary" type="button" onClick={onCancel}>{cancelLabel}</button> : null}
<button className="button-primary save-profile" type="submit" disabled={submitDisabled}>{submitLabel}</button>
+13 -16
View File
@@ -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 (
<div className="people-archive" data-view={view}>
<section className="people-archive-list" aria-label="星盘档案">
@@ -437,12 +433,13 @@ export function PeopleArchiveView({
{editing ? (
<ChartProfileForm
title={editingSelf ? "编辑我的资料" : selectedId ? "编辑人物" : "添加一个人"}
description={editingSelf ? "称呼和出生资料。" : "称呼和出生资料。填报的出生时间保存后不能再改。"}
description="称呼和出生资料。"
value={draft}
onChange={onDraftChange}
nameInputId="people-archive-name"
showAyanamsa
showGender
lockReportedBirthTime={lockReportedBirthTime}
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
onCancel={onCancelEdit}
submitLabel={saving ? "保存中" : "保存"}
@@ -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
? <span className="report-nakshatra-en" key={index}>{piece.text}</span>
: <Fragment key={index}>{piece.text}</Fragment>
));
}
if (Array.isArray(node)) {
return node.map((child, index) => <Fragment key={index}>{renderNakshatraLabel(child)}</Fragment>);
}
return node;
}
function markdownComponents(
headings: readonly LongformHeading[],
language: ReportLanguage,
@@ -63,10 +99,11 @@ function markdownComponents(
},
img: () => null,
table: ({ children }) => (
<div className="markdown-table personal-report-table-wrap">
<div className={reportTableWrapClass(tableHeaderLabels(children))}>
<table>{children}</table>
</div>
),
td: ({ children }) => <td>{renderNakshatraLabel(children)}</td>,
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)}
</ReactMarkdown>
);
}
+16 -2
View File
@@ -8,7 +8,17 @@ import { applyBirthTimeDraftPatch } from "@/lib/birth-time-intake-model";
import { invalidateCandidateAfterLocationChange } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
export function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
export function ProfileFields({
value,
onChange,
nameInputId,
lockReportedBirthTime = false,
}: {
value: Profile;
onChange: (profile: Profile) => void;
nameInputId?: string;
lockReportedBirthTime?: boolean;
}) {
const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`;
return (
<>
@@ -28,7 +38,11 @@ export function ProfileFields({ value, onChange, nameInputId }: { value: Profile
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
<BirthTimeIntakeFields
value={value}
lockReportedTime={lockReportedBirthTime}
onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))}
/>
<BirthLocationFields
value={value}
onChange={(next) => onChange(invalidateCandidateAfterLocationChange(value, next))}
+9 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import { useId, useRef, useState } from "react";
import {
@@ -41,6 +41,8 @@ export function SubjectSwitcher({
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listId = useId();
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
// Issue #86 said “5 people”. The live archive cap is 20 (`MAX_SAVED_SUBJECTS`).
const showAddPerson = (status === "ready" || status === "degraded") && library.length < MAX_SAVED_SUBJECTS;
function closePicker() {
setOpen(false);
@@ -77,6 +79,7 @@ export function SubjectSwitcher({
aria-label={`当前星盘:${label}`}
>
<span className="chat-profile-picker-name">{label}</span>
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
@@ -115,6 +118,11 @@ export function SubjectSwitcher({
))}
</div>
) : null}
{showAddPerson ? (
<div className="chat-profile-picker-add">
<a href="/people">+ 添加一个人</a>
</div>
) : null}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
+16 -1
View File
@@ -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 };
}
+63
View File
@@ -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-1405).
*/
export async function saveOtherProfile(
profile: Profile,
options: SaveOtherProfileOptions = {},
): Promise<SavedOtherProfile> {
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;
}
+89
View File
@@ -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");
}
+8
View File
@@ -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() !== "");
}
+14
View File
@@ -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<EphemerisPageSnapshot>(async () => {
const subjectId = readCurrentSubjectId();
@@ -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(/<div class="(markdown-table [^"]*)">([\s\S]*?)<\/table><\/div>/g)];
assert.equal(tables.length, 2);
const numeric = tables.find((table) => table[2]?.includes("<th>速度</th>"));
const prose = tables.find((table) => table[2]?.includes("<th>作用说明</th>"));
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(
<ChartBirthEditorForm
role="other"
baseline={sample}
draft={{ ...sample, date: "1991-02-02" }}
saving={false}
onChange={() => {}}
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(
<ChartBirthEditorForm
role="other"
baseline={emptyProfile}
draft={emptyProfile}
saving={false}
onChange={() => {}}
onCancel={() => {}}
onSubmit={() => {}}
/>,
);
assert.doesNotMatch(unlocked, /填报的出生时间保存后不能再改/);
const self = renderToStaticMarkup(
<ChartBirthEditorForm
role="self"
baseline={sample}
draft={sample}
saving={false}
onChange={() => {}}
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);
});
@@ -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\(\)/);
+9
View File
@@ -101,6 +101,15 @@ test("all chart types omit the engine display without removing calculation param
assert.doesNotMatch(renderToStaticMarkup(<ChartWesternTab view={view} />), /Swiss Ephemeris|>引擎</);
});
test("the birth line is the date and place, and the name is not repeated there", async () => {
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) {
+1 -1
View File
@@ -1037,7 +1037,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([
+5
View File
@@ -225,6 +225,11 @@ def test_birth_position_table_starts_with_the_ascendant(editions) -> None:
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: