Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 6dd1ff7d0c 记下星历路径核查:本机会退回低精度,生产镜像未打开(BUG-1322)
T5 不改代码。进度、更新日志和任务索引一起补上。上游 e1909724 只作同类问题参照。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:50:09 +08:00
jesse-uxandGrok (agent loop) aae3fea011 接口大运年长改成 365.25 天,和推运脚本同一天(BUG-1321,上游 4d543209)
只改兜底时间线的结束日和进入本运年数这两处。同一大运起止相差 0 天。pytest tests/test_dasha_year_length_alignment.py 2 通过。增长合同仍过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:49:21 +08:00
jesse-uxandGrok (agent loop) 4cdb997800 瑜伽角宫主和三方主按名字排序,不再随进程哈希变(BUG-1320,上游 0337b77f)
同一张盘在两个哈希种子下,宫主名单和瑜伽参与者顺序一致。pytest tests/test_yoga_lord_order_stable.py 1 通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:53 +08:00
jesse-uxandGrok (agent loop) 2748e43871 KP 改成宫头到宫头判宫,并写明口径(BUG-1319,上游 0337b77f)
本命 Bhava Chalit 表仍按中点,默认宫位不变。传了宫头却不声明就拒绝;没传宫头记整宫代理。报告多一行判宫口径。pytest tests/test_kp_cusp_house_assignment.py 3 通过,tests/test_bhava_chalit.py 仍通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:38 +08:00
jesse-uxandGrok (agent loop) d32ad7b7b1 婚恋评级改成证据档位,配偶星分数不再写成百分制(BUG-1318,上游 f891a2c1)
五档不再写稳定、离婚、再婚或婚前咨询,并写明这是结构指数。公开盘中英文各锁一条。配偶星证据行改成 0 到 1,不乘 100。pytest tests/test_d9_evidence_bands.py 3 通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:25 +08:00
Jesse_ChenandClaude Opus 5.5 99ef1bbd12 docs(tasks): upstream sync round 7 brief (BUG-1318 onward)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C
2026-10-14 00:30:38 +08:00
Claude 77057a819b merge origin/staging into the rectification step-0 entry (issue #54)
Independent Staging Quality Gate / validate (push) Successful in 14m24s
Independent Staging Quality Gate / publish (push) Successful in 4m21s
Docs-only conflicts (CHANGELOG, BUG_HISTORY 1316/1317) resolved by keeping
both; agent-loop rule A (2026-10-13).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 22:29:01 +08:00
Claude 9fca54eacb merge origin/staging into the report focus card (issue #55)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 5m38s
Docs-only catch-up (agent-loop rule A, 2026-10-13).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-13 22:14:47 +08:00
jesse-uxandGrok (agent loop) 8883f8475f 报告开头改成重点摘要卡
新报告用围栏画出大运和事业、财富、婚恋,标题下写名字与日期。旧报告不变。

自测:pytest test_report_full_data_fix.py 10 通过;相关四份报告测试退出码 0;前端相关测试 147 通过、0 失败、0 取消;tsc 退出码 0;lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-10-13 21:45:43 +08:00
34 changed files with 1472 additions and 97 deletions
+13
View File
@@ -1,11 +1,24 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
- KP 按宫头到下一宫头判宫,报告写明口径。本命宫位表的判宫不变。
- 接口里兜底大运的一年改成和推运脚本一样的 365.25 天。
- 星历路径只核查,没改。Skill 版本不变(BUG-1318~1321;BUG-1322 仍在核查)。
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
- 问事业、感情这类普通问题,仍是先排盘再回答。只是觉得解读不准,仍是认下来改着读,不会被拉去做校正。
- Skill 版本不变(BUG-1317,是 BUG-1306 的复发)。
## 2026-10-13 — 报告开头的「重点」改成一张摘要卡(待合入 staging)
- 新生成的个人报告,标题比正文大,下面一行小字是名字和生成日期。没有名字只写日期;标题里已经有名字就不重复。
- 开头不再堆四句行话。第一行是现在走的大运(主运、子运、次子运和起止年月),下面三行是事业、财富、婚恋,各带两个小标签。最后仍提醒:这段时间可能突出这些主题,不是确定会发生的事。
- 导出的文件里这一块是文字,不是一串代码。以前生成的报告打开还是原来的样子。Skill 版本不变(BUG-1316)。
## 2026-10-13 — 新报告正文画出分盘,中文更干净,上升行和同位列能读懂(待合入 staging)
- 重新生成的报告里,「本命与分盘北印度图盘」每张有数据的盘下面有一张北印度图。同一张盘只画一次。报告最末不再重复「分盘核对」。旧报告打开仍是当时保存的正文。
+89
View File
@@ -17690,6 +17690,20 @@
- 复发自:无。
- 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。
## BUG-1316 | 报告开头的「重点」没有设计,标题也没有样式
- 状态:fixed-pending-verify(须重新生成报告才看得到;真机清单未走,不标 resolved)
- 首次发现 / 最近更新:2026-10-13 / 2026-10-13
- 来源:issue #55。产品负责人选 A:做成一张摘要卡,标题下写名字和日期,保留提醒句。
- 现象:新报告开头先有一行「重点:当前主运 MD …」,接着又有「## 重点」和三条「事业 / 财富 / 婚恋」数据句,目录里再出现一次「重点」。标题和正文一样大。缩写 MD / AD / PD 直接给读者看。
- 根因:10-08 只要求开篇有这段内容,没有规定怎么呈现。09-29 去掉封面之后,标题只剩字体。检索「重点:」「opening_focus」「当前主运 MD」当时没有记录。不是旧修复回退。
- 修复:开篇改成一个 `jyotish-focus` 围栏。阅读页解析后画一张卡:现在走的大运、事业、财富、婚恋,尊贵和功能吉凶是两个小标签,最后是原来的提醒句。拿不到的日期写空,不新算大运或宫主。标题用显示字体,比下一章大;下面一行是名字和生成日期。名字只出现在本人打开的阅读页,公开投影和导出都不带名字。没有围栏的旧报告照旧显示。导出把围栏换成可读的字。
- 验证:`tests/test_report_full_data_fix.py` 10 通过。另四份报告测试退出码 0。前端改到的测试 147 通过、0 失败、0 取消,其中导出冻结哈希仍在。`tsc --noEmit` 退出码 0。`npm run lint` 0 error。
- 防复发:`tests/test_report_full_data_fix.py` 的开篇围栏用例;`frontend/tests/report-focus-card.test.ts` 用虚构日盘真实输出锁住卡片、坏数据和旧正文。
- 相关记录:无。
- 复发自:无。
- 修复版本:分支 `codex/report-focus-card-20261013`,尚未合入 staging。
## BUG-1317 | 明说要做生时校正时,仍先排完盘再写解读,校正没打开
- 状态:fixed-pending-verify(真机清单未走,没有模型凭据,不标 resolved)
@@ -17706,3 +17720,78 @@
- 复发自:BUG-1306。
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
## BUG-1318 | 婚恋评级仍写成稳定或婚前咨询,配偶星分数被写成百分制
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `f891a2c1`。任务书 `docs/tasks/TASK-upstream-sync7-20261014.md` T1。
- 现象:D9 综合评级仍写「婚姻稳定」「强烈建议婚前咨询」。这份文字进全读导出包,模型能读到。配偶星证据行把 0 到 1 的指数写成「婚姻质量 x/100」。
- 触发条件:算出婚恋几何指数,或关系时机证据带出配偶星。
- 根因:同步第六轮按标题筛提交时,漏了 10-02 到 10-03 的这条措辞修正。分数行把 0 到 1 当成了百分制。
- 修复:五档改成证据档位,并写明这是结构指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。中文报告用中文档位,英文报告用英文档位。证据行改成「婚姻质量指数 x(0到1)」,不乘 100。
- 验证:`tests/test_d9_evidence_bands.py` 3 通过。公开盘中英文各一条快照。配偶星行不再出现 `/100`。
- 防复发:五档文字不得含「稳定、离婚、再婚、婚前咨询、几段」;快照锁住公开盘的两句。
- 相关记录:BUG-1183。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1319 | KP 落宫沿用本命中点判宫,传了宫头也不声明口径
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T2。
- 现象:KP 用的行星落宫和本命 Bhava Chalit 表一样,按相邻宫头的中点判宫。宫头传进 KP 分析时,结果不写明判宫口径。
- 触发条件:打开 KP。本命 Bhava Chalit 表不走这条。
- 根因:同步第六轮漏了夹在上游隐私提交里的判宫修正。本命表和 KP 共用了中点函数。
- 修复:KP 改成宫头到下一宫头。本命表默认仍是中点,宫位数字不变。传了宫头却不声明口径就拒绝;没传宫头记整宫代理。KP 报告写出判宫口径。
- 验证:`tests/test_kp_cusp_house_assignment.py` 3 通过。同一颗星在两种口径下落入不同宫;默认中点与改前一致。
- 防复发:未知口径直接报错;报告正文必须出现 House occupancy basis。
- 相关记录:BUG-1318。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1320 | 瑜伽角宫主和三方主的顺序随进程哈希变化
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T3。
- 现象:同一张盘的角宫主、三方主列表用了集合去重。集合顺序会跟着进程哈希变,瑜伽参与者顺序不稳定。
- 触发条件:规则表达式读取这两份宫主列表。
- 根因:去重用了不保证顺序的集合。同步第六轮漏了上游把它们改成排序的那一处。
- 修复:两处都改成先去重再按名字排序。
- 验证:`tests/test_yoga_lord_order_stable.py` 1 通过。哈希种子 0 和 1 两个进程算出的参与者顺序相同。
- 防复发:两个子进程的名单必须逐项相同,并且等于排好序的名单。
- 相关记录:BUG-1319。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1321 | 接口里的大运年长和推运脚本差了一点,起止会对不齐
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `4d543209`。任务书 T4。
- 现象:接口的兜底大运和「进入本运几年」用 365.25636 天为一年。同一条时间线的推运脚本用 365.25 天。年限一长,日期会差一天。
- 触发条件:走兜底大运时间线,或计算进入当前大运的年数。
- 根因:两处常量没有跟着推运脚本改。同步第六轮漏了上游 10-03 的这一处。
- 修复:这两处改成 365.25,与推运脚本相同。其它大运系统的年长不动。
- 验证:`tests/test_dasha_year_length_alignment.py` 2 通过。同一大运起止相差 0 天。158 年的旧常量会落到下一天,新常量不会。
- 防复发:兜底时间线必须和推运脚本的起止是同一天;进入本运的年数按 365.25 除。
- 相关记录:BUG-1320。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1322 | 星历路径被设成空字符串后,计算退回低精度星历
- 状态:investigating
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `e1909724` 点出的同类问题。任务书 T5,本轮只核查、不改代码。
- 现象:排盘和过境入口都把星历路径设成空字符串。本机随后计算太阳位置时,返回标记含低精度星历位,不含星历文件位。
- 触发条件:调用排盘或过境计算。本轮没有改这条路径。
- 根因:尚未在生产镜像里核实。本机仓库里的星历文件只在参考资料目录,不在计算进程会去找的路径上。空路径是进程全局的,后一次调用会盖掉前面设过的路径。
- 修复:未改。建议另开一单:镜像里放一份星历文件,路径显式设上,并断言返回标记不再是低精度星历。
- 验证:本机一次探针。生产镜像未打开,不能当作已确认。
- 防复发:未做。等修复单再加断言。
- 相关记录:BUG-1321。
- 复发自:无。
- 修复版本:未修复。
@@ -0,0 +1,35 @@
# 进度 · 报告开篇重点卡(2026-10-13)
- 分支:`codex/report-focus-card-20261013`
- 任务书:`docs/tasks/TASK-report-focus-card-20261013.md`
- 基线:任务书写的 `856bc850b` 已过时。本轮按 Claude 计划,从当时的 `origin/staging` `963276ca2fc942bc28ef9bdee3b8d0e9d3c957c0` 建分支,没有回退。
- issue:#55,BUG-1316
## 做了什么
1. 新报告开篇改成一个 `jyotish-focus` 围栏。里面是现在的主运、子运、次子运,以及事业(第 10 宫)、财富(第 2 宫)、婚恋(第 7 宫)。日期和宫主都用报告里已经有的数,缺的写空。
2. 阅读页把这个围栏画成一张卡。中文不出现 MD / AD / PD。提醒句留在卡的最后。围栏坏了只显示「重点数据无效」。
3. 标题比下一章大。下面一行是「名字 · 生成于 日期」。没有名字只写日期。标题里已经有名字就不重复。名字只给登录后打开自己报告的人,公开页和导出文件都不带名字。
4. 旧报告没有这个围栏时,正文照旧。导出把围栏换成可读的字。
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 开篇围栏 | `.venv\Scripts\python -m pytest tests/test_report_full_data_fix.py -q` | 10 通过,退出码 0 |
| 相关报告测试 | 同解释器跑 chart blank columns、english edition、reader appendix、reader main | 退出码 0 |
| 前端改到的测试 | `node node_modules/tsx/dist/cli.mjs --test` 九个相关文件 | 147 通过,0 失败,0 取消 |
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 前端 lint | `frontend` 下 `npm run lint` | 0 error(125 条既有 warning,本轮未改) |
| 数据库 | 未跑 | 本轮不改表,任务书写明不需要 |
## 偏离
- 中文报告大运章的表头仍有 MD / AD / PD。卡片和开篇里没有。英文测试仍要求正文里能看到 AD、PD,所以没有改那一章的表头。任务书「全文不再出现」按开篇(出生资料之前)执行。
- 固定清单「先核对出生资料、图盘」原来也会被写进资料附录。附录单独整理时不再注入这张清单。正文开头有重点卡时也不再注入。
- 短报告测试要显式画出第 1 章,卡片才会出现在静态结果里。正式阅读页里,完整数据版第 1 章本来就会画出来。没有把「重点」改成一打开就全部展开。
## 环境缺口
- 真机清单 `docs/testing/report-focus-card-20261013.md` 留给部署后。本机没有登录态,没有重开浏览器看深浅色。
- 未跑前端全量测试、Python 快速门、`next build`。按任务书留给验收方。
@@ -0,0 +1,41 @@
# PROGRESS · 上游同步第七轮(2026-10-14)
- 分支:`codex/upstream-sync7-20261014`
- 基线:任务书写的是 `77057a81`。开工时 `origin/staging` 已到 `99ef1bbd`,本分支从这里建。
- 结论:T1 到 T4 已做完并推这个分支。T5 只核查,代码没改。星历在这台电脑上会退回低精度,生产镜像没打开,记 BUG-1322,状态是核查中。
## 做了什么
| 项 | 结果 |
| --- | --- |
| T1 婚恋断语 | 五档改成证据档位,中文给中文报告,英文给英文报告。写明不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。配偶星那一行改成 0 到 1 的指数,不再写 `/100`。BUG-1318,上游 `f891a2c1`。 |
| T2 KP 判宫 | KP 按宫头到下一宫头。本命宫位表仍按中点,默认宫位不变。传了宫头却不说口径就拒绝;没传记整宫代理。报告多一行判宫口径。BUG-1319,上游 `0337b77f`。 |
| T3 瑜伽排序 | 角宫主、三方主先去重再按名字排序。BUG-1320,上游 `0337b77f`。 |
| T4 大运年长 | 接口里两处 365.25636 改成 365.25,和推运脚本同一天。别的大运系统没动。BUG-1321,上游 `4d543209`。 |
| T5 星历 | 见下。不改代码。BUG-1322,核查中。 |
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 婚恋档位 | `pytest tests/test_d9_evidence_bands.py` | 3 通过 |
| KP 判宫 | `pytest tests/test_kp_cusp_house_assignment.py` | 3 通过 |
| 本命宫位表 | `pytest tests/test_bhava_chalit.py` | 通过(与上面一批共 20 通过、0 失败) |
| 几何指数旧测试 | `pytest tests/test_marriage_counting_geometry_only.py` | 通过(同上) |
| 瑜伽顺序 | `pytest tests/test_yoga_lord_order_stable.py` | 1 通过 |
| 大运年长 | `pytest tests/test_dasha_year_length_alignment.py` | 2 通过 |
| 接口增长合同 | `pytest tests/test_api_server_growth_contract.py` | 通过 |
| KP 报告标题回归 | `pytest tests/test_report_longform_parity.py` 里 KP 那一条 | 通过 |
| 快速门、前端全量 | 未跑 | 按执行分工,留给验收方 |
| 数据库 | 未跑 | 没动表 |
## T5 星历核查
本机把路径设成空字符串后,算太阳位置的返回标记带低精度星历,不带星历文件。仓库里的星历文件在参考资料目录,计算时用不到。排盘和过境两处都设成同一个空路径,所以这两处彼此一致,但都会退回低精度。路径是整个进程共用的,谁后设谁生效。
生产镜像这台电脑打不开,不能说线上也是这样。健康检查只看星历库能不能导入,看不出用的是哪一套。建议另开一单再改。
## 偏离
- 关系时机里还有一处用 65 和 45 去比这个 0 到 1 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
- 生时校正打分文件没有改。
+1
View File
@@ -331,6 +331,7 @@
| `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` |
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) |
| `TASK-ephemeris-nutation-20261008.md` | `PROGRESS-ephemeris-nutation-20261008.md` | **恒星黄道少扣章动(BUG-1275)**:行星 = 含章动热带视位置 − 平岁差,全体偏 Δψ(≤17″),大运日期差 2–3 天;改标准算法、PyJHora 冻结对照、scoring-12、v5 重跑 | 已验收(已部署 staging `db0134f9`,BUG-1275) | `codex/ephemeris-nutation-20261008`(未推送,未部署) |
| `TASK-sync6-gate-red-20261007.md` | — | **sync6 后门禁仍红**:run 3213 快速门已过,「Frontend and database tests」开跑 20 秒后日志中断、15 分钟后判失败,后续步骤 failure 0 秒;疑运行机掉线;先 Re-run,红则本机带 Docker 跑 `CI=1 npm test` 对照 `0ed45a4b` | 已完成(Re-run 通过,BUG-1271,部署 `2df6ee72`) | — |
@@ -0,0 +1,80 @@
# TASK · 上游同步第七轮:sync6 之后上游漏搬的 4 处小修 + 星历路径核查(2026-10-14)
- 日期:2026-10-14
- 来源:产品 10-14 要求复查上游 Skill 仓(`/workspace/yinduzhanxing`,GitHub `732642856/yinduzhanxing`)。远端 main 仍停在 10-08 `0337b77f`,无新提交;按补丁内容逐条对照 10-02 以来 142 个提交:已有 38、不适用 101、**缺 3 个提交共 4 处**。产品 10-14 同意出本单,交 agent 循环由 Grok 执行。
- 执行方:**Grok(Windows)**,分支 `codex/upstream-sync7-20261014`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**BUG-1318 起**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号为 BUG-1317)。T1–T4 各一个号,T5 有结论再取号。
## 1. 基线
- 基线 commit:`origin/staging` `77057a81`。
- 上游参照:`origin/main` `0337b77f`(10-08)。**上游 10-05 做过一次隐私改写,旧 SHA(`c593354f`、`23be1807`、`03bea6ed` 等)已不在上游 main**;本单只引用新 SHA。上游代码要点已抄在 §2,执行方不需要能访问上游仓库。
- 只按下表逐项移植,不整文件覆盖上游文件。
## 2. 事故实证(`77057a81`,行号按符号定位)
| 项 | 上游提交 | 我方现状 |
| --- | --- | --- |
| T1 婚恋断语 | `f891a2c1` | `scripts/marriage_counting.py` 综合评级仍写「高(关系质量好,伴侣支持强,婚姻稳定)」…「低(关系质量有重大挑战,强烈建议婚前咨询)」;该结果经 `scripts/jyotish_engine.py` 放进导出包 `detail_blocks.marriage_counting`(模型可读)。另 `scripts/jyotish_api_server.py` DK 证据行写 `婚姻质量 {marriage_quality_score}/100`,而该分数是 0–1 |
| T2 KP 判宫 | `0337b77f`(夹在隐私提交里) | `scripts/bhava_chalit.py` 只有 Parashari 中点判宫 `_planet_bhava`;`jyotish_engine.py` 的 `cmd_kp` 把同一结果当 KP 落宫用;`scripts/kp_system.py::calc_kp_analysis` 收了宫头却不声明判宫口径 |
| T3 瑜伽去重排序 | `0337b77f` | `scripts/yoga_engine.py` 内 `_kendra_lords_list` / `_trikona_lords_list` 仍是 `list(set([...]))`(`jyotish_engine.py` 里的 `kl` / `tl` 已是 sorted,不用改) |
| T4 大运年长 | `4d543209` | `scripts/jyotish_api_server.py` 两处 `365.25636`(大运兜底时间线 `end_date`、`years_into_md`),同一时间线 `scripts/dasha_analyzer.py` 用 `365.25` |
| T5 星历路径(核查) | `e1909724` 揭示的同类问题 | `jyotish_engine.py::compute_chart_data` 与 transit 入口都 `swe.set_ephe_path('')`;上游发现路径是进程全局、未设路径的调用会悄悄退回 Moshier、结果随调用顺序变。我方是否同样,未查 |
上游代码要点(照此语义实现,命名可按我方风格):
- **T1**:五档评级改为证据档位 `supportive_evidence_stronger` / `supportive_evidence_mixed_positive` / `mixed_evidence` / `pressure_evidence_mixed` / `pressure_evidence_stronger`,并加 `claim_boundary`:「D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询」。
- **T2**:`BhavaChalit` 新增 `_planet_bhava_from_cusps(planet_lon, cusps)`:宫 i 的范围是 `cusp[i] → cusp[(i+1)%12]`。计算入口加参数 `house_assignment`,取值只允许 `'sandhi'`(默认,本命 Bhava Chalit 表)或 `'cusp_to_cusp'`(KP),其他值抛 `ValueError`,结果里带 `house_assignment`。`cmd_kp` 显式用 `cusp_to_cusp`;`calc_kp_analysis` 新增 `house_occupancy_basis`,传了宫头却不声明就抛错,未传宫头时记 `whole_sign_proxy`;KP 报告表加一行「House occupancy basis」。
- **T3**:`sorted(set(...))`,注释说明 set 顺序随进程哈希变化。
## 3. 根因
我方 sync6 参照的是 10-05 的五个计算修正;上游随后把 T2/T3 夹在 10-08 的隐私提交里,T1/T4 在 10-02~10-03 的提交里,当时按标题筛选时漏掉。
## 4. 决策记录(产品 2026-10-14 授权)
1. 移植 T1–T4;T5 只核查、出结论,**不改代码**(有问题另立单)。
2. T1 用户可见措辞改中文证据档位(报告是中文为主;英文版用英文档位),**禁止**任何「婚姻稳定 / 离婚 / 再婚 / 几段婚姻 / 建议婚前咨询」类断语;`婚姻质量 X/100` 改为如实表达 0–1 指数(或去掉分母),不得乘 100 冒充百分制。沿用 sync4 T1(marriage_counting 删「再婚 / 几段关系」断语)的口径。
3. 不搬:`fee84828` Sade Sati(我方无该表);`59f61d3c` 等强制显式岁差(产品 10-03 已拒);`ca20ae14` 健康改 D6/D8(占星师裁定只看 D30,甲8 试算变差);10-02 校正门控 / 映射(试算未过门);书库语料(来源版权不清);上游 `AGENTS.md` §9 书库流程。
## 5. 硬红线
1. **不改生时校正打分**:T1–T4 均不在打分路径(KP 在 `OBSERVATION_ONLY_LAYERS`)。若任何改动触及 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 或其他冻结身份文件,停下回报(ERR-110),不得自行重新冻结。`test_rectification_validation_integrity_gate` 等冻结测试必须保持绿。
2. `scripts/jyotish_api_server.py` 只允许 T1 那一行与 T4 两处常量的 bugfix 级改动;`tests/test_api_server_growth_contract.py` 必须过。
3. golden 由真实引擎生成,不手造;改任何既有断言写「原值 / 新值 / 原因」三栏。
4. 本命 Bhava Chalit 表的判宫结果必须逐字节不变(默认 `sandhi`);只有 KP 层改变。
5. 不改数据库、不改 workflow、不提升 main、不推 staging;只推 `codex/upstream-sync7-20261014`。
6. 不写真实用户资料;示例只用公开名人盘(如乔布斯 AA)或虚构盘。
## 6. 任务分解
| 项 | 内容 | 验收 |
| --- | --- | --- |
| T1 | 婚恋评级改证据档位 + `claim_boundary`;DK 行「婚姻质量 X/100」改对;检查报告 / 导出包 / 中英文渲染里引用评级字符串的地方一并改 | 新测试:五档均不含断语词(稳定、离婚、再婚、婚前咨询、几段),`claim_boundary` 存在;DK 行不再出现 `/100`;中英文报告渲染各一例快照(公开盘) |
| T2 | KP 宫头到宫头判宫,`house_assignment` / `house_occupancy_basis` 显式声明 | 新测试:构造宫头使行星落在「中点判宫」与「宫头判宫」不同宫,两种口径结果不同;默认 sandhi 与改前逐字节相同;`calc_kp_analysis` 传宫头不声明即抛错;KP 报告出现判宫口径行 |
| T3 | 两处 `sorted(set(...))` | 新测试:用不同 `PYTHONHASHSEED` 起两个子进程算同一张盘,瑜伽参与者顺序一致 |
| T4 | 两处 365.25636 → 365.25 | 新测试:兜底时间线与 `dasha_analyzer` 时间线同一大运起止一致(误差 0 天) |
| T5 | 核查我方 Swiss Ephemeris 路径:`set_ephe_path('')` 实际落到哪套星历(看 `calc_ut` 返回 flag 是否含 `SEFLG_MOSEPH`)、生产镜像里是否有 `.se1` 星历文件、是否存在进程内不同调用路径用不同星历 | 进度记录写结论与证据;有问题写成新 BUG(`investigating`)并给建议,不改代码 |
每项一个提交,提交说明写上游提交号与 BUG 号。
## 7. 让步顺序
T1 不可让(违反「不断言婚姻」口径)。T3、T4 小,不可让。T2 可让(若改动面超出预期,先交 T1/T3/T4,T2 写清卡点)。T5 可让为环境缺口(Windows 无法确认生产镜像时如实写)。
## 8. 验收门(执行方自测 + Claude 复验)
- Python:`ruff`、`py_compile`、定向测试、**快速门全量** `scripts/run_quality_gate.py --profile quick`(`failures []` 或与基线同名);冻结身份测试绿;隐私标记测试。
- 本单只改 Python 与报告渲染;若碰到前端,加跑 tsc / lint / `npm test`(失败名单与基线逐名相同)/ build。
- `docs/BUG_HISTORY.md`(BUG-1318 起)、`CHANGELOG.md`、`docs/tasks/PROGRESS-upstream-sync7-20261014.md`、`docs/tasks/README.md` 状态。
## 9. 开工前置
```
git fetch origin --prune
git status -sb
git worktree add -b codex/upstream-sync7-20261014 .worktrees/upstream-sync7-20261014 origin/staging
```
开工时核对 `origin/staging` 是否仍为 `77057a81`;若已前进,以最新 staging 为基,进度记录写明。
+2
View File
@@ -675,6 +675,8 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **报告标题 (2026-10-13, BUG-1316):** the Markdown `h1` is the report title. It uses `--font-display` at `--type-display-md` (larger than the `h2` `--type-display-sm`), weight 500, with `--space-2` above and `--space-3` below. Under it, one caption (`.personal-report-md-byline`, `--color-ink-tertiary`, `--type-caption`, body face) reads `{名字} · 生成于 YYYY-MM-DD`, or `Generated` in English. No name: date only. The title already contains the name: date only, the name is not repeated. The name comes from the signed-in read (`subjectDisplayName`); it is not added to the public projection or the export. Old reports without a name still show the date.
- **开篇重点卡 (2026-10-13, BUG-1316):** the first chapter is one card (`.personal-report-focus-card`) drawn from a `jyotish-focus` fence. The engine writes the JSON; the page only parses it. First row, 「现在走的大运」: 主运 / 子运 / 次子运, each the planet's Chinese name plus `YYYY.MM – YYYY.MM`, in a wrapping row. Then 事业 / 财富 / 婚恋: 「第 N 宫主 · 行星 · 落第 M 宫」 and two tags (`.personal-report-focus-tag`, same muted pill as `.chart-role-badge`: `--color-canvas-muted`, `--color-ink-secondary`, `--type-overline`). The last line is the caveat at `--type-caption` / `--color-ink-tertiary`. No MD / AD / PD on the card. A bad fence says 「重点数据无效」 (English "Focus data is invalid"), the same invalid line as a bad chart, and does not echo the JSON. The card wraps; it does not scroll sideways. Tokens only, so light and dark both stay readable. Reports with no fence render as before. Export turns the fence into those same words; the file has no raw JSON. The public projection keeps a public fence the way it keeps `jyotish-chart`, and does not add a name field.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
+73 -3
View File
@@ -4338,9 +4338,79 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overflow-wrap: anywhere;
padding: var(--space-8) var(--space-7) var(--space-10);
}
/* The report title (markdown `#`) is the md view's cover line; it takes the
heading face like the document view's `.personal-report-cover h1`. */
.personal-report-md-article h1 { font-family: var(--font-display); }
.personal-report-md-article h1 {
margin: var(--space-2) 0 var(--space-3);
font-family: var(--font-display);
font-size: var(--type-display-md);
font-weight: 500;
line-height: 1.25;
}
.personal-report-md-byline {
margin: 0 0 var(--space-5);
color: var(--color-ink-tertiary);
font-family: var(--font-body);
font-size: var(--type-caption);
line-height: 1.5;
}
.personal-report-focus-card {
display: grid;
gap: var(--space-3);
margin: 0 0 var(--space-5);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
overflow-wrap: anywhere;
}
.personal-report-focus-kicker {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-overline);
letter-spacing: .06em;
}
.personal-report-focus-periods,
.personal-report-focus-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.personal-report-focus-period {
min-width: 0;
flex: 1 1 8rem;
margin: 0;
}
.personal-report-focus-period strong { display: block; font-weight: 600; }
.personal-report-focus-period span {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.personal-report-focus-domains { display: grid; gap: var(--space-3); }
.personal-report-focus-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
.personal-report-focus-row p { margin: 0; }
.personal-report-focus-domain { display: block; font-weight: 600; }
.personal-report-focus-tag {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 var(--space-2);
border-radius: 999px;
background: var(--color-canvas-muted);
color: var(--color-ink-secondary);
font-size: var(--type-overline);
font-weight: 600;
}
.personal-report-focus-caveat {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.5;
}
.personal-report-md-article h2,
.personal-report-md-article h3 { scroll-margin-top: 80px; }
.personal-report-md-article h2 {
@@ -6,11 +6,13 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
import { ReportFocusCard } from "@/components/personal-report/report-focus-card";
import {
chartAriaLabel,
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
@@ -35,8 +37,14 @@ function reportUrlTransform(url: string): string {
return url.startsWith("#") ? url : "";
}
function markdownComponents(headings: readonly LongformHeading[], language: ReportLanguage): Components {
function markdownComponents(
headings: readonly LongformHeading[],
language: ReportLanguage,
subjectName?: string,
createdAt?: string,
): Components {
const cursor = { h2: 0, h3: 0 };
const byline = { shown: false };
const nextId = (level: 2 | 3, children: ReactNode) => {
const title = flattenText(children).trim();
const key = level === 2 ? "h2" : "h3";
@@ -58,6 +66,17 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
<table>{children}</table>
</div>
),
h1: ({ children }) => {
const title = flattenText(children).trim();
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
byline.shown = true;
return (
<>
<h1>{children}</h1>
{line ? <p className="personal-report-md-byline">{line}</p> : null}
</>
);
},
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
h4: ({ children }) => {
@@ -70,7 +89,15 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
);
},
pre: ({ children, ...props }) => {
if (fencedLanguage(children) !== "jyotish-chart") {
const fence = fencedLanguage(children);
if (fence === "jyotish-focus") {
const focus = parseReportFocusBlock(fencedSource(children).trim());
if (!focus) {
return <p className="personal-report-chart-invalid">{language === "en" ? "Focus data is invalid" : "重点数据无效"}</p>;
}
return <ReportFocusCard block={focus} language={language} />;
}
if (fence !== "jyotish-chart") {
return <pre {...props}>{children}</pre>;
}
const block = parseReportChartBlock(fencedSource(children).trim());
@@ -90,10 +117,16 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
};
}
function renderMarkdown(markdown: string, headings: readonly LongformHeading[], language: ReportLanguage) {
function renderMarkdown(
markdown: string,
headings: readonly LongformHeading[],
language: ReportLanguage,
subjectName?: string,
createdAt?: string,
) {
return (
<ReactMarkdown
components={markdownComponents(headings, language)}
components={markdownComponents(headings, language, subjectName, createdAt)}
disallowedElements={["script", "iframe", "object", "embed", "img"]}
rehypePlugins={[reportChartGrid]}
remarkPlugins={[remarkGfm]}
@@ -125,12 +158,16 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
reveal,
language,
drawn = false,
subjectName,
createdAt,
}: {
section: LongformSection;
reveal: RevealSignal;
language: ReportLanguage;
/** Drawn from the first render: the reader is about to jump to it or past it. */
drawn?: boolean;
subjectName?: string;
createdAt?: string;
}) {
const ref = useRef<HTMLElement>(null);
const [seen, setVisible] = useState(section.eager || drawn);
@@ -151,10 +188,10 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
return () => observer.disconnect();
}, [visible]);
const content = useMemo(
() => (visible ? renderMarkdown(section.markdown, section.headings, language) : (
() => (visible ? renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt) : (
<h2 id={section.id}>{section.title}</h2>
)),
[visible, section, language],
[visible, section, language, subjectName, createdAt],
);
return (
<section ref={ref} className="personal-report-md-section">
@@ -211,11 +248,20 @@ function ReportToc({
);
}
export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThrough = null }: {
export function PersonalReportMarkdownView({
markdown,
language = "zh",
drawnThrough = null,
subjectName,
createdAt,
}: {
markdown: string;
language?: ReportLanguage;
/** Chapter index (h2 order) through which sections render immediately; used when switching editions. */
drawnThrough?: number | null;
/** Owner-only display name. Omitted from export and from the public projection. */
subjectName?: string;
createdAt?: string;
}) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]);
@@ -248,21 +294,21 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
for (const listener of reveal.listeners) listener();
}), [chapters.length, paged, reveal]);
const lead = useMemo(
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language) : null),
[outline, language],
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language, subjectName, createdAt) : null),
[outline, language, subjectName, createdAt],
);
const safeChapter = Math.min(chapter, Math.max(chapters.length - 1, 0));
const currentChapter = chapters[safeChapter];
const pagedChapter = useMemo(() => (
paged && currentChapter
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language)
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language, subjectName, createdAt)
: null
), [paged, currentChapter, language]);
), [paged, currentChapter, language, subjectName, createdAt]);
const printedChapters = useMemo(() => (
paged && printing
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language))
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt))
: null
), [paged, printing, chapters, language]);
), [paged, printing, chapters, language, subjectName, createdAt]);
const openChapter = useCallback((id: string) => {
const index = chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id));
if (index >= 0) setChapter(index);
@@ -318,10 +364,12 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
{outline.sections.map((section, index) => (section.eager ? null : (
<LazyMarkdownSection
key={section.id}
createdAt={createdAt}
drawn={drawnThrough !== null && index <= drawnThrough}
language={language}
reveal={reveal}
section={section}
subjectName={subjectName}
/>
)))}
</article>
@@ -61,6 +61,8 @@ export type ReportLoadState =
markdown: string;
reportId: string;
createdAt: string;
/** Signed-in reader's display name. Never written into the export. */
subjectDisplayName?: string;
factTables?: ReportFactTable[];
calculationCharts?: ReportDocumentV2["charts"];
/** `legacy`: generated before the English edition existed; no switch is shown. */
@@ -145,6 +147,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
const english = readEnglishEdition(json);
const subjectDisplayName = readSubjectDisplayName(json);
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
@@ -153,6 +156,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
markdown,
reportId: typeof view.id === "string" ? view.id : "",
createdAt: typeof view.createdAt === "string" ? view.createdAt : "",
...(subjectDisplayName ? { subjectDisplayName } : {}),
};
}
return { phase: "legacy-unavailable" };
@@ -195,6 +199,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readSubjectDisplayName(json: unknown): string | undefined {
if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined;
const trimmed = json.subjectDisplayName.trim();
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
return trimmed;
}
/**
* The English edition is trusted only in the shape the route promises and only
* when it holds no Han character; anything else reads as unavailable, and the
@@ -575,7 +586,14 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
/>
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */}
<PersonalReportMarkdownView key={`markdown-${shown}`} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
<PersonalReportMarkdownView
key={`markdown-${shown}`}
createdAt={state.createdAt}
drawnThrough={drawnThrough}
language={shown}
markdown={markdown}
subjectName={state.subjectDisplayName}
/>
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
{factTables && <ReportFactTables key={`facts-${shown}`} language={shown} tables={factTables} />}
</div>
@@ -0,0 +1,70 @@
import {
periodRange,
planetLabel,
type ReportFocusBlock,
} from "@/lib/report-focus-block";
import type { ReportLanguage } from "@/lib/report-language";
const LEVEL = {
md: { zh: "主运", en: "Major" },
ad: { zh: "子运", en: "Sub" },
pd: { zh: "次子运", en: "Sub-sub" },
} as const;
const DOMAIN = {
career: { zh: "事业", en: "Career" },
wealth: { zh: "财富", en: "Wealth" },
relationship: { zh: "婚恋", en: "Relationship" },
} as const;
export function ReportFocusCard({
block,
language,
}: {
block: ReportFocusBlock;
language: ReportLanguage;
}) {
const en = language === "en";
return (
<section className="personal-report-focus-card">
<p className="personal-report-focus-kicker">{en ? "Current period" : "现在走的大运"}</p>
<div className="personal-report-focus-periods">
{block.periods.map((period) => {
const range = periodRange(period.start, period.end);
return (
<p className="personal-report-focus-period" key={period.level}>
<strong>{LEVEL[period.level][language]} · {planetLabel(period.lord, language)}</strong>
{range ? <span>{range}</span> : null}
</p>
);
})}
</div>
<div className="personal-report-focus-domains">
{block.domains.map((domain) => {
const name = planetLabel(domain.lord, language);
const placed = domain.placed === null
? (en ? "house not listed" : "落宫未列出")
: (en ? `house ${domain.placed}` : `第${domain.placed}宫`);
const facts = en
? `House ${domain.house} lord · ${name} · ${placed}`
: domain.placed === null
? `第${domain.house}宫主 · ${name} · ${placed}`
: `第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
return (
<div className="personal-report-focus-row" key={domain.key}>
<p>
<span className="personal-report-focus-domain">{DOMAIN[domain.key][language]}</span>
<span>{facts}</span>
</p>
<p className="personal-report-focus-tags">
<span className="personal-report-focus-tag">{domain.dignity}</span>
<span className="personal-report-focus-tag">{domain.role}</span>
</p>
</div>
);
})}
</div>
<p className="personal-report-focus-caveat">{block.caveat}</p>
</section>
);
}
@@ -1,7 +1,8 @@
import { stripReportChartBlocks } from "./report-chart-block";
import { renderReportFocusFences } from "./report-focus-block";
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
/** Historical ordinary export contract, retained as the full-body byte regression lock. */
export function ordinaryReportDownloadMarkdown(markdown: string): string {
return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown));
return renderReportFocusFences(stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)));
}
@@ -147,6 +147,18 @@ export function reportListTimestamp(value: unknown): string {
return "";
}
/** Display name for the signed-in reader. Birth fields stay off this response. */
export function readOwnedSubjectDisplayName(document: unknown): string | undefined {
if (!document || typeof document !== "object") return undefined;
const subject = (document as { subject?: unknown }).subject;
if (!subject || typeof subject !== "object") return undefined;
const name = (subject as { displayName?: unknown }).displayName;
if (typeof name !== "string") return undefined;
const trimmed = name.trim();
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
return trimmed;
}
export function reportView(
row: PersonalReportRecord,
job?: PersonalReportJobRecord | null,
@@ -753,6 +765,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
: undefined);
if (typeof markdown === "string" && markdown.trim()) {
const validated = deps.validateReadyDocument(row.reportDocument);
const subjectDisplayName = readOwnedSubjectDisplayName(validated.ok ? validated.document : undefined);
// A failed English read never fails the Chinese report; it reads as unavailable.
const english: EnglishEditionRead = deps.loadLongformEnglish
? await deps.loadLongformEnglish({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
@@ -767,6 +780,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
factTablesEn: english.factTables,
} : {}),
...(subjectDisplayName ? { subjectDisplayName } : {}),
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
? { calculationCharts: validated.document.charts } : {}),
+4 -2
View File
@@ -2,6 +2,7 @@ import type { ReportDocumentV2 } from "./personal-report-contract";
import { reportChartExportSvg } from "./report-chart-export";
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import { renderReportFocusFences } from "./report-focus-block";
import type { ReportFactTable } from "./report-fact-table-schema";
import { factCellText, factStatusLabel } from "./report-fact-table-display";
import type { ReportLanguage } from "./report-language";
@@ -67,9 +68,10 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
}
// sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's
// leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose.
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", stripReportChartBlocks(outline.sourcePreamble));
const publishBody = (text: string) => renderReportFocusFences(stripReportChartBlocks(text), language);
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", publishBody(outline.sourcePreamble));
for (const section of outline.sections) {
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
add(`body:${section.id}`, "body", section.title, publishBody(section.sourceMarkdown));
}
const chartIds = fencedReportChartIds(projected);
for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
+158
View File
@@ -0,0 +1,158 @@
import { z } from "zod";
import type { ReportLanguage } from "./report-language";
const PLANET_ZH: Record<string, string> = {
Sun: "太阳",
Moon: "月亮",
Mars: "火星",
Mercury: "水星",
Jupiter: "木星",
Venus: "金星",
Saturn: "土星",
Rahu: "北交点",
Ketu: "南交点",
};
const LEVEL_LABEL = {
md: { zh: "主运", en: "Major" },
ad: { zh: "子运", en: "Sub" },
pd: { zh: "次子运", en: "Sub-sub" },
} as const;
const DOMAIN_LABEL = {
career: { zh: "事业", en: "Career" },
wealth: { zh: "财富", en: "Wealth" },
relationship: { zh: "婚恋", en: "Relationship" },
} as const;
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable();
const lord = z.string().min(1).max(40).nullable();
export const reportFocusBlockSchema = z.strictObject({
version: z.literal(1),
periods: z.tuple([
z.strictObject({ level: z.literal("md"), lord, start: day, end: day }),
z.strictObject({ level: z.literal("ad"), lord, start: day, end: day }),
z.strictObject({ level: z.literal("pd"), lord, start: day, end: day }),
]),
domains: z.tuple([
z.strictObject({
key: z.literal("career"),
house: z.literal(10),
lord,
placed: z.number().int().min(1).max(12).nullable(),
dignity: z.string().min(1).max(80),
role: z.string().min(1).max(80),
}),
z.strictObject({
key: z.literal("wealth"),
house: z.literal(2),
lord,
placed: z.number().int().min(1).max(12).nullable(),
dignity: z.string().min(1).max(80),
role: z.string().min(1).max(80),
}),
z.strictObject({
key: z.literal("relationship"),
house: z.literal(7),
lord,
placed: z.number().int().min(1).max(12).nullable(),
dignity: z.string().min(1).max(80),
role: z.string().min(1).max(80),
}),
]),
caveat: z.string().min(1).max(240),
});
export type ReportFocusBlock = z.infer<typeof reportFocusBlockSchema>;
export function parseReportFocusBlock(source: string): ReportFocusBlock | null {
try {
return reportFocusBlockSchema.parse(JSON.parse(source));
} catch {
return null;
}
}
export function planetLabel(name: string | null, language: ReportLanguage): string {
if (!name) return language === "en" ? "not listed" : "未列出";
if (language === "zh") return PLANET_ZH[name] ?? name;
return name;
}
function monthLabel(iso: string | null): string | null {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
}
export function periodRange(start: string | null, end: string | null): string | null {
const from = monthLabel(start);
const to = monthLabel(end);
if (from && to) return `${from} – ${to}`;
return from ?? to;
}
export function focusBlockText(block: ReportFocusBlock, language: ReportLanguage = "zh"): string {
const kicker = language === "en" ? "Current period" : "现在走的大运";
const periods = block.periods.map((period) => {
const label = LEVEL_LABEL[period.level][language];
const range = periodRange(period.start, period.end);
const name = planetLabel(period.lord, language);
return range ? `${label} ${name} ${range}` : `${label} ${name}`;
});
const domains = block.domains.map((domain) => {
const label = DOMAIN_LABEL[domain.key][language];
const name = planetLabel(domain.lord, language);
const placed = domain.placed === null
? (language === "en" ? "house not listed" : "落宫未列出")
: (language === "en" ? `house ${domain.placed}` : `第${domain.placed}宫`);
const where = language === "en"
? `${label}: House ${domain.house} lord · ${name} · ${placed}`
: domain.placed === null
? `${label}:第${domain.house}宫主 · ${name} · ${placed}`
: `${label}:第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
return `${where}(${domain.dignity};${domain.role})`;
});
return [kicker, ...periods, ...domains, block.caveat].join("\n");
}
const FOCUS_FENCE_RE = /```jyotish-focus\r?\n([\s\S]*?)```/g;
/** Turn the fence into readable lines. A bad fence becomes the same invalid sentence as the page. */
export function renderReportFocusFences(markdown: string, language: ReportLanguage = "zh"): string {
return markdown.replace(FOCUS_FENCE_RE, (_full, source: string) => {
const block = parseReportFocusBlock(String(source).trim());
if (!block) return language === "en" ? "Focus data is invalid\n" : "重点数据无效\n";
return `${focusBlockText(block, language)}\n`;
});
}
export function reportTitleByline(input: {
title: string;
subjectName?: string | null;
createdAt?: string | null;
language?: ReportLanguage;
}): string | null {
const language = input.language ?? "zh";
const name = (input.subjectName ?? "").trim();
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec((input.createdAt ?? "").trim());
const date = dateMatch && validDay(dateMatch[1]) ? dateMatch[1] : "";
const showName = name.length > 0 && !input.title.includes(name);
if (!showName && !date) return null;
if (language === "en") {
if (showName && date) return `${name} · Generated ${date}`;
if (showName) return name;
return `Generated ${date}`;
}
if (showName && date) return `${name} · 生成于 ${date}`;
if (showName) return name;
return `生成于 ${date}`;
}
function validDay(iso: string): boolean {
const [year, month, day] = iso.split("-").map(Number);
if (!year || !month || !day) return false;
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
return true;
}
+2 -2
View File
@@ -417,7 +417,7 @@ function analyzeOrdinaryReportMarkdown(markdown: string, scope: ProjectionScope
}
if (block.type === "fence") {
if (block.lang === "jyotish-chart" && fenceIsPublic(block.raw)) {
if ((block.lang === "jyotish-chart" || block.lang === "jyotish-focus") && fenceIsPublic(block.raw)) {
kept.push(block.raw);
continue;
}
@@ -497,7 +497,7 @@ function projectProseLine(line: string, scope: ProjectionScope = "report"): { ke
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
export function sanitizeRawAppendixMarkdown(markdown: string): string {
return parseBlocks(markdown).map((block) => {
if (block.type === "fence" && block.lang === "jyotish-chart") {
if (block.type === "fence" && (block.lang === "jyotish-chart" || block.lang === "jyotish-focus")) {
return fenceIsPublic(block.raw) ? block.raw : "";
}
return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => (
@@ -0,0 +1,9 @@
{
"fixtureProvenance": {
"fictional": true,
"edition": "full_data",
"command": "From tests.test_report_english_edition CASES day, edition full_data, both languages. Fictional chart only."
},
"zh": "## 重点\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"入庙\",\"role\":\"功能吉星\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"入友\",\"role\":\"功能吉星\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"中性\",\"role\":\"功能中性\"}],\"caveat\":\"这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。\"}\n```",
"en": "## Focus\n\n```jyotish-focus\n{\"version\":1,\"periods\":[{\"level\":\"md\",\"lord\":\"Rahu\",\"start\":\"2023-07-08\",\"end\":\"2041-07-08\"},{\"level\":\"ad\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2028-08-13\"},{\"level\":\"pd\",\"lord\":\"Jupiter\",\"start\":\"2026-03-21\",\"end\":\"2026-07-15\"}],\"domains\":[{\"key\":\"career\",\"house\":10,\"lord\":\"Saturn\",\"placed\":9,\"dignity\":\"Own Sign\",\"role\":\"functional benefic\"},{\"key\":\"wealth\",\"house\":2,\"lord\":\"Mercury\",\"placed\":12,\"dignity\":\"Friendly Sign\",\"role\":\"functional benefic\"},{\"key\":\"relationship\",\"house\":7,\"lord\":\"Mars\",\"placed\":2,\"dignity\":\"Neutral\",\"role\":\"functional neutral\"}],\"caveat\":\"This period may bring those house-lord topics forward. It is not a certain event.\"}\n```"
}
@@ -1165,6 +1165,33 @@ test("core read: returns a legitimate ready document after canonical re-validati
assert.ok(response.body.reportDocument);
});
test("core read: owner response includes the display name and not the birth fingerprint", async () => {
const persistence = new MemoryPersistence();
const row = {
...seedRecord(),
reportDocument: {
schemaVersion: "report_document.v2",
subject: { displayName: "虚构甲", birthFingerprint: "ab".repeat(16) },
executiveSummary: { headline: "摘要", summary: "正文" },
} as unknown as PersonalReportRecord["reportDocument"],
};
persistence.rows.set(REPORT_ID, row);
const response = await resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x",
origin: null,
allowedOrigins: [],
userId: UUID_A,
reportId: REPORT_ID,
persistence,
loadLongformMarkdown: async () => "# 长报告\n\n## 重点\n正文",
validateReadyDocument: acceptAnyDocument,
});
assert.equal(response.status, 200);
assert.equal(response.body.subjectDisplayName, "虚构甲");
assert.equal(JSON.stringify(response.body).includes("birthFingerprint"), false);
assert.equal(JSON.stringify(response.body.reportDocument).includes("虚构甲"), false);
});
test("core read: markdown loader returns longform and hides the five-chapter body when it is missing", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, seedRecord());
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
import { buildReportExportBlocks, joinExportBlocks } from "../src/lib/report-export-blocks.ts";
import { parseReportFocusBlock } from "../src/lib/report-focus-block.ts";
import { projectOrdinaryReportDocument, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection.ts";
Object.assign(globalThis, { React });
const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-focus-fictional-day.json", import.meta.url), "utf8")) as {
fixtureProvenance: { fictional: boolean; command: string; edition: string };
zh: string;
en: string;
};
const OLD_MARKDOWN = `# 印度占星星盘报告
重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。
## 重点
- 事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。
## 出生资料与图盘
旧正文仍在。
`;
function markup(markdown: string, props: { subjectName?: string; createdAt?: string; language?: "zh" | "en" } = {}) {
return renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown, drawnThrough: 0, ...props }));
}
test("fictional day-chart golden renders the focus card without MD labels", () => {
assert.equal(fixture.fixtureProvenance.fictional, true);
assert.match(fixture.fixtureProvenance.command, /full_data/);
assert.equal(fixture.fixtureProvenance.edition, "full_data");
const html = markup(fixture.zh);
assert.match(html, /现在走的大运/);
assert.match(html, /主运/);
assert.match(html, /子运/);
assert.match(html, /次子运/);
assert.match(html, /事业/);
assert.match(html, /财富/);
assert.match(html, /婚恋/);
assert.match(html, /这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。/);
assert.doesNotMatch(html, /\bMD\b|\bAD\b|\bPD\b/);
assert.doesNotMatch(html, /\{"version"/);
const english = markup(fixture.en, { language: "en" });
assert.match(english, /Current period/);
assert.match(english, /Career/);
assert.match(english, /This period may bring those house-lord topics forward/);
assert.equal(parseReportFocusBlock("{"), null);
assert.match(markup("## 重点\n\n```jyotish-focus\n{not-json}\n```\n"), /重点数据无效/);
assert.match(markup("## Focus\n\n```jyotish-focus\n{not-json}\n```\n", { language: "en" }), /Focus data is invalid/);
});
test("export writes the focus as words and the public projection keeps the fence", () => {
const blocks = buildReportExportBlocks({ markdown: fixture.zh });
const text = joinExportBlocks(blocks);
assert.doesNotMatch(text, /\{"version"/);
assert.match(text, /现在走的大运/);
assert.match(text, /这段时间可能突出这些宫主所守护的主题/);
const projected = projectOrdinaryReportMarkdown(fixture.zh);
assert.match(projected, /```jyotish-focus/);
const document = projectOrdinaryReportDocument({
schemaVersion: "report_document.v2",
subject: { displayName: "虚构甲" },
executiveSummary: { headline: "摘要", summary: "正文" },
});
assert.equal(JSON.stringify(document).includes("虚构甲"), false);
});
test("old prose without a fence is unchanged", () => {
const html = markup(OLD_MARKDOWN);
assert.match(html, /重点:当前主运 MD 北交点,子运 AD 木星,次子运 PD 木星。/);
assert.match(html, /事业第10宫主 土星 落在第9宫,尊贵为入庙,功能吉凶为功能吉星。/);
assert.doesNotMatch(html, /重点数据无效|personal-report-focus-card|现在走的大运/);
});
test("title byline covers a name, no name, and a title that already includes the name", () => {
const createdAt = "2026-10-13T08:00:00.000Z";
const named = markup("# 印度占星星盘报告\n", { subjectName: "虚构甲", createdAt });
assert.match(named, /虚构甲 · 生成于 2026-10-13/);
const unnamed = markup("# 印度占星星盘报告\n", { createdAt });
assert.match(unnamed, /生成于 2026-10-13/);
assert.doesNotMatch(unnamed, /虚构甲/);
const already = markup("# 虚构甲的印度占星星盘报告\n", { subjectName: "虚构甲", createdAt });
assert.match(already, /生成于 2026-10-13/);
assert.doesNotMatch(already, /虚构甲 · 生成于/);
const english = markup("# Vedic Astrology Chart Report\n", { subjectName: "Fictional", createdAt, language: "en" });
assert.match(english, /Fictional · Generated 2026-10-13/);
});
test("the signed-in envelope carries the display name onto the reader", () => {
const ready = classifyReportEnvelope(200, {
report: {
id: "11111111-1111-1111-1111-111111111111",
status: "ready",
failureCode: null,
createdAt: "2026-10-13T00:00:00Z",
},
longformMarkdown: "# 印度占星星盘报告\n",
subjectDisplayName: "虚构甲",
});
assert.equal(ready.phase, "markdown-ready");
if (ready.phase === "markdown-ready") assert.equal(ready.subjectDisplayName, "虚构甲");
});
+23 -2
View File
@@ -272,6 +272,20 @@ class BhavaChalitCalculator:
# fallback: 最近的宫
return 1
def _planet_bhava_from_cusps(self, planet_lon: float, cusps: List[float]) -> int:
"""KP 宫头到宫头:宫 i(0 起)的范围是 cusp[i] → cusp[(i+1)%12]。
落在下一宫头上的点归下一宫。本命 Bhava Chalit 表不用这条。
"""
for i in range(12):
start = cusps[i]
end = cusps[(i + 1) % 12]
arc = _angular_dist(start, end)
pos = _angular_dist(start, planet_lon)
if arc > 0 and pos < arc:
return i + 1
return 1
def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int:
"""整宫制宫位 (Rashi house)。"""
p_si = _sign_idx(planet_lon)
@@ -330,7 +344,8 @@ class BhavaChalitCalculator:
asc_lon: float, mc_lon: float,
house_system: str = 'sripati',
jd: float = None, lat: float = None,
lon: float = None) -> Dict:
lon: float = None,
house_assignment: str = 'sandhi') -> Dict:
"""根据 Bhava 边界重新分配行星宫位。
Parameters
@@ -346,13 +361,18 @@ class BhavaChalitCalculator:
dict with keys:
house_system, boundaries, planets
"""
if house_assignment not in ('sandhi', 'cusp_to_cusp'):
raise ValueError("house_assignment must be 'sandhi' or 'cusp_to_cusp'")
cusps = self.calculate_cusps(asc_lon, mc_lon, house_system,
jd, lat, lon)
sandhis = self.calculate_sandhis(cusps)
planets = {}
for pname, plon in planet_lons.items():
bhava = self._planet_bhava(plon, sandhis)
if house_assignment == 'cusp_to_cusp':
bhava = self._planet_bhava_from_cusps(plon, cusps)
else:
bhava = self._planet_bhava(plon, sandhis)
rashi = self._planet_rashi_house(plon, asc_lon)
si = _sign_idx(plon)
deg_in_sign = plon - si * 30
@@ -380,6 +400,7 @@ class BhavaChalitCalculator:
return {
'house_system': house_system,
'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''),
'house_assignment': house_assignment,
'ascendant_lon': round(asc_lon, 4),
'mc_lon': round(mc_lon, 4),
'planets': planets,
+3 -3
View File
@@ -6378,7 +6378,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
periods = []
current = birth_dt
for lord in DASHA_ORDER:
end_date = current + timedelta(days=period_years * 365.25636)
end_date = current + timedelta(days=period_years * 365.25)
periods.append({
'lord': lord,
'years': period_years,
@@ -7398,7 +7398,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
current_idx, current_md = analyzer.find_current(timeline, today)
antardashas = analyzer.build_antardasha(current_md)
current_ad = analyzer.find_current_sub(antardashas, today)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
@@ -7551,7 +7551,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
evidence.append({
'label': 'Darakaraka (DK)',
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量指数 {darakaraka.get('marriage_quality_score', '-')}(0到1)",
})
except Exception as exc:
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
+7 -1
View File
@@ -13963,6 +13963,7 @@ def cmd_kp(args):
)
bhava_chart = kp_house_calculator.get_bhava_chalit_chart(
planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon,
house_assignment='cusp_to_cusp',
)
bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {}
@@ -13977,7 +13978,12 @@ def cmd_kp(args):
'house': bhava_row.get('bhava_house', pdata.get('house', 1)),
}
result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps'))
result = calc_kp_analysis(
planet_positions,
asc_sign,
house_cusps=boundaries.get('cusps'),
house_occupancy_basis='cusp_to_cusp',
)
moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0
try:
birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0))
+7
View File
@@ -371,6 +371,7 @@ def calc_kp_analysis(
planet_positions: Dict,
asc_sign: str = 'Aries',
house_cusps: Optional[List[float]] = None,
house_occupancy_basis: Optional[str] = None,
) -> Dict:
"""
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
@@ -379,6 +380,7 @@ def calc_kp_analysis(
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
house_occupancy_basis: 传了宫头就必须声明判宫口径;没传宫头时记 whole_sign_proxy。
Returns:
完整KP分析结果
@@ -405,8 +407,11 @@ def calc_kp_analysis(
}
explicit_cusps = house_cusps is not None
if explicit_cusps and not house_occupancy_basis:
raise ValueError('house_occupancy_basis is required when house_cusps are provided')
if explicit_cusps and len(house_cusps) != 12:
raise ValueError('house_cusps must contain exactly 12 longitudes')
occupancy_basis = house_occupancy_basis if explicit_cusps else 'whole_sign_proxy'
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
houses = []
@@ -438,6 +443,7 @@ def calc_kp_analysis(
'source': 'diliprk/VedicAstro MIT License',
'maturity_profile': kp_maturity_profile(),
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
'house_occupancy_basis': occupancy_basis,
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
for pname, data in kp_planets.items()},
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
@@ -494,6 +500,7 @@ def build_kp_report_pack(
f"- claim_status: `{maturity.get('claim_status')}`",
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
f"- House occupancy basis: `{kp_analysis.get('house_occupancy_basis') or 'whole_sign_proxy'}`",
f"- planet_count: `{len(planets)}`",
f"- house_count: `{len(houses)}`",
f"- dba_period_count: `{len(periods)}`",
+41 -7
View File
@@ -21,6 +21,23 @@ from typing import Dict, Optional, List
SIGN_CN = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
# D9 结构证据档位(BUG-1318)。中文给报告正文,英文键与英文短句给英文版。
# 不写婚姻稳定、离婚、再婚、段数或婚前咨询。
D9_CLAIM_BOUNDARY = (
'D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询'
)
D9_CLAIM_BOUNDARY_EN = (
'D9 structural evidence index. It does not predict marriage count, stability, '
'divorce, or partner quality, and it does not suggest premarital counseling.'
)
D9_EVIDENCE_BANDS = (
('supportive_evidence_stronger', '支持证据较强', 'Stronger supportive evidence'),
('supportive_evidence_mixed_positive', '支持证据偏正', 'Mixed supportive evidence'),
('mixed_evidence', '证据混杂', 'Mixed evidence'),
('pressure_evidence_mixed', '压力证据偏杂', 'Mixed pressure evidence'),
('pressure_evidence_stronger', '压力证据较强', 'Stronger pressure evidence'),
)
def sign_of(lon: float) -> int:
"""从黄道经度计算星座序号 (0-11)"""
@@ -348,25 +365,42 @@ def _assess_d9_marriage_quality(
quality_points -= 1
factors.append("D9 Ketu在7宫 → 关系中的分离倾向")
# 综合评级
# 证据档位,不是婚姻结局(BUG-1318,上游 f891a2c1)。
if quality_points >= 4:
rating = "高(关系质量好,伴侣支持强,婚姻稳定)"
band = D9_EVIDENCE_BANDS[0]
elif quality_points >= 1:
rating = "中上(关系质量尚可,需经营但基础好)"
band = D9_EVIDENCE_BANDS[1]
elif quality_points >= 0:
rating = "中(关系质量一般,需用心经营)"
band = D9_EVIDENCE_BANDS[2]
elif quality_points >= -2:
rating = "中下(关系有挑战,需提前沟通和调整期望)"
band = D9_EVIDENCE_BANDS[3]
else:
rating = "低(关系质量有重大挑战,强烈建议婚前咨询)"
band = D9_EVIDENCE_BANDS[4]
return {
'quality_rating': rating,
'quality_band': band[0],
'quality_rating': band[1],
'quality_rating_en': band[2],
'quality_points': quality_points,
'factors': factors,
'claim_boundary': D9_CLAIM_BOUNDARY,
'claim_boundary_en': D9_CLAIM_BOUNDARY_EN,
}
def render_d9_evidence_report(quality: Dict, language: str = 'zh') -> str:
"""中文报告用证据档位短句;英文版用英文短句。两边都带同一条边界。"""
if not isinstance(quality, dict):
quality = {}
if language == 'en':
band = quality.get('quality_rating_en') or quality.get('quality_band') or ''
boundary = quality.get('claim_boundary_en') or D9_CLAIM_BOUNDARY_EN
return f"D9 evidence band: {band}\n{boundary}"
band = quality.get('quality_rating') or ''
boundary = quality.get('claim_boundary') or D9_CLAIM_BOUNDARY
return f"D9 证据档位:{band}\n{boundary}"
def _marriage_recommendations(base: Dict, d9_quality: Dict) -> List[str]:
"""几何指数与伴侣数量、事件断语分开(BUG-1183);不随指数大小给不同结论。"""
return [
+48 -12
View File
@@ -91,7 +91,12 @@ def _load_bhava_rows():
return bhava_house_rows
def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星星盘报告") -> str:
def _sanitize_pl9_ai_density_markdown(
markdown: str,
title: str = "印度占星星盘报告",
*,
opening_summary: bool = True,
) -> str:
"""Keep the parity volume's facts while removing internal-only labels."""
try:
@@ -111,6 +116,8 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
from pl9_language_terms import translate_zh_glued_phrases
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import translate_zh_glued_phrases
hold_focus_fences, restore_focus_fences = _focus_fence_tools()
markdown, focus_slots = hold_focus_fences(markdown)
# Whole phrases and glued planet+dignity tokens before shorter word swaps.
# Chapters appended after the parity strip still pass through here.
text = translate_zh_glued_phrases(_sanitize_pl9_public_markdown(markdown))
@@ -515,10 +522,12 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
continue
first_body_line = line
break
has_opening_summary = bool(first_body_line) and any(
marker in first_body_line for marker in ("摘要:", "结论:", "重点:")
has_opening_summary = _opening_focus_authored(text) or (
bool(first_body_line) and any(
marker in first_body_line for marker in ("摘要:", "结论:", "重点:")
)
)
if text.startswith(f"# {title}") and not has_opening_summary:
if opening_summary and text.startswith(f"# {title}") and not has_opening_summary:
summary = "\n".join(
[
f"# {title}",
@@ -532,7 +541,7 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星
]
)
text = re.sub(r"^#\s+.*$", summary, text, count=1, flags=re.MULTILINE)
return strip_han_plural_s(text) + "\n"
return restore_focus_fences(strip_han_plural_s(text) + "\n", focus_slots)
@@ -887,7 +896,8 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import normalize_en_markdown_terms
text = markdown
hold_focus_fences, restore_focus_fences = _focus_fence_tools()
text, focus_slots = hold_focus_fences(markdown)
replacements = {
"# PL9 对标主册": f"# {title}",
"PL9 对标主册": title,
@@ -1266,7 +1276,7 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr
# 防御性幂等:折叠残留的 "condition condition"(确保 sanitize 可安全重复执行)。
text = re.sub(r"\b(condition)(?:\s+condition)+\b", r"\1", text, flags=re.IGNORECASE)
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return text
return restore_focus_fences(text, focus_slots)
@@ -1279,7 +1289,10 @@ _ZH_FUNCTION_WORD_RE = re.compile(
r"(?<![A-Za-z])(?:of|in|the|and|by|from|with|listed|background|native|condition|met|satisfied|occupied)(?![A-Za-z])",
re.IGNORECASE,
)
_ZH_CHART_FENCE_RE = re.compile(r"```jyotish-chart\n.*?\n```", re.DOTALL)
_ZH_CHART_FENCE_RE = re.compile(
r"```(?:jyotish-chart|jyotish-focus)\n.*?\n```",
re.DOTALL,
)
def _zh_body_residue(markdown: str) -> dict[str, int]:
@@ -2230,14 +2243,36 @@ def _append_native_rashi_dashas(markdown: str, packet: dict) -> str:
return markdown.rstrip() + "\n\n" + chapter
def _focus_fence_tools():
try:
from report_focus_block import hold_focus_fences, restore_focus_fences
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.report_focus_block import hold_focus_fences, restore_focus_fences
return hold_focus_fences, restore_focus_fences
def _opening_focus_authored(text: str) -> bool:
"""True when the title is already followed by the focus heading or fence.
The fixed checklist must not come back just because the old one-line
「重点:」 summary was removed.
"""
head = "\n".join((text or "").splitlines()[:16])
if re.search(r"(?m)^## (?:重点|Focus)\s*$", head):
return True
return "```jyotish-focus" in head or "JYOTISHFOCUSHOLD" in head
def _insert_opening_focus(markdown: str, focus: str) -> str:
"""Put the current-period focus directly under the title."""
first = next((line.strip() for line in focus.splitlines() if line.strip()), "")
if not first or first in markdown:
if "```jyotish-focus" not in (focus or ""):
return markdown
match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE)
if "```jyotish-focus" in (markdown or ""):
return markdown
match = re.search(r"^#\s+.+$", markdown or "", flags=re.MULTILINE)
if not match:
return focus.rstrip() + "\n\n" + markdown
return focus.rstrip() + "\n\n" + (markdown or "")
return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :]
@@ -2286,6 +2321,7 @@ def render_full_data_markdown(packet: dict) -> str:
zh_structured_markdown = _sanitize_pl9_ai_density_markdown(
_render_pl9_ai_density_raw_data_markdown_en(packet),
_pl9_public_report_title(packet),
opening_summary=False,
)
zh_structured_appendix = re.sub(
r"^#\s+.*?(?:\n{2,}|\n)",
+75 -44
View File
@@ -1393,61 +1393,92 @@ def missing_field_count(packet: dict) -> int:
return len(fields)
def _iso_day(value: Any) -> str | None:
text = _date_text(value)
return text if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text) else None
def _current_period_rows(packet: dict) -> dict[str, dict]:
"""Current MD/AD/PD rows already stored on the dasha timeline. No new dates."""
dasha = _dict(_timing(packet).get("dasha"))
current = _dict(dasha.get("current_dasha"))
md_row = current if current.get("lord") else {}
if not md_row:
for period in _list(dasha.get("timeline")):
if isinstance(period, dict) and period.get("is_current"):
md_row = period
break
ad_row: dict = {}
pd_row: dict = {}
for row in _list(md_row.get("antardasha_timeline")):
if isinstance(row, dict) and row.get("is_current"):
ad_row = row
for child in _list(row.get("pratyantar_dasha_timeline")):
if isinstance(child, dict) and child.get("is_current"):
pd_row = child
break
break
return {"md": md_row, "ad": ad_row, "pd": pd_row}
def _focus_fence(payload: dict) -> str:
try:
from report_focus_block import build_focus_fence
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.report_focus_block import build_focus_fence
return build_focus_fence(payload)
def opening_focus_block(packet: dict) -> str:
"""Current MD/AD/PD plus career, wealth, and relationship house lords."""
"""Current MD/AD/PD plus career, wealth, and relationship house lords.
The reader draws the card from the fence. This function does not compute
a new dasha or house lord; missing dates stay null.
"""
language = _lang(packet)
lords = _current_lords(packet)
houses = _houses(packet)
planets = _planets(packet)
classification = _functional_classification(packet)
def lord_line(number: int, topic_zh: str, topic_en: str) -> str:
rows = _current_period_rows(packet)
periods = []
for level in ("md", "ad", "pd"):
row = rows[level]
lord = str(row.get("lord") or "").strip()
periods.append({
"level": level,
"lord": lord or None,
"start": _iso_day(row.get("start")),
"end": _iso_day(row.get("end")),
})
domains = []
for key, number in (("career", 10), ("wealth", 2), ("relationship", 7)):
house = _house_row(houses, number)
lord = str(house.get("lord") or "")
lord = str(house.get("lord") or "").strip()
row = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
placed = row.get("house")
dignity = _dignity(row.get("status") or row.get("dignity"), language)
role = _role(classification.get(lord), language)
if language == "zh":
return (
f"- {topic_zh}第{number}宫主 {_planet(lord, language)} 落在第{placed if isinstance(placed, int) else '未列出'}宫,"
f"尊贵为{dignity},功能吉凶为{role}。"
)
return (
f"- {topic_en} house {number} lord {lord or 'not listed'} is in house "
f"{placed if isinstance(placed, int) else 'not listed'}, dignity {dignity}, functional nature {role}."
)
domains.append({
"key": key,
"house": number,
"lord": lord or None,
"placed": placed if isinstance(placed, int) else None,
"dignity": _dignity(row.get("status") or row.get("dignity"), language),
"role": _role(classification.get(lord), language),
})
if language == "zh":
lines = [
"重点:当前主运 MD {md},子运 AD {ad},次子运 PD {pd}。".format(
md=_planet(lords["md"], language),
ad=_planet(lords["ad"], language),
pd=_planet(lords["pd"], language),
),
"",
"## 重点",
lord_line(10, "事业", "Career"),
lord_line(2, "财富", "Resources"),
lord_line(7, "婚恋", "Relationship"),
"- 这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。",
]
caveat = "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"
heading = "## 重点"
else:
lines = [
"Focus: current Maha Dasha MD {md}, Antar Dasha AD {ad}, Pratyantar Dasha PD {pd}.".format(
md=lords["md"] or "not listed",
ad=lords["ad"] or "not listed",
pd=lords["pd"] or "not listed",
),
"",
"## Focus",
lord_line(10, "事业", "Career"),
lord_line(2, "财富", "Resources"),
lord_line(7, "婚恋", "Relationship"),
"- This period may bring those house-lord topics forward. It is not a certain event.",
]
return "\n".join(lines)
caveat = "This period may bring those house-lord topics forward. It is not a certain event."
heading = "## Focus"
fence = _focus_fence({
"version": 1,
"periods": periods,
"domains": domains,
"caveat": caveat,
})
return f"{heading}\n\n{fence}"
def _strength_snapshot(packet: dict) -> dict:
+1
View File
@@ -2140,6 +2140,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'### p123 KP Chart / Cusps',
'',
f"- House basis:{_cell(kp.get('house_basis'))}",
f"- House occupancy basis:{_cell(kp.get('house_occupancy_basis'))}",
'',
'| House | Sign | Cusp longitude | KP lord chain |',
'|-------|------|----------------|---------------|',
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Structured opening-focus fence for the full-data report.
The engine writes one ``jyotish-focus`` JSON fence. The report page renders
that fence; it does not recompute dasha lords or house lords.
"""
from __future__ import annotations
import json
import re
from typing import Any
FOCUS_FENCE_RE = re.compile(r"```jyotish-focus\n(.*?)\n```", re.DOTALL)
_FOCUS_BLOCK_RE = re.compile(r"```jyotish-focus\n.*?\n```", re.DOTALL)
_HOLD_TOKEN = "JYOTISHFOCUSHOLD{index}END"
def build_focus_fence(payload: dict[str, Any]) -> str:
"""Return a one-line JSON fence. Values are already public words."""
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return f"```jyotish-focus\n{body}\n```"
def parse_focus_fence(markdown: str) -> dict[str, Any] | None:
match = FOCUS_FENCE_RE.search(markdown or "")
if not match:
return None
try:
value = json.loads(match.group(1))
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def hold_focus_fences(markdown: str) -> tuple[str, list[str]]:
"""Lift fences out of the glossary pass so planet words stay valid JSON."""
slots: list[str] = []
def repl(match: re.Match[str]) -> str:
slots.append(match.group(0))
return _HOLD_TOKEN.format(index=len(slots) - 1)
return _FOCUS_BLOCK_RE.sub(repl, markdown or ""), slots
def restore_focus_fences(markdown: str, slots: list[str]) -> str:
text = markdown
for index, slot in enumerate(slots):
text = text.replace(_HOLD_TOKEN.format(index=index), slot)
return text
+4 -2
View File
@@ -1687,11 +1687,13 @@ class YogaEngine:
# 辅助:获取角宫主星列表
def _kendra_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# 辅助:获取三方宫主星列表
def _trikona_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
safe_globals = {
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
+134
View File
@@ -0,0 +1,134 @@
"""BUG-1318: D9 marriage rating is an evidence band, not an outcome claim.
Upstream f891a2c1. The five bands must not say the relationship is stable,
ending, repeated, or in need of premarital counseling. The DK evidence line
keeps the 0-1 index and does not print a fake /100 score.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
ROOT = os.path.join(os.path.dirname(__file__), "..")
SCRIPTS = os.path.join(ROOT, "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from jyotish_engine import compute_chart_data # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
from marriage_counting import ( # noqa: E402
D9_CLAIM_BOUNDARY,
D9_EVIDENCE_BANDS,
_assess_d9_marriage_quality,
marriage_counting_full_analysis,
render_d9_evidence_report,
)
from varga import calc_varga # noqa: E402
BANNED = re.compile(r"稳定|离婚|再婚|婚前咨询|几段")
def _quality(points_chart: dict) -> dict:
return _assess_d9_marriage_quality(points_chart, None)
def test_five_bands_have_no_outcome_claims_and_keep_the_boundary() -> None:
samples = {
"supportive_evidence_stronger": {"Venus": 331.0, "Mars": 271.0, "Jupiter": 91.0},
"supportive_evidence_mixed_positive": {"Venus": 31.0},
"mixed_evidence": {"Venus": 10.0},
"pressure_evidence_mixed": {"Venus": 151.0},
"pressure_evidence_stronger": {"Venus": 151.0, "Mars": 91.0, "Jupiter": 271.0},
}
seen = []
for key, chart in samples.items():
quality = _quality(chart)
assert quality["quality_band"] == key
assert quality["claim_boundary"] == D9_CLAIM_BOUNDARY
assert quality["claim_boundary_en"]
assert not BANNED.search(quality["quality_rating"])
assert not BANNED.search(quality["quality_rating_en"])
seen.append(quality["quality_band"])
assert seen == [band[0] for band in D9_EVIDENCE_BANDS]
def test_public_chart_report_snapshots_in_both_languages() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
assert birth["source"]["rodden_rating"] == "AA"
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
d9 = {}
for name, lon in longitudes.items():
divisional = calc_varga(lon, 9)
d9[name] = divisional["sign_idx"] * 30 + divisional["degree_in_sign"]
result = marriage_counting_full_analysis(chart["houses"]["house_7"]["lord"], longitudes, d9)
quality = result["d9_marriage_quality"]
assert quality["quality_band"] == "mixed_evidence"
assert quality["quality_points"] == 0
chinese = render_d9_evidence_report(quality, "zh")
english = render_d9_evidence_report(quality, "en")
assert chinese == (
"D9 证据档位:证据混杂\n"
"D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询"
)
assert english == (
"D9 evidence band: Mixed evidence\n"
"D9 structural evidence index. It does not predict marriage count, stability, "
"divorce, or partner quality, and it does not suggest premarital counseling."
)
assert "/100" not in chinese
assert "/100" not in english
def test_dk_evidence_line_prints_the_zero_to_one_index() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, asc_idx, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
handler = _api_handler()
evidence = handler._compute_relationship_timing_evidence(
{},
chart["planets"],
longitudes,
asc_idx,
chart["ascendant"]["sign"],
)
notes = [row["note"] for row in evidence["evidence"] if row.get("label") == "Darakaraka (DK)"]
assert len(notes) == 1
assert "/100" not in notes[0]
assert "婚姻质量指数" in notes[0]
assert "(0到1)" in notes[0]
score = evidence["darakaraka"]["marriage_quality_score"]
assert isinstance(score, float)
assert 0.0 <= score <= 1.0
assert str(score) in notes[0]
+62
View File
@@ -0,0 +1,62 @@
"""BUG-1321: the API fallback dasha year matches dasha_analyzer's 365.25 days.
Upstream 4d543209. The two 365.25636 constants in the API server made the
fallback timeline and the years-into-mahadasha figure drift from the analyzer.
"""
from __future__ import annotations
import importlib.util
import os
import sys
from datetime import datetime, timedelta
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers_dasha", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
def test_fallback_timeline_matches_analyzer_to_the_day() -> None:
birth = datetime(2000, 1, 1)
nak_info, progress, _pada = lon_to_nakshatra(0.0)
timeline, _elapsed, _remaining, _lord = build_dasha_timeline("2000-01-01", nak_info, progress)
handler = _api_handler()
years = timeline[0]["years"]
periods = handler._fallback_dasha_periods(birth, "generic", {"years": years * 9})
assert periods[0]["lord"] == timeline[0]["lord"]
assert periods[0]["start"] == timeline[0]["start"].strftime("%Y-%m-%d")
assert periods[0]["end"] == timeline[0]["end"].strftime("%Y-%m-%d")
fallback_days = (
datetime.strptime(periods[0]["end"], "%Y-%m-%d")
- datetime.strptime(periods[0]["start"], "%Y-%m-%d")
).days
analyzer_days = (timeline[0]["end"].date() - timeline[0]["start"].date()).days
assert fallback_days - analyzer_days == 0
long_periods = handler._fallback_dasha_periods(birth, "generic", {"years": 1422})
expected = (birth + timedelta(days=158 * 365.25)).strftime("%Y-%m-%d")
stale = (birth + timedelta(days=158 * 365.25636)).strftime("%Y-%m-%d")
assert expected != stale
assert long_periods[0]["end"] == expected
def test_years_into_mahadasha_uses_the_analyzer_year() -> None:
birth = datetime(2000, 1, 1)
today = datetime(2003, 1, 1)
handler = _api_handler()
layer = handler._compute_vimshottari_analysis_layer(birth, 0.0, current_date="2003-01-01")
days = (today - birth).days
years_into = layer["five_levels"]["mahadasha"]["years_into_period"]
assert years_into == days / 365.25
assert years_into != days / 365.25636
+58
View File
@@ -0,0 +1,58 @@
"""BUG-1319: KP house occupancy is cusp-to-cusp and must be declared.
Upstream 0337b77f. Natal Bhava Chalit stays on the sandhi midpoint rule.
"""
from __future__ import annotations
import os
import sys
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from bhava_chalit import BhavaChalitCalculator # noqa: E402
from kp_system import build_kp_report_pack, calc_kp_analysis # noqa: E402
def test_cusp_to_cusp_differs_from_sandhi_and_default_houses_stay() -> None:
calc = BhavaChalitCalculator()
planets = {"Sun": 40.0}
sandhi = calc.get_bhava_chalit_chart(planets, 15.0, 270.0, "equal")
explicit = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="sandhi",
)
cusp = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="cusp_to_cusp",
)
assert sandhi["house_assignment"] == "sandhi"
assert explicit["planets"]["Sun"]["bhava_house"] == sandhi["planets"]["Sun"]["bhava_house"] == 2
assert cusp["house_assignment"] == "cusp_to_cusp"
assert cusp["planets"]["Sun"]["bhava_house"] == 1
assert cusp["planets"]["Sun"]["bhava_house"] != sandhi["planets"]["Sun"]["bhava_house"]
def test_unknown_house_assignment_is_rejected() -> None:
calc = BhavaChalitCalculator()
with pytest.raises(ValueError, match="house_assignment"):
calc.get_bhava_chalit_chart({"Sun": 10.0}, 15.0, 270.0, "equal", house_assignment="whole")
def test_kp_cusps_require_an_occupancy_basis_and_the_report_names_it() -> None:
planets = {"Sun": {"sign": "Taurus", "degree": 10.0, "house": 2}}
cusps = [15.0 + 30.0 * index for index in range(12)]
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps)
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps, house_occupancy_basis="")
declared = calc_kp_analysis(
planets, "Aries", house_cusps=cusps, house_occupancy_basis="cusp_to_cusp",
)
assert declared["house_occupancy_basis"] == "cusp_to_cusp"
proxy = calc_kp_analysis(planets, "Aries")
assert proxy["house_occupancy_basis"] == "whole_sign_proxy"
markdown = build_kp_report_pack(declared)["exports"]["markdown"]
assert "House occupancy basis: `cusp_to_cusp`" in markdown
+72 -4
View File
@@ -8,15 +8,18 @@ calculate a new dasha or import PyJHora.
from __future__ import annotations
import copy
import json
import re
from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown
from scripts.pl9_interpretation_packs import (
_current_lords,
attach_full_data_interpretation,
missing_field_count,
opening_focus_block,
section_coverage,
)
from scripts.report_focus_block import FOCUS_FENCE_RE, parse_focus_fence
from scripts.pl9_reader_english import HAN
FORBIDDEN_ZH = (
@@ -170,9 +173,15 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None:
packet = _packet("zh")
attach_full_data_interpretation(packet)
focus = opening_focus_block(packet)
assert focus.startswith("重点:")
assert "MD" in focus and "AD" in focus and "PD" in focus
assert "第10宫" in focus and "第2宫" in focus and "第7宫" in focus
# 原值:focus.startswith("重点:"),并且正文含 MD / AD / PD。
# 新值:开篇是「## 重点」加一个 jyotish-focus 围栏,不再写那一行行话。
# 原因:产品选择把开篇做成摘要卡,围栏交给阅读页画,不在正文里堆 MD/AD/PD。
assert focus.startswith("## 重点")
assert "重点:当前主运" not in focus
assert "MD " not in focus and "AD " not in focus and "PD " not in focus
payload = parse_focus_fence(focus)
assert payload is not None
assert [row["house"] for row in payload["domains"]] == [10, 2, 7]
text = render_full_data_markdown(packet)
for heading in ("大运解释", "行星、星宿与宫位解释", "宫主逐宫解释", "Dosha 逐项事实与解释", "瑜伽解释"):
assert heading in text
@@ -194,7 +203,12 @@ def test_templates_cover_required_sections_and_forbidden_wording() -> None:
def test_english_focus_and_new_sections_have_no_han() -> None:
packet = _packet("en")
text = render_full_data_markdown(packet)
assert "Focus: current Maha Dasha MD" in text
# 原值:正文含 "Focus: current Maha Dasha MD"。
# 新值:英文开篇是「## Focus」加 jyotish-focus 围栏,不再写那一行。
# 原因:与中文摘要卡同一结构;大运章里的 AD/PD 表头仍保留。
assert "## Focus" in text
assert "```jyotish-focus" in text
assert "Focus: current Maha Dasha MD" not in text
assert "AD" in text and "PD" in text
assert "Dasha Interpretation" in text
assert "House-lord interpretation" in text
@@ -209,6 +223,60 @@ def test_english_focus_and_new_sections_have_no_han() -> None:
assert leaked == [], leaked[:12]
def _focus_dates(packet: dict, level: str) -> tuple[str | None, str | None]:
dasha = packet["worksheets"]["timing_and_predictive_systems"]["dasha"]
current = dasha["current_dasha"]
if level == "md":
return current.get("start"), current.get("end")
for row in current["antardasha_timeline"]:
if not row.get("is_current"):
continue
if level == "ad":
return row.get("start"), row.get("end")
for child in row["pratyantar_dasha_timeline"]:
if child.get("is_current"):
return child.get("start"), child.get("end")
return None, None
def test_opening_focus_fence_matches_current_lords_and_skips_the_fixed_list() -> None:
for language in ("zh", "en"):
packet = _packet(language)
lords = _current_lords(packet)
text = render_full_data_markdown(packet)
fences = FOCUS_FENCE_RE.findall(text.split("## 结构化资料附录", 1)[0].split("## Structured Data Appendix", 1)[0])
assert len(fences) == 1, language
payload = json.loads(fences[0])
assert payload["version"] == 1
assert [row["level"] for row in payload["periods"]] == ["md", "ad", "pd"]
assert [row["lord"] for row in payload["periods"]] == [lords["md"], lords["ad"], lords["pd"]]
for row in payload["periods"]:
start, end = _focus_dates(packet, row["level"])
assert row["start"] == start
assert row["end"] == end
assert [row["key"] for row in payload["domains"]] == ["career", "wealth", "relationship"]
assert [row["house"] for row in payload["domains"]] == [10, 2, 7]
opening = text.split("## 出生资料", 1)[0].split("## Birth Data", 1)[0]
assert "重点:当前主运" not in opening
assert "先核对出生资料、图盘" not in text
if language == "zh":
assert "MD " not in opening and "AD " not in opening and "PD " not in opening
assert "功能吉凶为功能吉星" not in opening
assert payload["caveat"] == "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。"
else:
assert "Focus: current Maha Dasha" not in opening
def test_opening_focus_keeps_null_when_a_period_has_no_date() -> None:
packet = _packet("zh")
packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["current_dasha"]["antardasha_timeline"][0]["end"] = ""
payload = parse_focus_fence(render_full_data_markdown(packet))
assert payload is not None
ad = next(row for row in payload["periods"] if row["level"] == "ad")
assert ad["start"] == "2001-01-01"
assert ad["end"] is None
def test_yoga_effect_does_not_assert_lifespan() -> None:
# 原值:作用列写「长寿;健康良好」/ Longevity。
# 新值:作用列改成体质与恢复力的参考句。名称「长寿格局」和 Ayur Yoga (Strong Longevity) 可以留下。
+72
View File
@@ -0,0 +1,72 @@
"""BUG-1320: kendra and trikona lord lists do not follow PYTHONHASHSEED.
Upstream 0337b77f. Two processes with different hash seeds must list the
same yoga participants in the same order.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_PROBE = r"""
import json
import sys
sys.path.insert(0, "scripts")
from yoga_engine import YogaContext, YogaEngine, detect_yogas
planets = {
"Sun": {"sign": "Leo", "house": 5, "degree": 10},
"Moon": {"sign": "Cancer", "house": 4, "degree": 5},
"Mars": {"sign": "Aries", "house": 1, "degree": 8},
"Mercury": {"sign": "Virgo", "house": 6, "degree": 12},
"Jupiter": {"sign": "Sagittarius", "house": 9, "degree": 15},
"Venus": {"sign": "Libra", "house": 7, "degree": 3},
"Saturn": {"sign": "Capricorn", "house": 10, "degree": 20},
"Rahu": {"sign": "Taurus", "house": 2, "degree": 9},
"Ketu": {"sign": "Scorpio", "house": 8, "degree": 9},
}
rules = "references/yoga_rules.json"
engine = YogaEngine(rules)
ctx = YogaContext(planets, "Aries")
namespace = engine._build_custom_exec_globals(ctx, {}, {})
rows = detect_yogas(planets, "Aries", rules)
print(json.dumps({
"kendra": namespace["kendra_lords_list"](),
"trikona": namespace["trikona_lords_list"](),
"participants": [
[row.get("rule_id"), str(row.get("combination"))]
for row in rows
],
}, ensure_ascii=False))
"""
def _run(seed: str) -> dict:
env = os.environ.copy()
env["PYTHONHASHSEED"] = seed
env["PYTHONIOENCODING"] = "utf-8"
completed = subprocess.run(
[sys.executable, "-c", _PROBE],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return json.loads(completed.stdout)
def test_yoga_participants_keep_the_same_order_across_hash_seeds() -> None:
first = _run("0")
second = _run("1")
assert first == second
assert first["kendra"] == sorted(first["kendra"])
assert first["trikona"] == sorted(first["trikona"])
assert first["kendra"] == ["Mars", "Moon", "Saturn", "Venus"]
assert first["trikona"] == ["Jupiter", "Mars", "Sun"]