Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd1ff7d0c | ||
|
|
aae3fea011 | ||
|
|
4cdb997800 | ||
|
|
2748e43871 | ||
|
|
d32ad7b7b1 | ||
|
|
99ef1bbd12 | ||
|
|
77057a819b | ||
|
|
9fca54eacb | ||
|
|
8883f8475f |
@@ -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)
|
||||
|
||||
- 重新生成的报告里,「本命与分盘北印度图盘」每张有数据的盘下面有一张北印度图。同一张盘只画一次。报告最末不再重复「分盘核对」。旧报告打开仍是当时保存的正文。
|
||||
|
||||
@@ -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 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
|
||||
- 生时校正打分文件没有改。
|
||||
@@ -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 为基,进度记录写明。
|
||||
@@ -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%.
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)}`",
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |',
|
||||
'|-------|------|----------------|---------------|',
|
||||
|
||||
@@ -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
|
||||
@@ -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 缺失而静默失效。
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) 可以留下。
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user