From 25d067bf125b1d50307d8dfea06076133e459728 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 8 Oct 2026 17:11:08 +0800 Subject: [PATCH] fix(report): fill the full-data edition and clear English Han Add the interpretation pages from fields the engine already computed, project the annual tables the reader prints, and clear English Han on the four measured charts. Patyayini is not implemented. The generate button copy is unchanged. This commit stays on the feature branch and is not pushed to staging. --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 34 +- .../PROGRESS-report-full-data-fix-20261008.md | 59 + docs/tasks/README.md | 4 +- docs/testing/report-full-data-fix-20261008.md | 19 + scripts/pl9_full_data_export.py | 79 +- scripts/pl9_interpretation_packs.py | 1228 +++++++++++++++++ scripts/pl9_language_terms.py | 178 ++- scripts/pl9_reader_export.py | 174 ++- tests/test_report_english_edition.py | 30 +- tests/test_report_full_data_fix.py | 218 +++ 11 files changed, 1958 insertions(+), 71 deletions(-) create mode 100644 docs/tasks/PROGRESS-report-full-data-fix-20261008.md create mode 100644 docs/testing/report-full-data-fix-20261008.md create mode 100644 scripts/pl9_interpretation_packs.py create mode 100644 tests/test_report_full_data_fix.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a0db361d..d14253c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ - 行星、上升和宫头以前用含章动的视位置,减去不含章动的平岁差,全体偏一个很小的角度。现在与 Swiss Ephemeris 的恒星黄道使用同一套章动(BUG-1275)。 - 大运日期可能因此前后移动 2–3 天。同一份生时校正输入的分数会变。算法身份从 scoring-11 升到 scoring-12。以前做好的校正记录仍能打开,只读,并可以按新算法再比一次。Skill 版本不变。 +## 2026-10-08 — 完整数据版补上解释,英文可以生成(未上线) + +- 个人报告的中文补上大运、行星、宫主、Dosha 和瑜伽的解释,开篇「重点」写当前三级大运和事业、财富、婚恋三宫的宫主。Patyayini 仍没有实现。 +- 乔布斯、奥巴马、爱因斯坦和一张虚构盘的英文不再夹汉字,新报告可以切换「中文 / English」(BUG-1272)。「Bhava 年龄状态」「行星s」这两处误译已改(BUG-1274)。 +- 生成按钮的时长说明没改。以前生成的报告仍按当时的正文打开。不改数据库。Skill 版本不变。 + ## 2026-10-08 — 个人报告改为完整数据版(未上线) - 「生成报告」改为完整数据版:星盘、力量、大运、年运和瑜伽写进正文,后面有一份结构化资料附录。阅读版的生成入口已删除。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e0fd219c..d9f24abd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17117,19 +17117,19 @@ ## BUG-1272 | 完整数据版英文报告清理后仍含汉字,接口整份撤回英文 -- 状态:investigating +- 状态:resolved(本分支 `codex/report-full-data-edition-20261007`,未合入 staging,未上线) - 首次发现 / 最近更新:2026-10-08 / 2026-10-08 -- 来源:`TASK-report-full-data-edition-20261007`。上游任务书已记录乔布斯盘英文清理失败(`chinese_characters:14981`)。 +- 来源:`TASK-report-full-data-edition-20261007`。上游任务书已记录乔布斯盘英文清理失败(`chinese_characters:14981`)。补齐见 `TASK-report-full-data-fix-20261008`。 - 影响面:新生成的个人报告英文版。中文完整数据版不受影响。已生成的旧报告仍按当时保存的正文打开。 - 现象:完整数据版英文沿用上游清理器。清理器对没有译文的汉字不会删行,而是包成 `[source text retained: …]`。这样正文里仍然有汉字。公开接口发现汉字后设置 `english_unavailable=han_leak`,不返回 `markdown_en`。页面不显示「中文 / English」切换。 -- 触发条件:用完整数据版生成中英两份。本机虚构盘、乔布斯盘、奥巴马盘都如此。泰勒盘没有公开出生资料,没有生成。 -- 根因:英文清理器的设计是留下缺译文的原文,避免看起来干净、实际少了证据。本仓资料包里这类汉字不是一小张术语表:补译分盘提示、行星主星标题、八分法标题和「合计」之前,三张盘大约有 350–470 处汉字串、240–300 个不同写法,很多是单字或半句。不能靠手写词典清空,也不能把夹汉字的英文交给用户。 -- 修复:未解决。生成路径已经整份撤回英文,不输出中英夹杂的英文报告。清理器里的汉字标记只留在生成过程中。 -- 验证:`tests/test_report_english_edition.py` 要求要么英文没有汉字,要么接口撤回且不带 `markdown_en`。没有在浏览器里走查。 -- 防复发:沿用阅读版泄漏词表检查英文全文;接口和前端都在发现汉字时撤回英文。汉字清零之前不得把这则标成 resolved。 -- 相关记录:BUG-1126(有英文版时切换必须真换正文)、BUG-1271。 +- 触发条件:用完整数据版生成中英两份。本机虚构盘、乔布斯盘、奥巴马盘都如此。泰勒盘没有公开出生资料,没有生成。第四张公开盘用爱因斯坦。 +- 根因:短词替换会先把整句拆开,剩下的单字和半句再被包进 source text retained。早期大约 350–470 处汉字串,是拆开之后的碎片,不是必须整段删掉的正文。双主星有一条中文依据,资料包里本来就有英文对照,阅读时却只印了中文,单字「水」又把「水瓶」拆成了 Mercury。 +- 修复:章节标题按语言写;拆开后剩下的碎片补进英文术语,不删段落。双主星说明在英文里改用已有的英文依据。清理器仍把没有译文的汉字包起来。接口发现汉字后仍整份撤回英文。 +- 验证:虚构、爱因斯坦、乔布斯、奥巴马四张盘英文汉字数为 0,中英二级标题都是 35/35。`tests/test_report_english_edition.py` 改为要求三张虚构盘汉字为 0,并且虚构日盘的接口必须交回英文。故意写入汉字的测试仍要求 `english_unavailable=han_leak`。没有在浏览器里走查。 +- 防复发:上述测试。接口和前端仍在发现汉字时撤回英文。 +- 相关记录:BUG-1126(有英文版时切换必须真换正文)、BUG-1271、BUG-1274。 - 复发自:无。 -- 修复版本:无。 +- 修复版本:本分支,未合入 staging。 ## BUG-1273 | 生产镜像没有 PyJHora,八个特殊上升点算不出来 @@ -17149,6 +17149,22 @@ - Claude 验收(2026-10-08,Linux):屏蔽 `jhora` 导入后跑 `cmd_full_reading`,乔布斯、奥巴马、虚构盘 × Lahiri / Raman 共 6 组,8 个点全部 `computed`、`source` 为 `native_special_lagna`,与 `fb8277bf` 调 PyJHora 的结果最大差 0°。Python 全量 62 条失败,与 `2df6ee72` 逐名相同;快速门通过;`tests/test_runtime_jhora_import_scan.py` 在快速门内,人为在 `native_special_lagnas.py` 加一行 `from jhora ...` 后该测试失败(变异检查)。年运资料包屏蔽 `jhora` 前后逐字节相同。 - 待问占星师:Vighati 上升按每分钟 15° 计(与 PyJHora 一致);按字面「每个 vighati 一个星座」应为每分钟 75°。 +## BUG-1274 | 中文报告把 Bhava Bala 译成年龄状态,并把 Planets 译成行星s + +- 状态:resolved(本分支 `codex/report-full-data-edition-20261007`,未合入 staging,未上线) +- 首次发现 / 最近更新:2026-10-08 / 2026-10-08 +- 来源:`TASK-report-full-data-fix-20261008` 的译名项。 +- 影响面:完整数据版中文报告的力量标题和行星标题。 +- 现象:标题出现「六维力量 / Bhava 年龄状态」和「Natal / KP Ruling 行星s」。 +- 触发条件:生成完整数据版中文。清理器先把 Shadbala 写成「六维力量」,再把单独的 Bala 写成「年龄状态」,于是修 Bhava Bala 的那一步对不上原文。另一处把 Planet 换成「行星」时没有先处理 Planets,复数的 s 留在汉字后面。 +- 根因:两段替换都按较短的词下手,较长的词还在句里。 +- 修复:先替换整段「Natal / KP Ruling Planets」「Planets」「Planetary」,再替换 Planet。力量标题改回「六维力量 / Bhava Bala」。星体年龄那一列仍叫年龄状态,没有全局改掉。 +- 验证:`tests/test_report_full_data_fix.py` 检查这两句,并检查中文不得出现「英文单词 + 汉字 + s」。 +- 防复发:同一条测试。 +- 相关记录:BUG-1272。 +- 复发自:无。 +- 修复版本:本分支,未合入 staging。 + ## BUG-1275 | 恒星黄道经度少扣章动:所有行星偏约 0.005°,Vimshottari 日期差 2–3 天 - 状态:resolved(代码;部署以 `/api/health` 的 `deployment.gitCommit` 为准) diff --git a/docs/tasks/PROGRESS-report-full-data-fix-20261008.md b/docs/tasks/PROGRESS-report-full-data-fix-20261008.md new file mode 100644 index 00000000..ed331079 --- /dev/null +++ b/docs/tasks/PROGRESS-report-full-data-fix-20261008.md @@ -0,0 +1,59 @@ +# 进度 · 完整数据版补齐(2026-10-08) + +- 分支:`codex/report-full-data-edition-20261007` +- 基线:`e14fa377`(完整数据版那一笔,已经 rebase 到 `origin/staging` `16782634` 之上;特殊上升点 `795b8845` 仍是祖先) +- 状态:代码在本分支,**未合入 staging**,未上线。等 Claude 验收通过后再合入 +- 远端功能分支上的旧提交 `06028759` 不是这次 rebase 之后的祖先。更新功能分支需要 `--force-with-lease`,不能推 `staging` + +## 结论 + +新报告仍是完整数据版。中文补上了大运、行星、宫主、Dosha、瑜伽解释,以及开篇「重点」。英文四张盘汉字数为 0,接口会返回英文。Patyayini 没有实现,没有新写计算器。生成按钮文案没改。 + +## 四张盘(本机,一次一个进程) + +汉字范围用的是接口同一条规则。没有把出生时间写进这份记录。 + +| 盘 | 中文字符 | 英文字符 | 英文汉字 | 二级标题(中/英) | 缺字段 | +| --- | --- | --- | --- | --- | --- | +| 虚构 | 212336 | 361577 | 0 | 35/35 | 6 | +| 爱因斯坦 | 234199 | 402881 | 0 | 35/35 | 6 | +| 乔布斯 | 209717 | 359869 | 0 | 35/35 | 6 | +| 奥巴马 | 222602 | 380264 | 0 | 35/35 | 6 | + +四份中文都有「大运解释」「行星、星宿与宫位解释」「宫主逐宫解释」「Dosha 逐项」「瑜伽解释」,开篇「重点」里有 MD、AD、PD。中文没有「再婚」「几段关系」「早夭」「一定会」「必然」「疾病诊断」「疾病」。英文没有 divorce、miscarriage、infertility、guarantee。决策 4 的七种大运标题没有出现。中文都超过约 20 万字,阅读页会一次显示一章。 + +另有一张任务书测试里的夜盘,双主星说明原来会留下汉字。英文改用资料包里已经写好的英文依据之后,正文和适用性说明的汉字也是 0。接口因此会交出英文。 + +## 章节目录 + +| 状态 | 章节 | +| --- | --- | +| 已有 | 大运解释、行星解释、宫主逐宫、Dosha 逐项、瑜伽解释、Mudda、Sahams、Tajika 瑜伽、Mudda 月段窗口、太阳返照 | +| 本单不做(决策 4) | Sthira、Drig、Shoola、Navamsha、Niryaana Shoola、Lagna Kendradi、Chara。不写占位 | +| 本单不做(让步,月返照 / 八年总览) | 月返照盘、八年总览。目录标成延后,不算进缺字段 | +| 缺字段 | Patyayini(两节同一个来源,只计一次;正文写「没有实现」)、Raman Sahams(不用普通 Sahams 冒充)、一生年运图、Tripataki、年运特殊点、当年结果说明。合计 6,不超过 10 | + +## 让步 + +1. T6 生成按钮不改。`frontend/docs/VOICE.md` 和现有测试锁着「大约 10–30 秒」。本机不是 staging 的 2 核计时,不新编一段分钟数。卡片里的「中英两版」留着,因为英文现在能生成。 +2. T3 没有让。开篇「重点」写当前 MD / AD / PD,以及事业、财富、婚恋三宫的宫主、尊贵和功能吉凶。 +3. T2 的月返照和八年总览按让步顺序放下,没有新算。 + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tests/test_report_full_data_fix.py` | 4 条通过 | +| `tests/test_report_english_edition.py` | 18 条通过。含上面两处改过的断言 | +| 运行时不得导入 jhora | `tests/test_runtime_jhora_import_scan.py` 4 条通过 | +| ruff(改过的四个脚本加新测试) | 8 条都是改动前就有的,没有改格式去消 | +| Linux 快速门、Python 全量、`npm test`、`next build` | 本机没跑,不能当成通过 | + +改过的既有断言: + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 英文无汉字 | 有汉字就必须包在 source text retained 里 | 三张虚构盘汉字数为 0 | BUG-1272 已清零 | +| 接口是否交英文 | 没汉字就交,有汉字就整份撤回 | 虚构日盘必须交回无汉字的英文 | 同上。故意写进汉字的那条测试仍要求撤回 | + +没有改 `scripts/jyotish_api_server.py`,没有改 `Home()` 的状态数,没有改默认报告版本,没有改生时校正公式,没有改生成按钮。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 7c894f48..255ce4b0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -202,8 +202,8 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 未合入(验收未通过,见 fix-20261008) | `codex/report-full-data-edition-20261007`(未推 staging) | -| `TASK-report-full-data-fix-20261008.md` | — | **完整数据版补齐**(同分支继续):中文只有上游 19%(9.9 万 vs 52.6 万字);产品允许移植上游解释生成器并逐条核口径;年运表接我方字段;英文必须交付才上线;其他 7 种大运另开单 | 待领取 | — | -| `TASK-report-full-data-fix2-20261008.md` | — | **完整数据版第二轮**(同分支 `32f19f07` 续):英文已过;未过——全量 +1 旧断言、寿命断言、半译词与字段名被改坏、「守护第未列出宫」163 处、年运章 6/29 节、大运解释只有上游 42%(产品:补回传统段落改「可能」口吻) | 待领取 | — | +| `TASK-report-full-data-fix-20261008.md` | `PROGRESS-report-full-data-fix-20261008.md` | **完整数据版补齐**(同分支继续):解释段、年运字段、开篇重点、英文汉字清零。Patyayini 不新算。生成按钮文案不改。等验收后再合入 staging | 验收未通过(见 fix2) | `codex/report-full-data-edition-20261007`(未合入 staging) | +| `TASK-report-full-data-fix2-20261008.md` | — | **完整数据版第二轮**(同分支续):英文已过;未过——全量 +1 旧断言、寿命断言、半译词与字段名被改坏、「守护第未列出宫」163 处、年运章 6/29 节、大运解释只有上游 42%(产品:补回传统段落改「可能」口吻) | 待领取 | — | | `TASK-report-reader-main-fix-20260925.md` | `PROGRESS-report-reader-main-fix-20260925.md` | **读者版修复**:真实年主比较、分字段绑定与未来返照;保留 Bhava Bala 和标题,隔离报告/聊天规则 | 已验收(Claude 09-25:年运表逐年填齐且不同、Bhava Bala 保留、泄漏 0) | `4d801e53`(已部署,health 核对一致) | | `TASK-report-reader-main-20260924.md` | `PROGRESS-report-reader-main-20260924.md` | **报告正文换上游读者版 reader_main + KP / Muntha 计算缺陷 + 投影截断(BUG-1026/1027/1028)**:正文一直是上游审计版(读者版 09-09 才出、从未同步),本仓又自加「结论等级规则」/英文段/异常原文;投影黑名单不认 PL9 词汇且截断半句、删表留说明;KP args `vars()` 拷贝为空恒失败;Muntha 漏合 `birth_asc_sign_idx` → Year Lord 恒火星。产品拍板接入读者版、旧报告不回填。 | 部分通过(Claude 09-25:读者版切换、泄漏 0 命中、KP 已修;年运 Year Lord/Muntha 全为「-」P1、投影误删 Bhava Bala P2 → 修复单) | `3c2f7bd5`(已部署) | | `TASK-report-reader-actions-20260924.md` | — | **报告操作收敛**:列表只留「查看报告」;阅读页删「下载原始附录」「打印」,导出改图标按钮;导出对话框 ≥860 居中、窄屏仍贴底(修订 chapter-export D9/D10)。产品变更,不开 BUG 号 | 基本通过(Claude 09-24:入口收敛、图标、≥860 居中均符合;原始附录三文件因执行方权限未删、PROGRESS/清单未写 → 并入修复单) | `f125fae0`(未部署) | diff --git a/docs/testing/report-full-data-fix-20261008.md b/docs/testing/report-full-data-fix-20261008.md new file mode 100644 index 00000000..ec950296 --- /dev/null +++ b/docs/testing/report-full-data-fix-20261008.md @@ -0,0 +1,19 @@ +# 真机清单 · 完整数据版补齐(2026-10-08) + +对应 `docs/tasks/TASK-report-full-data-fix-20261008.md`。电脑和手机各走一遍。第 1 步会用掉一次生成次数。本机没有登录态,也没有用浏览器点过,下表结果留空。 + +这一轮和上一份清单的差别,供对照: + +- 新报告中文大约 21 万到 23 万字,超过约 20 万字的分页线。阅读页一次只显示一章,目录和「上一章 / 下一章」可以换章。 +- 四张核对过的盘,英文汉字数为 0。新生成的报告应出现「中文 / English」。点过去正文和目录一起换,停在同一章。 +- 生成卡片仍写「大约 10–30 秒」和「中英两版」。时长不是 staging 上量的,这轮没有改文案。 +- 以前生成的报告仍按当时保存的正文打开,不会自动变成这一版。 + +| # | 操作 | 应该看到 | 结果 | +| --- | --- | --- | --- | +| 1 | 「我的报告」→ 生成报告,等它完成 → 打开 | 正文是中文。有「中文 / English」。有「上一章 / 下一章」 | | +| 2 | 点「English」,再点「中文」 | 正文和目录换成另一种语言,还停在刚才那一章。英文里看不到汉字 | | +| 3 | 点目录里的某一章 | 正文跳到那一章。页面不卡住 | | +| 4 | 点「导出」 | 只有这一处导出。新报告可以选择中文或 English,下载的是 Markdown。不出现「英文版没有生成成功」 | | +| 5 | 打开一份这次更新之前生成的旧报告 | 能打开,正文仍是当时保存的那一版,没有自动重算 | | +| 6 | 在旧报告地址后手动加 `?lang=en` | 仍显示原来的正文,不报错 | | diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index 6556f010..0cc62913 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -212,6 +212,11 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星 text = text.replace("Natural friends", "天然友星") text = text.replace("Natural enemies", "天然敌星") text = text.replace("Temporary relation", "临时关系") + text = text.replace("Natal / KP Ruling Planets", "本命 / KP 征象星") + text = text.replace("Ruling Planets", "征象星") + text = text.replace("Sub-Planets", "副行星") + text = text.replace("Planetary", "行星") + text = text.replace("Planets", "行星") text = text.replace("Planet", "行星") text = text.replace("Sign", "星座") text = text.replace("Degree in sign", "落座度数") @@ -409,6 +414,9 @@ def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星 "Divisional Charts Numeric Tables": "分盘数值位置表", "Sensitive Points": "敏感点", "Upagraha and Sub-行星 Points": "Upagraha 与副行星点", + "Upagraha and 副行星 Points": "Upagraha 与副行星点", + "行星 Friendship Matrix": "行星友敌关系矩阵", + "行星 Friendship 矩阵源数据": "行星友敌关系矩阵源数据", "六维力量 Components": "六维力量细项", "Functional 自然吉星 / 自然凶星 Layer": "功能性吉凶星层", "Bhava 年龄状态 宫位 强度": "十二宫力量", @@ -1123,6 +1131,18 @@ def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astr "表达受阻,可能表现为说话不顺、迟疑或难以清楚表达": "speech may be obstructed, hesitant or difficult to express clearly", "思绪容易扰动,需留意情绪、压力与判断稳定性": "the mind may be disturbed; emotional pressure and judgment stability need care", "体型偏瘦,体质较敏感": "leaner body type and sensitive constitution", + "水瓶": "Aquarius", + "天蝎": "Scorpio", + "白羊": "Aries", + "摩羯": "Capricorn", + "金牛": "Taurus", + "双子": "Gemini", + "巨蟹": "Cancer", + "狮子": "Leo", + "处女": "Virgo", + "天秤": "Libra", + "射手": "Sagittarius", + "双鱼": "Pisces", "火": "Mars", "水": "Mercury", "木": "Jupiter", @@ -2110,16 +2130,61 @@ _SITE_BHAVA_SCORE = re.compile(r"\(Benefic\):\s*\+2|\(Malefic\):\s*-1\.5|吉星\ _NEECHA_RAJA = re.compile(r"Neecha Bhanga Raja Yoga|落陷取消王瑜伽|坏座位消王") -def _apply_local_report_conventions(markdown: str) -> str: +def _apply_local_report_conventions(markdown: str, *, language: str = "zh") -> str: """Keep this site's report wording inside the upstream appendix.""" - text = _NEECHA_RAJA.sub("落陷取消(Neecha Bhanga)", markdown) + if language == "en": + text = _NEECHA_RAJA.sub( + "Neecha Bhanga (debilitation cancellation, not a Raja Yoga)", + markdown, + ) + text = re.sub(r"\bguaranteed\b", "certain", text, flags=re.IGNORECASE) + text = re.sub(r"\bguarantee\b", "certain outcome", text, flags=re.IGNORECASE) + text = re.sub(r"\bdivorce\b", "relationship strain", text, flags=re.IGNORECASE) + text = re.sub(r"\bmiscarriage\b", "a sensitive health topic", text, flags=re.IGNORECASE) + text = re.sub(r"\binfertility\b", "a sensitive fertility topic", text, flags=re.IGNORECASE) + else: + text = _NEECHA_RAJA.sub("落陷取消(Neecha Bhanga)", markdown) + text = text.replace("疾病诊断", "身体负荷话题") + text = text.replace("一定会", "有可能") + text = re.sub(r"(? 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: + return markdown + match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE) + if not match: + return focus.rstrip() + "\n\n" + markdown + return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :] + + +def _interpretation_tools(): + try: + from pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block + except ModuleNotFoundError: # pragma: no cover - package import compatibility + from scripts.pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block + return attach_full_data_interpretation, opening_focus_block + + def render_full_data_markdown(packet: dict) -> str: """Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body.""" - if _pl9_report_language(packet) == "en": + attach_full_data_interpretation, opening_focus_block = _interpretation_tools() + if isinstance(packet, dict): + attach_full_data_interpretation(packet) + focus = opening_focus_block(packet) + else: + focus = "" + language = _pl9_report_language(packet if isinstance(packet, dict) else {}) + if language == "en": structured_appendix = re.sub( r"^#\s+.*?(?:\n{2,}|\n)", "", @@ -2127,7 +2192,7 @@ def render_full_data_markdown(packet: dict) -> str: count=1, flags=re.DOTALL, ).strip() - english_markdown = render_pl9_parity_markdown(packet) + english_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus) if structured_appendix: english_markdown = ( english_markdown.rstrip() @@ -2140,7 +2205,7 @@ def render_full_data_markdown(packet: dict) -> str: english_markdown, _pl9_english_report_title(packet), ) - return _apply_local_report_conventions(rendered) + return _apply_local_report_conventions(rendered, language="en") zh_structured_markdown = _sanitize_pl9_ai_density_markdown( _render_pl9_ai_density_raw_data_markdown_en(packet), _pl9_public_report_title(packet), @@ -2152,7 +2217,7 @@ def render_full_data_markdown(packet: dict) -> str: count=1, flags=re.DOTALL, ).strip() - zh_markdown = render_pl9_parity_markdown(packet) + zh_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus) if zh_structured_appendix and "## 结构化资料附录" not in zh_markdown: zh_markdown = ( zh_markdown.rstrip() @@ -2162,7 +2227,7 @@ def render_full_data_markdown(packet: dict) -> str: + "\n\n\n" ) rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet)) - return _apply_local_report_conventions(rendered) + return _apply_local_report_conventions(rendered, language="zh") def read_full_data_domain(packet: Mapping, domain: str) -> dict: diff --git a/scripts/pl9_interpretation_packs.py b/scripts/pl9_interpretation_packs.py new file mode 100644 index 00000000..caa7a390 --- /dev/null +++ b/scripts/pl9_interpretation_packs.py @@ -0,0 +1,1228 @@ +"""Template interpretation packs for the full-data report. + +These functions only read fields the engine has already calculated. They do +not compute charts, dashas, or Saturn cycles, and they do not import PyJHora. +Wording stays conditional: a period may bring a topic forward. It does not +assert death, lifespan, diagnosis, divorce, miscarriage, infertility, remarriage, +or a count of relationships. Neecha Bhanga is not labeled a Raja Yoga. +Functional nature is taken from this site's functional benefic/malefic layer. +""" + +from __future__ import annotations + +import re +from datetime import date +from typing import Any + +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") + +PLANET_ZH = { + "Sun": "太阳", + "Moon": "月亮", + "Mars": "火星", + "Mercury": "水星", + "Jupiter": "木星", + "Venus": "金星", + "Saturn": "土星", + "Rahu": "北交点", + "Ketu": "南交点", +} + +ROLE_ZH = { + "functional_benefic": "功能吉星", + "functional_malefic": "功能凶星", + "functional_neutral": "功能中性", +} +ROLE_EN = { + "functional_benefic": "functional benefic", + "functional_malefic": "functional malefic", + "functional_neutral": "functional neutral", +} + +# Themes only. No diagnosis, lifespan, or relationship-count claims. +_THEMES: dict[str, dict[str, tuple[str, str]]] = { + "Sun": { + "zh": ("这段时间可能突出责任、可见度与需要拍板的公共角色。", "父亲、地位或对外代表一类议题更容易被看见。"), + "en": ("This period may bring forward responsibility, visibility, and public roles that need a decision.", "Father, status, or representing a group can become easier to notice."), + }, + "Moon": { + "zh": ("这段时间可能突出情绪节奏、居住与照料关系。", "家庭日常、变动和公众情绪更容易成为主题。"), + "en": ("This period may bring forward emotional pace, home, and care in relationships.", "Household routine, change, and public mood can become easier to notice."), + }, + "Mars": { + "zh": ("这段时间可能突出行动、竞争与把事情做完的压力。", "勇气、技术操练和冲突处理更容易被看见。"), + "en": ("This period may bring forward action, competition, and pressure to finish work.", "Courage, technical practice, and handling conflict can become easier to notice."), + }, + "Mercury": { + "zh": ("这段时间可能突出学习、书写、交易与来回沟通。", "合同、计算和技能交换更容易成为主题。"), + "en": ("This period may bring forward study, writing, trade, and back-and-forth communication.", "Contracts, calculation, and skill exchange can become easier to notice."), + }, + "Jupiter": { + "zh": ("这段时间可能突出判断、教导、信仰与扩张机会。", "顾问、远行和意义感更容易被看见。"), + "en": ("This period may bring forward judgment, teaching, belief, and chances to expand.", "Advising, long travel, and a search for meaning can become easier to notice."), + }, + "Venus": { + "zh": ("这段时间可能突出合作、审美、合意与资源交换。", "艺术、协商和关系里的舒适感更容易成为主题。"), + "en": ("This period may bring forward cooperation, taste, agreement, and exchange of resources.", "Art, negotiation, and comfort inside a relationship can become easier to notice."), + }, + "Saturn": { + "zh": ("这段时间可能突出结构、延迟、责任与长期功课。", "界限、资历和工作负担更容易被看见。"), + "en": ("This period may bring forward structure, delay, duty, and long-term work.", "Boundaries, seniority, and the weight of work can become easier to notice."), + }, + "Rahu": { + "zh": ("这段时间可能突出放大、突破与不按旧习惯走的路径。", "外来机会和强烈欲望更容易成为主题。"), + "en": ("This period may bring forward amplification, breaks with habit, and unfamiliar routes.", "Outside opportunities and strong wanting can become easier to notice."), + }, + "Ketu": { + "zh": ("这段时间可能突出收束、分离与向内整理。", "放下、专精和减少外界噪声更容易被看见。"), + "en": ("This period may bring forward release, separation, and sorting things inwardly.", "Letting go, specialization, and less outer noise can become easier to notice."), + }, +} + +_HOUSE_TOPIC_ZH = { + 1: "自我呈现与人生主轴", + 2: "资源、表达与家庭积累", + 3: "行动、学习与沟通", + 4: "家庭基础与居住", + 5: "创造、判断与教育", + 6: "服务、竞争与日常责任", + 7: "合作与一对一关系", + 8: "共享资源、风险与深层变化", + 9: "信念、远行与高阶学习", + 10: "职业、职责与社会角色", + 11: "收益、社群与长期目标", + 12: "休整、支出与幕后事务", +} +_HOUSE_TOPIC_EN = { + 1: "self-presentation and the main life axis", + 2: "resources, speech, and family accumulation", + 3: "initiative, study, and communication", + 4: "home base and residence", + 5: "creativity, judgment, and education", + 6: "service, competition, and daily duty", + 7: "cooperation and one-to-one relationship", + 8: "shared resources, risk, and deep change", + 9: "belief, long travel, and higher study", + 10: "career, duty, and public role", + 11: "gains, networks, and long-range aims", + 12: "retreat, spending, and behind-the-scenes work", +} + +DECISION_4_DASHAS = ( + "sthira", + "drig", + "shoola", + "navamsha", + "niryaana_shoola", + "lagna_kendradi", + "chara", +) + +# These two producers are named in the catalog and left for a later pass. +DEFERRED_ANNUAL_FIELDS = frozenset({"monthly_chart_pack", "annual_chart_series_pack"}) + +# Upstream annual chapters. A missing producer is listed, not calculated here. +ANNUAL_CHAPTERS = ( + ("Mudda Dasha", "mudda_dasha"), + ("Patyayini Dasha", "patyayini_dasha"), + ("Patyayini ending dates", "patyayini_dasha"), + ("Sahams", "sahams"), + ("Raman Sahams", "sahams"), + ("Tajika yogas", "tajika_yogas"), + ("Monthly mudda windows", "monthly_windows"), + ("Solar return", "solar_return"), + ("Monthly return charts", "monthly_chart_pack"), + ("Eight-year overview", "annual_chart_series_pack"), + ("Lifetime annual charts", "lifetime_annual_chart_pages"), + ("Tripataki", "tripataki_chakra"), + ("Annual special points", "annual_special_points"), + ("Current-year result notes", "pl9_current_year_results"), +) + + +def _lang(packet: dict | None) -> str: + language = str((packet or {}).get("report_language") or "").strip().lower() + return language if language in {"zh", "en"} else "zh" + + +def _dict(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list: + return value if isinstance(value, list) else [] + + +def _planet(name: Any, language: str) -> str: + text = str(name or "").strip() + if not text: + return "未列出" if language == "zh" else "not listed" + if language == "zh": + return PLANET_ZH.get(text, text) + return text + + +def _role(raw: Any, language: str) -> str: + key = str(raw or "").strip() + table = ROLE_ZH if language == "zh" else ROLE_EN + if key in table: + return table[key] + return "未单独列出" if language == "zh" else "not listed separately" + + +def _dignity(value: Any, language: str) -> str: + text = str(value or "").strip() + if not text: + return "未列出" if language == "zh" else "not listed" + match = re.fullmatch(r"([^()()]+?)\s*[((]([A-Za-z][A-Za-z \-]*)[))]", text) + if not match: + if language == "en" and re.search(r"[\u4e00-\u9fff]", text): + return "not listed separately" + return text + return match.group(1).strip() if language == "zh" else match.group(2).strip() + + +def _date_text(value: Any) -> str: + text = str(value or "").strip() + if "T" in text and len(text) >= 10: + return text[:10] + return text[:10] if len(text) >= 10 and text[4:5] == "-" else text + + +def _worksheets(packet: dict) -> dict: + return _dict(packet.get("worksheets")) + + +def _timing(packet: dict) -> dict: + return _dict(_worksheets(packet).get("timing_and_predictive_systems")) + + +def _advanced(packet: dict) -> dict: + return _dict(_worksheets(packet).get("advanced_systems")) + + +def _planets(packet: dict) -> dict: + d1 = _dict(_worksheets(packet).get("d1_rasi_bhava")) + planets = _dict(d1.get("planets")) + if planets: + return planets + return _dict(_dict(packet.get("core_chart")).get("planets")) + + +def _houses(packet: dict) -> dict: + d1 = _dict(_worksheets(packet).get("d1_rasi_bhava")) + houses = _dict(d1.get("houses")) + if houses: + return houses + return _dict(_dict(packet.get("core_chart")).get("houses")) + + +def _house_row(houses: dict, number: int) -> dict: + return _dict(houses.get(f"house_{number}") or houses.get(str(number)) or houses.get(number)) + + +def _functional_classification(packet: dict) -> dict[str, str]: + layer = _dict(_dict(_worksheets(packet).get("strengths_and_scores")).get("functional_benefic_malefic")) + if not layer: + layer = _dict(_advanced(packet).get("functional_benefic_malefic")) + classified: dict[str, str] = {} + for label, key in ( + ("functional_benefic", "functional_benefics"), + ("functional_malefic", "functional_malefics"), + ("functional_neutral", "functional_neutrals"), + ): + for planet in _list(layer.get(key)): + classified[str(planet)] = label + return classified + + +def _owned_houses(packet: dict, houses: dict) -> dict[str, list[int]]: + layer = _dict(_dict(_worksheets(packet).get("strengths_and_scores")).get("functional_benefic_malefic")) + raw = layer.get("owned_houses") + owned: dict[str, list[int]] = {} + if isinstance(raw, dict): + for planet, houses_owned in raw.items(): + if isinstance(houses_owned, list): + owned[str(planet)] = [int(item) for item in houses_owned if isinstance(item, int)] + if owned: + return owned + for number in range(1, 13): + lord = _house_row(houses, number).get("lord") + if lord: + owned.setdefault(str(lord), []).append(number) + return owned + + +def _current_lords(packet: dict) -> dict[str, str]: + dasha = _dict(_timing(packet).get("dasha")) + current = _dict(dasha.get("current_dasha")) + md = str(current.get("lord") or "") + ad = "" + pd = "" + for row in _list(current.get("antardasha_timeline")): + if isinstance(row, dict) and row.get("is_current"): + ad = str(row.get("lord") or "") + for child in _list(row.get("pratyantar_dasha_timeline")): + if isinstance(child, dict) and child.get("is_current"): + pd = str(child.get("lord") or "") + break + break + if not md: + for period in _list(dasha.get("timeline")): + if isinstance(period, dict) and period.get("is_current"): + md = str(period.get("lord") or "") + break + return {"md": md, "ad": ad, "pd": pd} + + +def _lord_facts(packet: dict) -> dict[str, dict[str, Any]]: + planets = _planets(packet) + houses = _houses(packet) + owned = _owned_houses(packet, houses) + facts: dict[str, dict[str, Any]] = {} + for planet, row in planets.items(): + if not isinstance(row, dict): + continue + item = { + "house": row.get("house"), + "ownership": owned.get(str(planet), []), + "dignity": row.get("status") or row.get("dignity"), + "nakshatra": row.get("nakshatra"), + "pada": row.get("nakshatra_pada") or row.get("pada"), + "sign": row.get("sign"), + } + facts[str(planet)] = item + return facts + + +def _themes(lord: str, language: str) -> list[str]: + bank = _THEMES.get(lord) or {} + pair = bank.get(language) or bank.get("en") or () + return list(pair) + + +def _native_vimshottari_periods(packet: dict) -> list[dict[str, Any]]: + dasha = _dict(_timing(packet).get("dasha")) + timeline = _list(dasha.get("timeline")) + periods = [] + for period in timeline: + if not isinstance(period, dict) or not period.get("lord"): + continue + antardashas = [] + for ad in _list(period.get("antardasha_timeline")): + if not isinstance(ad, dict): + continue + antardashas.append({ + **ad, + "start": _date_text(ad.get("start") or ad.get("start_date")), + "end": _date_text(ad.get("end") or ad.get("end_date")), + "pratyantar_dasha_timeline": [ + { + **child, + "start": _date_text(child.get("start") or child.get("start_date")), + "end": _date_text(child.get("end") or child.get("end_date")), + } + for child in _list(ad.get("pratyantar_dasha_timeline")) + if isinstance(child, dict) + ], + }) + periods.append({ + "lord": period.get("lord"), + "start": _date_text(period.get("start") or period.get("start_date")), + "end": _date_text(period.get("end") or period.get("end_date")), + "status": "verified", + "raw_period": { + "is_current": bool(period.get("is_current")), + "antardasha_timeline": antardashas, + }, + }) + return periods + + +def build_dasha_master_view(packet: dict) -> dict[str, Any]: + """Reshape the native Vimshottari timeline. No new periods are calculated.""" + + language = _lang(packet) + classification = _functional_classification(packet) + facts = _lord_facts(packet) + periods = _native_vimshottari_periods(packet) + items = [] + for period in periods: + lord = str(period.get("lord") or "") + if any(row.get("lord") == lord for row in items): + continue + row_facts = facts.get(lord, {}) + blocked = [key for key in ("house", "ownership", "dignity", "nakshatra", "pada") if row_facts.get(key) in (None, "", [])] + items.append({ + "kind": "planet_lord_trigger", + "family": "vimshottari", + "lord": lord, + "facts": row_facts, + "functional_classification": classification.get(lord, "blocked"), + "blocked_fields": blocked, + "status": "partial" if blocked else "verified", + "evidence_label": "single_system_inference" if not blocked else "parameter_sensitive", + }) + return { + "schema": "dasha_master_report_pack_v1", + "audit": {"status": "partial_verified" if items else "blocked"}, + "families": {"vimshottari": {"periods": periods, "execution_status": "partial_verified" if periods else "blocked"}}, + "interpretation_triggers": { + "status": "partial" if items else "blocked", + "planet_lord_items": items, + "sign_items": [], + }, + "language": language, + } + + +def build_dasha_interpretation_pack(dasha_master_pack: dict[str, Any], profile: dict[str, Any] | None = None) -> dict[str, Any]: + """Turn already-built Vimshottari facts into report pages.""" + + language = str((dasha_master_pack or {}).get("language") or (profile or {}).get("language") or "zh") + triggers = _dict(dasha_master_pack.get("interpretation_triggers")) + items = [row for row in _list(triggers.get("planet_lord_items")) if isinstance(row, dict)] + source_status = str(_dict(dasha_master_pack.get("audit")).get("status") or triggers.get("status") or "blocked") + pages = _dasha_pages(dasha_master_pack, items, language) + status = "partial_verified" if pages else "blocked" + return { + "schema": "pl9.dasha_interpretation_pack.v1", + "status": status, + "language": language, + "page_series": pages, + "domain_paragraphs": _domain_paragraphs(items, language), + "summary": {"page_series_count": len(pages), "narrative_item_count": len(items)}, + "audit": {"status": status, "source_dasha_master_status": source_status}, + } + + +def _dasha_pages(master: dict, items: list[dict], language: str) -> list[dict[str, Any]]: + periods = _list(_dict(_dict(master.get("families")).get("vimshottari")).get("periods")) + by_lord = {str(row.get("lord")): row for row in items if row.get("lord")} + current_index = next((idx for idx, period in enumerate(periods) if _dict(period.get("raw_period")).get("is_current")), 0) + ordered = list(periods[current_index:]) + list(periods[:current_index]) + pages = [] + for period in ordered: + if not isinstance(period, dict) or not period.get("lord"): + continue + lord = str(period["lord"]) + trigger = by_lord.get(lord, {}) + facts = _dict(trigger.get("facts") or trigger.get("trigger_facts")) + raw = _dict(period.get("raw_period")) + antardashas = _list(raw.get("antardasha_timeline")) + if raw.get("is_current"): + current_ad_index = next((idx for idx, row in enumerate(antardashas) if isinstance(row, dict) and row.get("is_current")), 0) + report_ads = antardashas[current_ad_index:] + else: + report_ads = antardashas + start = period.get("start") or "?" + end = period.get("end") or "?" + title = ( + f"{_planet(lord, language)} 主运(MD):{start} 至 {end}" + if language == "zh" + else f"{lord} Maha Dasha (MD): {start} to {end}" + ) + current_ad = next((row for row in antardashas if isinstance(row, dict) and row.get("is_current")), None) + if language == "zh": + general = f"{_planet(lord, language)} 主运期间,这段时间可能突出该星的自然主题。下面只按已算好的落宫、守护宫、尊贵、星宿和功能吉凶来读,不是确定会发生的事。" + natal = _natal_zh(lord, facts, trigger) + current_text = ( + f"当前子运(AD)是 {_planet(_dict(current_ad).get('lord'), language)},{current_ad.get('start') or '?'} 至 {current_ad.get('end') or '?'}。它只细化当前节奏。" + if isinstance(current_ad, dict) + else "这一主运不是当前阶段,子运保留在时间表里。" + ) + else: + general = f"During the Maha Dasha of {lord}, this period may bring the planet's natural topics forward. The reading uses only the calculated house, ownership, dignity, nakshatra, and functional nature. It is not a certain event." + natal = _natal_en(lord, facts, trigger) + current_text = ( + f"The current Antar Dasha (AD) is {current_ad.get('lord')}, {current_ad.get('start') or '?'} to {current_ad.get('end') or '?'} . It only refines the current pace." + if isinstance(current_ad, dict) + else "This Maha Dasha is not the current period. Sub-periods stay in the timeline." + ) + flow_blocks = [{ + "kind": "mahadasha", + "title": title, + "heading": title, + "sections": [("General", line) for line in (general, *_themes(lord, language))], + }] + subperiods = [] + for ad in report_ads: + if not isinstance(ad, dict) or not ad.get("lord"): + continue + ad_lord = str(ad["lord"]) + ad_facts = _dict(by_lord.get(ad_lord, {}).get("facts") or by_lord.get(ad_lord, {}).get("trigger_facts")) + paragraph = _sub_paragraph(lord, ad_lord, ad, ad_facts, language, level="AD") + pratyantars = [] + for pd in _list(ad.get("pratyantar_dasha_timeline")): + if not isinstance(pd, dict) or not pd.get("lord"): + continue + pd_lord = str(pd["lord"]) + pd_facts = _dict(by_lord.get(pd_lord, {}).get("facts") or by_lord.get(pd_lord, {}).get("trigger_facts")) + pd_paragraph = _sub_paragraph(ad_lord, pd_lord, pd, pd_facts, language, level="PD") + pratyantars.append({"lord": pd_lord, "start": pd.get("start"), "end": pd.get("end"), "paragraph": pd_paragraph}) + flow_blocks.append({ + "kind": "pratyantar", + "title": f"{lord}-{ad_lord}-{pd_lord}", + "heading": f"{_planet(pd_lord, language)} PD" if language == "zh" else f"Pratyantar Dasha of {pd_lord}", + "sections": [("Pratyantar", pd_paragraph), *[( "Theme", line) for line in _themes(pd_lord, language)[:1]]], + }) + subperiods.append({"lord": ad_lord, "start": ad.get("start"), "end": ad.get("end"), "paragraph": paragraph, "pratyantar_periods": pratyantars}) + flow_blocks.append({ + "kind": "antardasha", + "title": f"{lord}-{ad_lord}", + "heading": f"{_planet(ad_lord, language)} AD" if language == "zh" else f"Antar Dasha of {ad_lord}", + "sections": [("Antar", paragraph), *[("Theme", line) for line in _themes(ad_lord, language)[:1]]], + }) + pages.append({ + "title": title, + "maha_lord": lord, + "start": start, + "end": end, + "subperiods": subperiods, + "flow_blocks": flow_blocks, + "sections": { + "general": general, + "natal_context": natal, + "domain_context": _domain_line(lord, facts, language), + "current_subperiod": current_text, + }, + }) + return pages + + +def _natal_zh(lord: str, facts: dict, trigger: dict) -> str: + house = facts.get("house") + owned = facts.get("ownership") if isinstance(facts.get("ownership"), list) else [] + bits = [f"{_planet(lord, 'zh')}在本命盘位于第{house}宫" if isinstance(house, int) else f"{_planet(lord, 'zh')}的落宫未列出"] + if owned: + bits.append("守护第" + "、".join(str(item) for item in owned) + "宫") + bits.append(f"尊贵为{_dignity(facts.get('dignity'), 'zh')}") + bits.append(f"星宿为{facts.get('nakshatra') or '未列出'}") + bits.append(f"功能吉凶为{_role(trigger.get('functional_classification'), 'zh')}") + return "盘内依据:" + ";".join(bits) + "。功能吉凶用本仓功能吉凶层,不另做一套判断。" + + +def _natal_en(lord: str, facts: dict, trigger: dict) -> str: + house = facts.get("house") if isinstance(facts.get("house"), int) else "not listed" + owned = facts.get("ownership") if isinstance(facts.get("ownership"), list) else [] + owned_text = ", ".join(str(item) for item in owned) if owned else "not listed" + nakshatra = facts.get("nakshatra") or "not listed" + if re.search(r"[\u4e00-\u9fff]", str(nakshatra)): + nakshatra = "not listed separately" + return ( + f"Natal basis: {lord} is in house {house}, owns houses {owned_text}, " + f"dignity {_dignity(facts.get('dignity'), 'en')}, nakshatra {nakshatra}, " + f"functional nature {_role(trigger.get('functional_classification'), 'en')}. " + "Functional nature comes from this site's functional benefic/malefic layer." + ) + + +def _domain_line(lord: str, facts: dict, language: str) -> str: + house = facts.get("house") + if language == "zh": + if not isinstance(house, int): + return f"领域展开:{_planet(lord, language)}的宫位未列出,这里只保留主运索引。" + return f"领域展开:{_planet(lord, language)}位于第{house}宫。事业、财富、关系与日常责任要再看对应宫主和分盘,这段时间可能突出这些主题。" + if not isinstance(house, int): + return f"Domain note: house placement for {lord} is not listed, so this page keeps the period index only." + return f"Domain note: {lord} is in house {house}. Career, resources, relationship, and daily duty still need their house lords and divisional charts. This period may bring those topics forward." + + +def _sub_paragraph(parent: str, lord: str, row: dict, facts: dict, language: str, *, level: str) -> str: + house = facts.get("house") if isinstance(facts.get("house"), int) else None + owned = facts.get("ownership") if isinstance(facts.get("ownership"), list) else [] + start = row.get("start") or "?" + end = row.get("end") or "?" + if language == "zh": + owned_text = "、".join(str(item) for item in owned) if owned else "未列出" + house_text = str(house) if house is not None else "未列出" + return ( + f"{_planet(parent, language)} / {_planet(lord, language)}({level}):{start} 至 {end}。" + f"这段时间可能突出{_planet(lord, language)}的第{house_text}宫、守护第{owned_text}宫、" + f"尊贵{_dignity(facts.get('dignity'), language)}与星宿{facts.get('nakshatra') or '未列出'}。" + "这是阶段线索,不是确定会发生的事。" + ) + owned_text = ", ".join(str(item) for item in owned) if owned else "not listed" + house_text = str(house) if house is not None else "not listed" + nakshatra = facts.get("nakshatra") or "not listed" + if re.search(r"[\u4e00-\u9fff]", str(nakshatra)): + nakshatra = "not listed separately" + return ( + f"{parent} / {lord} ({level}): {start} to {end}. " + f"This period may bring forward {lord}'s house {house_text}, owned houses {owned_text}, " + f"dignity {_dignity(facts.get('dignity'), language)}, and nakshatra {nakshatra}. " + "This is a period clue, not a certain event." + ) + + +def _domain_paragraphs(items: list[dict], language: str) -> list[dict[str, Any]]: + groups = { + "career": {1, 2, 6, 10, 11}, + "wealth": {2, 5, 9, 11}, + "relationship": {1, 5, 7, 11}, + "health": {1, 6, 8, 12}, + } + titles = { + "zh": {"career": "事业", "wealth": "财富", "relationship": "关系", "health": "日常状态"}, + "en": {"career": "Career", "wealth": "Resources", "relationship": "Relationship", "health": "Daily condition"}, + } + rows = [] + for domain, houses in groups.items(): + matched = [] + for item in items: + facts = _dict(item.get("facts") or item.get("trigger_facts")) + found = set() + if isinstance(facts.get("house"), int): + found.add(facts["house"]) + if isinstance(facts.get("ownership"), list): + found.update(value for value in facts["ownership"] if isinstance(value, int)) + if found & houses: + matched.append(str(item.get("lord"))) + names = ", ".join(_planet(name, language) for name in matched[:5]) or ("未列出" if language == "zh" else "not listed") + if language == "zh": + paragraph = f"{titles['zh'][domain]}:这段时间可能突出 {names} 所守护的宫位主题,需与分盘和年度层一起看,不是确定会发生的事。" + else: + paragraph = f"{titles['en'][domain]}: this period may bring forward topics of {names}. Read them with the divisional charts and the annual layer. This is not a certain event." + rows.append({"domain": domain, "paragraph": paragraph, "trigger_count": len(matched)}) + return rows + + +def build_vimshottari_dasha_report_pack( + native_payload: dict[str, Any], + pl9_payload: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + tolerance_days: int = 1, +) -> dict[str, Any]: + """Compare native and supplied PL9 periods by period_key. It does not calculate dashas.""" + + native_periods = [row for row in _list((native_payload or {}).get("periods")) if isinstance(row, dict)] + pl9_periods = [row for row in _list((pl9_payload or {}).get("periods")) if isinstance(row, dict)] + + def _key(row: dict) -> str: + if row.get("period_key"): + return str(row["period_key"]) + return "|".join(str(row.get(part) or "") for part in ("lord", "start", "end")) + + native_by_key = {_key(row): row for row in native_periods if _key(row).strip("|")} + pl9_by_key = {_key(row): row for row in pl9_periods if _key(row).strip("|")} + rows = [] + matched = mismatched = blocked = 0 + for period_key in sorted(set(native_by_key) | set(pl9_by_key)): + native_period = native_by_key.get(period_key) + pl9_period = pl9_by_key.get(period_key) + start_delta = end_delta = None + if native_period is None or pl9_period is None: + status = "blocked" + blocked += 1 + else: + start_delta = _delta_days(native_period.get("start"), pl9_period.get("start")) + end_delta = _delta_days(native_period.get("end"), pl9_period.get("end")) + deltas = [item for item in (start_delta, end_delta) if item is not None] + if len(deltas) != 2: + status = "blocked" + blocked += 1 + elif max(deltas) <= tolerance_days: + status = "verified" + matched += 1 + else: + status = "parameter_sensitive" + mismatched += 1 + rows.append({ + "period_key": period_key, + "start_delta_days": start_delta, + "end_delta_days": end_delta, + "status": status, + }) + if not pl9_periods: + audit_status = "blocked" + reason = "no_external_period_list" + elif blocked: + audit_status = "blocked" + reason = "period_missing" + elif mismatched: + audit_status = "parameter_sensitive" + reason = "period_delta" + elif matched: + audit_status = "verified" + reason = "within_tolerance" + else: + audit_status = "blocked" + reason = "no_periods" + return { + "schema": "pl9.vimshottari_dasha_report_pack.v1", + "status": audit_status, + "tolerance_days": tolerance_days, + "rows": rows, + "summary": {"matched": matched, "mismatched": mismatched, "blocked": blocked}, + "audit": {"status": audit_status, "reason": reason, "profile": profile or {}}, + } + + +def _delta_days(left: Any, right: Any) -> int | None: + if not left or not right: + return None + try: + return abs((date.fromisoformat(str(left)[:10]) - date.fromisoformat(str(right)[:10])).days) + except ValueError: + return None + + +def build_graha_interpretation_pack( + chart_planets: dict[str, Any], + owned_houses: dict[str, Any] | None = None, + functional_classification: dict[str, str] | None = None, + active_dasha_lords: set[str] | None = None, + remedy_snapshot: dict[str, Any] | None = None, + strength_snapshot: dict[str, Any] | None = None, + *, + language: str = "zh", +) -> dict[str, Any]: + """Planet pages from calculated placement, dignity, and functional nature.""" + + owned_houses = owned_houses or {} + functional_classification = functional_classification or {} + active = active_dasha_lords or set() + strength_snapshot = strength_snapshot or {} + pages = [] + for planet in PLANETS: + row = chart_planets.get(planet) if isinstance(chart_planets.get(planet), dict) else {} + if not row: + continue + house = row.get("house") + sign = row.get("sign") + owned = owned_houses.get(planet) if isinstance(owned_houses.get(planet), list) else [] + role = _role(functional_classification.get(planet), language) + dignity = _dignity(row.get("status") or row.get("dignity"), language) + nakshatra = row.get("nakshatra") or ("未列出" if language == "zh" else "not listed") + if language == "en" and re.search(r"[\u4e00-\u9fff]", str(nakshatra)): + nakshatra = "not listed separately" + if language == "en" and re.search(r"[\u4e00-\u9fff]", str(sign or "")): + sign = "not listed separately" + strength = _dict(_dict(strength_snapshot.get("planets")).get(planet)) + total = strength.get("total") or strength.get("total_shadbala") + active_text = planet in active + if language == "zh": + sections = { + "placement": f"{_planet(planet, language)}落在{sign or '未列出'}第{house if isinstance(house, int) else '未列出'}宫,尊贵为{dignity}。", + "sign_and_dispositor": f"守护宫为{'、'.join(str(item) for item in owned) or '未列出'}。这段时间可能突出这些宫的主题。", + "nakshatra": f"星宿为{nakshatra}。星宿主只作为已算好的修饰,不单独下结论。", + "functional_role": f"功能吉凶为{role}。这一层用本仓功能吉凶 v2,与自然吉凶分开读。", + "strength_and_context": f"六维力量合计为{total if total is not None else '未列出'}。力量只说明表现条件,不是确定会发生的事。", + "activation": ( + f"{_planet(planet, language)}在当前 MD、AD 或 PD 中,这段时间可能突出它守护的主题。" + if active_text + else f"{_planet(planet, language)}不在当前三级大运里,先作为本命背景。" + ), + } + else: + sections = { + "placement": f"{planet} is in {sign or 'not listed'}, house {house if isinstance(house, int) else 'not listed'}, dignity {dignity}.", + "sign_and_dispositor": f"Owned houses: {', '.join(str(item) for item in owned) or 'not listed'}. This period may bring those house topics forward.", + "nakshatra": f"Nakshatra: {nakshatra}. The star lord only qualifies the calculated placement.", + "functional_role": f"Functional nature: {role}. This uses the site's functional benefic/malefic layer, separate from natural benefic or malefic.", + "strength_and_context": f"Shadbala total: {total if total is not None else 'not listed'}. Strength describes condition, not a certain event.", + "activation": ( + f"{planet} is inside the current MD, AD, or PD, so this period may bring its topics forward." + if active_text + else f"{planet} is not inside the current three Dasha levels, so it stays natal background." + ), + } + pages.append({"planet": planet if language == "en" else _planet(planet, language), "sections": sections}) + return { + "schema": "pl9.graha_interpretation_pack.v1", + "status": "partial_verified" if pages else "blocked", + "planet_pages": pages, + "audit": {"status": "partial_verified" if pages else "blocked", "remedy_used": bool(remedy_snapshot)}, + } + + +def build_yoga_interpretation_pack( + yoga_snapshot: dict[str, Any] | None, + yogas_doshas_snapshot: dict[str, Any] | None = None, + active_dasha_lords: set[str] | None = None, + *, + language: str = "zh", +) -> dict[str, Any]: + """Pages for yogas the detector already returned.""" + + yoga_snapshot = yoga_snapshot or {} + yogas_doshas_snapshot = yogas_doshas_snapshot or {} + active = active_dasha_lords or set() + groups = ( + yoga_snapshot.get("detected_yogas"), + yoga_snapshot.get("yogas"), + _dict(yogas_doshas_snapshot.get("raj_yogas")).get("yogas"), + _dict(yogas_doshas_snapshot.get("dhana_yogas")).get("yogas"), + _dict(yogas_doshas_snapshot.get("pancha_mahapurusha")).get("yogas"), + _dict(yogas_doshas_snapshot.get("nicha_bhanga_raj")).get("yogas"), + ) + pages = [] + seen: set[str] = set() + for rows in groups: + for raw in _list(rows): + if not isinstance(raw, dict): + continue + name = _yoga_name(raw, language) + if name in seen: + continue + seen.add(name) + combination = str(raw.get("combination_en") or raw.get("combination") or raw.get("reason") or "") + if language == "en": + combination = str(raw.get("combination_en") or raw.get("name") or combination) + if re.search(r"[\u4e00-\u9fff]", combination): + combination = "combination text is not listed separately" + planets = [str(item) for item in _list(raw.get("planets"))] + overlap = sorted(set(planets) & set(active)) + if language == "zh": + sections = { + "detected": f"本案检出:{name}。组合:{combination or '未列出'}。", + "theme": "主题层只保留传统候选。落陷取消不是王者格。这段时间可能突出相关行星守护的主题,不是确定会发生的事。", + "result": "结果只作为条件说明,不把单一瑜伽写成职位、收入或关系的保证。", + "activation": ( + f"当前大运重叠:{'、'.join(_planet(item, language) for item in overlap)}。" + if overlap + else "当前三级大运没有与这条组合的行星直接重叠。" + ), + } + else: + sections = { + "detected": f"Detected: {name}. Combination: {combination or 'not listed'}.", + "theme": "The theme stays a traditional candidate. Neecha Bhanga is not a Raja Yoga. This period may bring the involved planets' topics forward. It is not a certain event.", + "result": "The result is a condition note. One yoga is not a certain outcome for office, income, or relationship.", + "activation": ( + f"Current Dasha overlap: {', '.join(overlap)}." + if overlap + else "The current three Dasha levels do not directly overlap the planets in this combination." + ), + } + pages.append({"name": name, "sections": sections}) + return {"schema": "pl9.yoga_interpretation_pack.v1", "status": "partial_verified" if pages else "blocked", "pages": pages} + + +def _yoga_name(raw: dict, language: str) -> str: + blob = " ".join(str(raw.get(key) or "") for key in ("name", "name_cn", "type", "category", "subtype")).lower() + if "neecha" in blob or "nicha" in blob or "落陷" in blob: + if language == "zh": + return "落陷取消(Neecha Bhanga,不是王者格)" + return "Neecha Bhanga (debilitation cancellation, not a Raja Yoga)" + if language == "en": + name = str(raw.get("name") or raw.get("type") or "Yoga") + if re.search(r"[\u4e00-\u9fff]", name): + return "Yoga (name not listed separately)" + return name + return str(raw.get("name_cn") or raw.get("name") or raw.get("type") or "瑜伽") + + +def build_bhavesh_interpretation_pack( + houses: dict[str, Any] | None, + chart_planets: dict[str, Any] | None, + functional_classification: dict[str, str] | None = None, + active_dasha_lords: set[str] | None = None, + *, + language: str = "zh", +) -> dict[str, Any]: + """One page per house lord, using calculated placement and functional nature.""" + + houses = houses or {} + chart_planets = chart_planets or {} + functional_classification = functional_classification or {} + active = active_dasha_lords or set() + topics = _HOUSE_TOPIC_ZH if language == "zh" else _HOUSE_TOPIC_EN + pages = [] + for number in range(1, 13): + house = _house_row(houses, number) + lord = house.get("lord") + lord_row = chart_planets.get(lord) if isinstance(lord, str) and isinstance(chart_planets.get(lord), dict) else {} + if not house or not lord or not lord_row: + continue + lord_house = lord_row.get("house") + role = _role(functional_classification.get(str(lord)), language) + dignity = _dignity(lord_row.get("status") or lord_row.get("dignity"), language) + sign = lord_row.get("sign") or ("未列出" if language == "zh" else "not listed") + if language == "en" and re.search(r"[\u4e00-\u9fff]", str(sign)): + sign = "not listed separately" + cusp = house.get("cusp_sign") or house.get("sign") or ("未列出" if language == "zh" else "not listed") + if language == "en" and re.search(r"[\u4e00-\u9fff]", str(cusp)): + cusp = "not listed separately" + if language == "zh": + sections = { + "house_context": f"第{number}宫:{topics[number]}。宫头为{cusp},宫主为{_planet(lord, language)}。", + "lord_placement": f"宫主{_planet(lord, language)}位于{sign}第{lord_house if isinstance(lord_house, int) else '未列出'}宫,尊贵为{dignity},功能吉凶为{role}。", + "result": f"第{number}宫的主题会通过宫主所在的第{lord_house if isinstance(lord_house, int) else '未列出'}宫表现。这段时间可能突出这一层,不是确定会发生的事。", + "context": "同宫与相位仍以本命表为准,这里不另算。", + "activation": ( + f"{_planet(lord, language)}在当前大运里,这段时间可能突出第{number}宫主题。" + if lord in active + else f"{_planet(lord, language)}不在当前三级大运里,第{number}宫以本命背景为主。" + ), + } + else: + sections = { + "house_context": f"House {number}: {topics[number]}. Cusp {cusp}. Lord {lord}.", + "lord_placement": f"Lord {lord} is in {sign}, house {lord_house if isinstance(lord_house, int) else 'not listed'}, dignity {dignity}, functional nature {role}.", + "result": f"House {number} shows through the lord's house {lord_house if isinstance(lord_house, int) else 'not listed'}. This period may bring that forward. It is not a certain event.", + "context": "Conjunctions and aspects stay in the natal tables. This page does not recompute them.", + "activation": ( + f"{lord} is inside the current Dasha, so this period may bring house {number} forward." + if lord in active + else f"{lord} is not inside the current three Dasha levels, so house {number} stays natal background." + ), + } + pages.append({"house": number, "lord": lord, "sections": sections}) + return {"schema": "pl9.bhavesh_interpretation_pack.v1", "status": "partial_verified" if pages else "blocked", "pages": pages} + + +def build_dosha_remedy_pack( + yogas_doshas: dict[str, Any] | None, + chart_planets: dict[str, Any] | None = None, + ascendant: dict[str, Any] | None = None, + transit_snapshot: dict[str, Any] | None = None, + remedy_snapshot: dict[str, Any] | None = None, + ashtakavarga: dict[str, Any] | None = None, + *, + language: str = "zh", +) -> dict[str, Any]: + """Explain dosha rows the detector already returned. Saturn cycles are not recalculated.""" + + yogas_doshas = yogas_doshas or {} + rows = [] + labels = { + "mangal_dosha": ("火星婚姻煞", "Mangala / Kuja Dosha"), + "kaal_sarp_dosha": ("时蛇煞", "Kaal Sarp Dosha"), + "pitra_dosha": ("祖业煞", "Pitra Dosha"), + } + for key, (zh_label, en_label) in labels.items(): + raw = yogas_doshas.get(key) + if not isinstance(raw, dict): + continue + present = raw.get("has_dosha") is True + title = zh_label if language == "zh" else en_label + facts = ", ".join( + f"{field}={raw.get(field)}" + for field in ("mars_house", "mars_sign", "severity", "type", "rahu_house", "ketu_house") + if raw.get(field) not in (None, "", [], {}) + ) or ("未列出" if language == "zh" else "not listed") + if language == "en" and re.search(r"[\u4e00-\u9fff]", facts): + facts = "not listed separately" + if language == "zh": + sections = { + "detected": f"{title}:{'本案检测到条件' if present else '本案未检测到条件'}。", + "consideration": "只核对检测器已经给出的宫位和星座,不把这一条写成关系的确定结果。", + "facts": f"结构字段:{facts}。", + "result": "若条件成立,这段时间可能突出合作里的压力或节奏;若未成立,就不把它当成主导障碍。", + "cancellation": "取消条件以检测器返回的说明为准,这里不新判。", + } + else: + sections = { + "detected": f"{title}: {'condition detected' if present else 'condition not detected'}.", + "consideration": "Only the houses and signs already returned by the detector are repeated. This is not a certain relationship outcome.", + "facts": f"Structured fields: {facts}.", + "result": "When the condition is present, this period may bring forward pressure or pace in cooperation. When it is absent, it is not treated as the leading obstacle.", + "cancellation": "Cancellation notes stay with the detector. This page does not add a new judgment.", + } + rows.append({"id": key, "title": title, "present": present, "sections": sections}) + sade = yogas_doshas.get("sade_sati") if isinstance(yogas_doshas.get("sade_sati"), dict) else {} + phase = sade.get("phase") or sade.get("status") or ("未列出" if language == "zh" else "not listed") + if language == "en" and re.search(r"[\u4e00-\u9fff]", str(phase)): + phase = "not listed separately" + if language == "zh": + saturn_note = f"Sade Sati 使用检测器已给出的状态:{phase}。这里不重算土星周期。" + else: + saturn_note = f"Sade Sati uses the detector status already calculated: {phase}. This page does not recompute the Saturn cycle." + return { + "schema": "pl9.dosha_remedy_pack.v1", + "status": "partial_verified" if rows or sade else "blocked", + "dosha_rows": rows, + "saturn_note": saturn_note, + "supportive_practices": { + "recommendations": _list(_dict(remedy_snapshot).get("recommendations")), + }, + "audit": { + "status": "partial_verified" if rows or sade else "blocked", + "ashtakavarga_present": bool(ashtakavarga), + "transit_present": bool(transit_snapshot), + "ascendant_present": bool(ascendant), + "chart_planets_present": bool(chart_planets), + }, + } + + +def _field_usable(field: Any) -> bool: + if isinstance(field, list): + return bool(field) + if not isinstance(field, dict) or not field: + return False + if field.get("status") in {"blocked", "not_applicable"}: + return False + if field.get("periods") or field.get("rows") or field.get("data") or field.get("dasha_sequence"): + return True + return field.get("status") in {"partial_verified", "verified", "conflict", "parameter_sensitive"} + + +def project_annual_tables(annual: dict) -> None: + """Copy existing annual fields onto the table names the reader already prints.""" + + if not isinstance(annual, dict): + return + normalized = annual.get("normalized_tables") + if not isinstance(normalized, dict): + normalized = {} + annual["normalized_tables"] = normalized + mudda = _dict(annual.get("mudda_dasha")) + if not normalized.get("mudda_rows"): + periods = _list(mudda.get("periods") or _dict(mudda.get("data")).get("periods") or _dict(mudda.get("data")).get("dasha_sequence")) + rows = [] + for period in periods: + if not isinstance(period, dict): + continue + rows.append({ + "balance_profile": period.get("balance_profile") or "native", + "lord": period.get("lord"), + "start": period.get("start") or period.get("start_date"), + "end": period.get("end") or period.get("end_date"), + "boundary_status": mudda.get("status") or "partial_verified", + }) + if rows: + normalized["mudda_rows"] = rows + if not normalized.get("saham_rows"): + normalized["saham_rows"] = _saham_rows(annual.get("sahams")) + if not normalized.get("tajika_yoga_rows"): + normalized["tajika_yoga_rows"] = _tajika_rows(annual.get("tajika_yogas")) + + +def _saham_rows(field: Any) -> list[dict[str, Any]]: + data = field.get("data") if isinstance(field, dict) else field + rows = [] + if isinstance(data, list): + source = data + elif isinstance(data, dict): + nested = data.get("rows") or data.get("sahams") or data.get("items") + source = nested if isinstance(nested, list) else [{"name": key, **value} if isinstance(value, dict) else {"name": key, "value": value} for key, value in data.items() if key not in {"status", "reason", "producer"}] + else: + source = [] + for item in source: + if not isinstance(item, dict): + continue + if item.get("sign") in (None, "") and item.get("degree_in_sign") in (None, "") and item.get("longitude") in (None, ""): + continue + rows.append({ + "name": item.get("name") or item.get("saham"), + "saham": item.get("saham") or item.get("name"), + "sign": item.get("sign"), + "degree_in_sign": item.get("degree_in_sign") or item.get("degree"), + }) + return rows + + +def _tajika_rows(field: Any) -> list[dict[str, Any]]: + data = field.get("data") if isinstance(field, dict) else field + rows = [] + candidates = [] + if isinstance(data, list): + candidates = data + elif isinstance(data, dict): + for key in ("yogas", "rows", "named_yogas", "items", "results"): + if isinstance(data.get(key), list): + candidates = data[key] + break + for item in candidates: + if not isinstance(item, dict): + continue + rows.append({ + "yoga": item.get("yoga") or item.get("name") or item.get("id"), + "name": item.get("name") or item.get("yoga"), + "present": item.get("present") if item.get("present") is not None else item.get("status") or item.get("formed"), + "forming_planets": item.get("forming_planets") or item.get("planets"), + }) + return [row for row in rows if row.get("yoga") or row.get("name")] + + +def annual_coverage(annual: dict | None) -> list[dict[str, str]]: + """Each upstream annual chapter is present or named with the missing field.""" + + annual = annual or {} + rows = [] + seen_missing = set() + for title, field in ANNUAL_CHAPTERS: + usable = _field_usable(annual.get(field)) + if field == "patyayini_dasha": + usable = False + if field == "sahams" and title == "Raman Sahams": + usable = bool(_dict(annual.get("normalized_tables")).get("raman_saham_rows")) + if field == "mudda_dasha": + usable = bool(_dict(annual.get("normalized_tables")).get("mudda_rows") or _field_usable(annual.get("mudda_dasha"))) + if field == "tajika_yogas": + usable = bool(_dict(annual.get("normalized_tables")).get("tajika_yoga_rows") or _field_usable(annual.get("tajika_yogas"))) + if usable: + rows.append({"section": title, "status": "present", "field": field}) + else: + # Patyayini's two chapters share one producer. Count the field once. + status = "deferred" if field in DEFERRED_ANNUAL_FIELDS else "missing_field" + if field == "patyayini_dasha" and field in seen_missing: + status = "missing_field_same_producer" + seen_missing.add(field) + rows.append({"section": title, "status": status, "field": field}) + return rows + + +def section_coverage(packet: dict) -> list[dict[str, str]]: + """Catalog used by the progress note. Decision 4 dashas are out of scope.""" + + rows = [] + for family in DECISION_4_DASHAS: + rows.append({"section": family, "status": "out_of_scope", "field": "decision_4"}) + timing = _timing(packet) + advanced = _advanced(packet) + checks = ( + ("Dasha interpretation", bool(_list(_dict(timing.get("dasha_interpretation_pack")).get("page_series")))), + ("Graha interpretation", bool(_list(_dict(advanced.get("graha_interpretation_pack")).get("planet_pages")))), + ("Bhavesh interpretation", bool(_list(_dict(advanced.get("bhavesh_interpretation_pack")).get("pages")))), + ("Dosha interpretation", bool(_list(_dict(advanced.get("dosha_remedy_pack")).get("dosha_rows")))), + ("Yoga interpretation", bool(_list(_dict(advanced.get("yoga_interpretation_pack")).get("pages")))), + ) + for title, present in checks: + rows.append({"section": title, "status": "present" if present else "missing_field", "field": title}) + rows.extend(annual_coverage(_dict(timing.get("annual_tajika_pack")))) + return rows + + +def missing_field_count(packet: dict) -> int: + fields = { + row["field"] + for row in section_coverage(packet) + if row["status"] == "missing_field" + } + return len(fields) + + +def opening_focus_block(packet: dict) -> str: + """Current MD/AD/PD plus career, wealth, and relationship house lords.""" + + 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: + house = _house_row(houses, number) + lord = str(house.get("lord") or "") + 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}." + ) + + 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"), + "- 这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。", + ] + 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) + + +def _strength_snapshot(packet: dict) -> dict: + strengths = _dict(_worksheets(packet).get("strengths_and_scores")) + shadbala = _dict(strengths.get("shadbala")) + return {"planets": _dict(shadbala.get("planets"))} + + +def attach_full_data_interpretation(packet: dict) -> dict: + """Fill interpretation packs and annual table aliases on a report packet.""" + + if not isinstance(packet, dict): + return packet + language = _lang(packet) + worksheets = packet.setdefault("worksheets", {}) + if not isinstance(worksheets, dict): + return packet + timing = worksheets.setdefault("timing_and_predictive_systems", {}) + advanced = worksheets.setdefault("advanced_systems", {}) + if not isinstance(timing, dict) or not isinstance(advanced, dict): + return packet + houses = _houses(packet) + planets = _planets(packet) + classification = _functional_classification(packet) + owned = _owned_houses(packet, houses) + current = _current_lords(packet) + active = {lord for lord in (current["md"], current["ad"], current["pd"]) if lord} + master = build_dasha_master_view(packet) + timing["dasha_interpretation_pack"] = build_dasha_interpretation_pack(master, {"language": language}) + native_periods = [] + for period in _list(_dict(_dict(master.get("families")).get("vimshottari")).get("periods")): + native_periods.append({ + "period_key": f"{period.get('lord')}|{period.get('start')}|{period.get('end')}", + "lord": period.get("lord"), + "start": period.get("start"), + "end": period.get("end"), + }) + timing["vimshottari_dasha_report_pack"] = build_vimshottari_dasha_report_pack( + {"periods": native_periods, "lord_facts": _lord_facts(packet)}, + {}, + {"language": language}, + ) + advanced["graha_interpretation_pack"] = build_graha_interpretation_pack( + planets, + owned, + classification, + active, + strength_snapshot=_strength_snapshot(packet), + language=language, + ) + advanced["bhavesh_interpretation_pack"] = build_bhavesh_interpretation_pack( + houses, + planets, + classification, + active, + language=language, + ) + yoga = _dict(advanced.get("yoga")) + dosha = _dict(advanced.get("yogas_doshas") or _worksheets(packet).get("yogas_and_doshas")) + advanced["yoga_interpretation_pack"] = build_yoga_interpretation_pack( + yoga, + dosha, + active, + language=language, + ) + advanced["dosha_remedy_pack"] = build_dosha_remedy_pack( + dosha, + planets, + _dict(_dict(_worksheets(packet).get("d1_rasi_bhava")).get("ascendant")), + _dict(timing.get("transit_multi_reference")), + language=language, + ) + annual = timing.get("annual_tajika_pack") + if isinstance(annual, dict): + project_annual_tables(annual) + return packet diff --git a/scripts/pl9_language_terms.py b/scripts/pl9_language_terms.py index 1310c46e..7ab94556 100644 --- a/scripts/pl9_language_terms.py +++ b/scripts/pl9_language_terms.py @@ -297,6 +297,8 @@ def normalize_zh_markdown_terms(markdown: str) -> str: text = _normalize_zh_bhava_factor_text(text) text = text.replace("Bhava 年龄状态 十二宫力量", "Bhava Bala 十二宫力量") text = text.replace("Shadbala / Bhava 年龄状态", "Shadbala / Bhava Bala") + text = text.replace("六维力量 / Bhava 年龄状态", "六维力量 / Bhava Bala") + text = text.replace("行星s", "行星") text = text.replace("星宿 Scheme", "星宿身体对应表") text = _normalize_mula_gloss(_restore_zh_nakshatra_glosses(_collapse_duplicate_zh_parentheses(text))) text = text.replace("### 是 Solar Return", "### 真实太阳返照") @@ -559,6 +561,180 @@ def _normalize_zh_dasha_prose(markdown: str) -> str: return text +# Fragments left after the longer English glossary. Longer keys are applied first. +_EN_RESIDUAL_HAN: tuple[tuple[str, str], ...] = ( + ("没有候选以吉相位照年盘", "no candidate aspects the annual Lagna by a benefic aspect"), + ("按子运起点星座奇偶", "by the odd or even sign where the sub-period starts"), + ("按七级比较所落星座", "compare the occupied signs across seven levels"), + ("两主分落别的星座", "the two lords fall in other signs"), + ("星座或其主所落星座", "the sign, or the sign its lord occupies"), + ("实际位置判断是否", "judge from the actual position whether"), + ("以吉相位照年盘", "aspect the annual Lagna by a benefic aspect"), + ("不单独增加结论的确定性", "does not by itself make a conclusion more certain"), + ("年数数到该", "the year count reaches that sign"), + ("一律顺行", "always direct"), + ("只倒算计都", "only Ketu is counted backward"), + ("未进打分", "not used in the score"), + ("时间轴结论", "timeline conclusion"), + ("主三角色", "primary triangle role"), + ("七轮裁定", "seven-round decision"), + ("书内分歧", "the book itself disagrees"), + ("待替换为", "still to be replaced by"), + ("取为年主", "is taken as the year lord"), + ("五分力量", "five-fold strength"), + ("五类候选", "five candidate groups"), + ("以凶相位照", "aspect by a malefic aspect"), + ("阶段框架", "phase frame"), + ("起始年龄", "start age"), + ("结束年龄", "end age"), + ("六重力量", "six-fold strength"), + ("矩阵源数据", "matrix source"), + ("相位矩阵", "aspect matrix"), + ("灵魂使命", "soul aim"), + ("核心自我", "core self"), + ("人生最高目标", "highest aim"), + ("主要谋士", "main advisor"), + ("权力代理", "delegated authority"), + ("兄弟姐妹", "siblings"), + ("冒险精神", "appetite for risk"), + ("家庭根基", "home base"), + ("情感安全感", "emotional security"), + ("祖先业力", "ancestral pattern"), + ("传统传承", "inherited tradition"), + ("智能成果", "crafted results"), + ("竞争对手", "rivals"), + ("转化力量", "capacity for change"), + ("配偶特质", "spouse traits"), + ("伴侣关系", "partnership"), + ("书例校准", "book-example calibration"), + ("八分法", "eightfold division"), + ("要素数量", "factor count"), + ("要素总数", "factor total"), + ("综合性质", "overall nature"), + ("基本年数", "base years"), + ("并列参考", "side reference"), + ("双主星", "dual lords"), + ("子运方向", "sub-period direction"), + ("身份按同", "identity follows the same"), + ("明显迹象", "a clear indication"), + ("严重程度", "severity"), + ("轴线之间", "between the axis"), + ("颗行星", "planets"), + ("高峰期", "peak phase"), + ("下降期", "setting phase"), + ("最困难", "most difficult"), + ("需结合", "read together with"), + ("灵魂星", "soul planet"), + ("兄弟星", "sibling planet"), + ("母亲星", "mother planet"), + ("父亲星", "father planet"), + ("子女星", "child planet"), + ("障碍星", "obstacle planet"), + ("配偶星", "spouse planet"), + ("旧算法", "previous algorithm"), + ("按原书", "per the book"), + ("起运", "period start"), + ("从主", "from the lord"), + ("级取", "the level selects"), + ("两主", "two lords"), + ("等分", "equal parts"), + ("未采用", "not used"), + ("星座奇偶", "odd or even sign"), + ("的子运", "sub-period"), + ("待评估", "not yet assessed"), + ("进行medium", "underway"), + ("当before", "current "), + ("原始值", "raw value"), + ("主层", "main level"), + ("子层", "sub level"), + ("年数", "years"), + ("主星", "lord"), + ("序号", "index"), + ("起始", "start"), + ("结束", "end"), + ("星座", "sign"), + ("相位", "aspect"), + ("年度", "annual"), + ("细项", "detail"), + ("年返", "solar return"), + ("摘要", "summary"), + ("项目", "item"), + ("数值", "value"), + ("方法", "method"), + ("校验", "check"), + ("辅助", "supporting"), + ("算法", "algorithm"), + ("并列", "listed alongside"), + ("顺序", "order"), + ("修正", "adjustment"), + ("最终", "final"), + ("正文", "body text"), + ("阶段", "phase"), + ("是否", "whether"), + ("只有", "only"), + ("特殊", "special"), + ("方向", "direction"), + ("勇气", "courage"), + ("母亲", "mother"), + ("父亲", "father"), + ("子女", "children"), + ("创造力", "creativity"), + ("学生", "students"), + ("障碍", "obstacles"), + ("质量", "quality"), + ("顺行", "direct"), + ("逆行", "retrograde"), + ("比较", "compare"), + ("另一", "another"), + ("部分", "partial"), + ("进行", "in progress"), + ("占据planet尊贵", "occupied planet dignity"), + ("占据planet数", "occupied planet count"), + ("partial减weak", "partially reduced"), + ("成立条件", "conditions"), + ("不in煞", "not in a malefic house"), + ("迹象", "indication"), + ("相邻", "adjacent"), + ("占据", "occupies"), + ("尊贵", "dignity"), + ("克", "restrains"), + ("其", " its "), + ("减", "reduced"), + ("最", "most "), + ("条", "items"), + ("的", " of "), + ("不", "not "), + ("吉", "benefic"), + ("数", "count"), + ("煞", "malefic"), + ("看", "see"), + ("起", "from"), + ("版", "edition"), + ("级", "level"), + ("主", "lord"), + ("期", "phase"), + ("星", "planet"), + ("度", "degree"), + ("从", "from"), + ("取", "take"), + ("所", "at"), + ("较", "comparatively"), + ("当", "when"), + ("按", "by"), + ("有", "present"), + ("无", "absent"), + ("表", "table"), +) + + +def apply_en_residual_han(markdown: str) -> str: + """Translate Han fragments that survive the longer English glossary.""" + text = markdown + for raw, public in sorted(_EN_RESIDUAL_HAN, key=lambda item: len(item[0]), reverse=True): + text = text.replace(raw, public) + return text + + def normalize_en_markdown_terms(markdown: str) -> str: text = markdown.replace("(", "(").replace(")", ")").replace("、", ", ") text = ( @@ -576,7 +752,7 @@ def normalize_en_markdown_terms(markdown: str) -> str: r"\1 \2", text, ) - return _thicken_en_dasha_density(text) + return apply_en_residual_han(_thicken_en_dasha_density(text)) def _thicken_en_dasha_density(markdown: str) -> str: diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 916f8e98..7891a4be 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -634,7 +634,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: return ';'.join(f"{key}={value}" for key, value in counts.items()) def _public_status_note(value) -> str: - mapping = { + zh_mapping = { 'partial_verified': '已生成', 'parameter_sensitive': '参数敏感', 'blocked': '需补资料', @@ -647,6 +647,20 @@ def render_pl9_parity_markdown(packet: dict) -> str: 'blocked_external_callable': '外部回放待校对', 'superseded_by_annual_tajika_pack.lifetime_annual_chart_pages': '由生命周期图盘页承接', } + en_mapping = { + 'partial_verified': 'listed', + 'parameter_sensitive': 'see the listed parameters', + 'blocked': 'not supplied', + 'verified': 'checked', + 'match': 'matches', + 'mismatch': 'needs review', + 'not_applicable': 'not applicable', + 'pyjhora_behavior_only': 'external behavior note', + 'pyjhora_behavior_only / not_multiengine_parity': 'external behavior note', + 'blocked_external_callable': 'external replay still to review', + 'superseded_by_annual_tajika_pack.lifetime_annual_chart_pages': 'carried by the lifetime annual pages', + } + mapping = en_mapping if _lang == 'en' else zh_mapping text = str(value or '').strip() return mapping.get(text, text or '-') @@ -827,7 +841,11 @@ def render_pl9_parity_markdown(packet: dict) -> str: ) lines.extend([ '', - '星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。', + ( + 'Sign lord, star lord, sub lord and sub-sub lord name the ruler of the planet sign, nakshatra and KP subdivision. Strength ratio compares Shadbala with the required strength. A ratio of 1 or more meets it.' + if _lang == 'en' + else '星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。' + ), '', ]) moon_chart = d1_sheet.get('moon_chart') if isinstance(d1_sheet.get('moon_chart'), dict) else {} @@ -1105,7 +1123,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: graha = advanced.get('graha_interpretation_pack') if isinstance(advanced.get('graha_interpretation_pack'), dict) else {} pages = graha.get('planet_pages') if isinstance(graha.get('planet_pages'), list) else [] if pages: - lines.extend(['## 行星、星宿与宫位解释', '']) + lines.extend(['## Planets, Nakshatras and Houses' if _lang == 'en' else '## 行星、星宿与宫位解释', '']) for page in pages: if not isinstance(page, dict): continue @@ -1124,10 +1142,18 @@ def render_pl9_parity_markdown(packet: dict) -> str: shadbala_planets = _dict(shadbala.get('planets')) if shadbala_planets: lines.extend([ - '### p31-p33 Shadbala / Bhava Bala 摘要', + '### Shadbala / Bhava Bala Summary' if _lang == 'en' else '### p31-p33 Shadbala / Bhava Bala 摘要', '', - f"- Shadbala 方法:{_cell(shadbala.get('method'))}", - f"- 外部边界:{_cell(shadbala.get('external_parity_boundary'))}", + ( + f"- Shadbala method: {_cell(shadbala.get('method'))}" + if _lang == 'en' + else f"- Shadbala 方法:{_cell(shadbala.get('method'))}" + ), + ( + f"- External boundary: {_cell(shadbala.get('external_parity_boundary'))}" + if _lang == 'en' + else f"- 外部边界:{_cell(shadbala.get('external_parity_boundary'))}" + ), '', '| Planet | Sthana | Dig | Kala | Chesta | Naisargika | Drik | Total | Required | BPHS minimum | Site band |', '|--------|--------|-----|------|--------|------------|------|-------|----------|--------------|-----------|', @@ -1202,7 +1228,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: # BUG-1210 (甲13): the formal three components (scripts/bhava_bala.py); # the local +2 / -1.5 house score is never shown. lines.extend([ - '### p32 Bhava Bala 十二宫力量', + '### Bhava Bala of the twelve houses' if _lang == 'en' else '### p32 Bhava Bala 十二宫力量', '', '| House | Sign | Lord | Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa) |', '|-------|------|------|--------------|-----|---------|----------------|--------------|', @@ -1613,9 +1639,13 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.extend([ '### p62-p74 Vimshottari Dasha', '', - f"- Current Dasha:{_token(current.get('lord'))}({_cell(current.get('start'))} - {_cell(current.get('end'))})" if current else '- Current Dasha:blocked', + ( + f"- Current Dasha: {_token(current.get('lord'))} ({_cell(current.get('start'))} - {_cell(current.get('end'))})" + if _lang == 'en' + else f"- Current Dasha:{_token(current.get('lord'))}({_cell(current.get('start'))} - {_cell(current.get('end'))})" + ) if current else ('- Current Dasha: not supplied' if _lang == 'en' else '- Current Dasha:未列出'), '', - '| 序号 | 主运 | 起始 | 结束 | 年数 |', + '| Index | Maha | Start | End | Years |' if _lang == 'en' else '| 序号 | 主运 | 起始 | 结束 | 年数 |', '|------|------|------|------|------|', ]) for index, period in enumerate(vimshottari_timeline, start=1): @@ -1640,11 +1670,11 @@ def render_pl9_parity_markdown(packet: dict) -> str: if isinstance(pd_period, dict): pd_rows.append((md_lord, ad_lord, pd_period)) if ad_rows: - lines.extend(['', '| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |', '|--------------|------|------|------|------|']) + lines.extend(['', '| Vimshottari AD | Maha | Antar | Start | End |' if _lang == 'en' else '| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |', '|--------------|------|------|------|------|']) for index, (md_lord, period) in enumerate(ad_rows, start=1): lines.append(f"| {index} | {_cell(_token(md_lord))} | {_cell(_token(period.get('lord')))} | {_period_start(period)} | {_period_end(period)} |") if pd_rows: - lines.extend(['', '| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |', '|--------------|------|------|--------|------|------|']) + lines.extend(['', '| Vimshottari PD | Maha | Antar | Pratyantar | Start | End |' if _lang == 'en' else '| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |', '|--------------|------|------|--------|------|------|']) for index, (md_lord, ad_lord, period) in enumerate(pd_rows, start=1): lines.append( f"| {index} | {_cell(_token(md_lord))} | {_cell(_token(ad_lord))} | {_cell(_token(period.get('lord')))} | " @@ -1743,7 +1773,16 @@ def render_pl9_parity_markdown(packet: dict) -> str: legacy_line = f'- 算法:{LEGACY_NOTE_ZH}' if legacy_line not in lines: lines.extend(['### Narayana Dasha', '', legacy_line, '']) - rath_lines = [line for line in _rath_lines(narayana_packet.get('rath')) if line] + rath_view = narayana_packet.get('rath') + rath_lines = [line for line in _rath_lines(rath_view) if line] + if _lang == 'en' and isinstance(rath_view, dict): + for row in _list(rath_view.get('mahadasha')): + if not isinstance(row, dict): + continue + zh_basis = row.get('dual_lord_basis') + en_basis = row.get('dual_lord_basis_en') + if zh_basis and en_basis: + rath_lines = [line.replace(str(zh_basis), str(en_basis)) for line in rath_lines] if rath_lines and not any(str(line).startswith('- 并列:') for line in lines): lines.extend(rath_lines) lines.append('') @@ -1932,17 +1971,28 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.append('') annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {} if annual: - lines.extend(['## 年度图盘与 Tajika(PL9 p126-p155 对标)', '']) + lines.extend([ + '## Annual Varshaphala and Tajika' if _lang == 'en' else '## 年度图盘与 Tajika(PL9 p126-p155 对标)', + '', + ]) annual_chart = _dict(annual.get('annual_chart')) annual_chart_data = _dict(annual_chart.get('data')) solar_return = _dict(annual.get('solar_return')) solar_return_data = _dict(solar_return.get('data') or solar_return) if solar_return_data or annual_chart_data: lines.extend([ - '### p126-p127 Varshaphala 基础资料与年度行星表', + '### Varshaphala base data and annual planets' if _lang == 'en' else '### p126-p127 Varshaphala 基础资料与年度行星表', '', - f"- Varshapravesha:{solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}", - f"- 年返上升:{_token(annual_chart_data.get('asc_sign'))}", + ( + f"- Varshapravesha: {solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}" + if _lang == 'en' + else f"- Varshapravesha:{solar_return_data.get('dt_local') or solar_return_data.get('local_time') or solar_return_data.get('date') or '-'}" + ), + ( + f"- Solar-return Lagna: {_token(annual_chart_data.get('asc_sign'))}" + if _lang == 'en' + else f"- 年返上升:{_token(annual_chart_data.get('asc_sign'))}" + ), '', ]) year_lord = _dict(annual.get('year_lord')) @@ -2041,8 +2091,8 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.extend([ '### p132-p135 Mudda / Patyayini 年度 Dasha 表', '', - '| balance profile | 主星 | 开始 | 结束 | 边界口径 |', - '|-----------------|------|------|------|----------|', + '| Balance profile | Lord | Start | End | Boundary |' if _lang == 'en' else '| balance profile | 主星 | 开始 | 结束 | 边界口径 |', + '|-----------------|------|-------|-----|----------|' if _lang == 'en' else '|-----------------|------|------|------|----------|', ]) for row in mudda_rows: if not isinstance(row, dict): @@ -2068,13 +2118,34 @@ def render_pl9_parity_markdown(packet: dict) -> str: f"{_cell(row.get('boundary'))} | {_public_status_note(row.get('boundary_status'))} |" ) lines.append('') + if not patyayini_rows: + lines.extend([ + 'Patyayini is not implemented in this report.' if _lang == 'en' else 'Patyayini 没有实现。', + '', + ]) + monthly_windows = _list(annual.get('monthly_windows')) + if monthly_windows: + lines.extend([ + '### Monthly mudda windows' if _lang == 'en' else '### Mudda 月段窗口', + '', + '| Index | Lord | Months | Start | End |' if _lang == 'en' else '| 序号 | 主星 | 月数 | 开始 | 结束 |', + '|-------|------|--------|-------|-----|' if _lang == 'en' else '|------|------|------|------|------|', + ]) + for row in monthly_windows: + if not isinstance(row, dict): + continue + lines.append( + f"| {_cell(row.get('index'))} | {_cell(_token(row.get('lord')))} | " + f"{_cell(row.get('duration_months') or row.get('months'))} | {_cell(row.get('start'))} | {_cell(row.get('end'))} |" + ) + lines.append('') tajika_rows = _list(normalized.get('tajika_yoga_rows')) or _list(normalized.get('pyjhora_tajika_yoga_rows')) if tajika_rows: lines.extend([ '### p136-p138 Tajika Yoga Presence 对照表', '', - '| Yoga | 结果 | 形成行星 | 相位运动(入相/分离) |', - '|------|------|----------|----------------------|', + '| Yoga | Result | Forming planets | Motion (applying/separating) |' if _lang == 'en' else '| Yoga | 结果 | 形成行星 | 相位运动(入相/分离) |', + '|------|--------|-----------------|--------------------------------|' if _lang == 'en' else '|------|------|----------|----------------------|', ]) for row in tajika_rows[:32]: if not isinstance(row, dict): @@ -2088,10 +2159,10 @@ def render_pl9_parity_markdown(packet: dict) -> str: saham_rows = _list(normalized.get('saham_rows')) if saham_rows: lines.extend([ - '### p130 Sahams 点位表', + '### Sahams' if _lang == 'en' else '### p130 Sahams 点位表', '', - '| Saham | 星座 | 星座内度数 |', - '|-------|------|------------|', + '| Saham | Sign | Degree in sign |' if _lang == 'en' else '| Saham | 星座 | 星座内度数 |', + '|-------|------|----------------|' if _lang == 'en' else '|-------|------|------------|', ]) for row in saham_rows[:48]: if not isinstance(row, dict): @@ -2442,11 +2513,16 @@ def render_pl9_parity_markdown(packet: dict) -> str: detected_yogas = _list(advanced_yoga.get('detected_yogas')) or _list(advanced_yoga.get('yogas')) dosha_pack = _dict(advanced.get('dosha_remedy_pack')) bhavesh_pack = _dict(advanced.get('bhavesh_interpretation_pack')) - if d1_houses or yogas_doshas or detected_yogas or bhavesh_pack or dosha_pack: - lines.extend(['## Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)', '']) + yoga_pack = _dict(advanced.get('yoga_interpretation_pack')) + yoga_pages = _list(yoga_pack.get('pages')) + if d1_houses or yogas_doshas or detected_yogas or bhavesh_pack or dosha_pack or yoga_pages: + lines.extend([ + '## Bhavesh, Dosha and Yoga' if _lang == 'en' else '## Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)', + '', + ]) if d1_houses and d1_planets: lines.extend([ - '### p156-p162 宫主(Bhavesh)落宫解释', + '### House lords (Bhavesh)' if _lang == 'en' else '### p156-p162 宫主(Bhavesh)落宫解释', '', '| House | Sign | Lord | Lord placement | Lord dignity | Dasha activation |', '|-------|------|------|----------------|--------------|------------------|', @@ -2473,14 +2549,18 @@ def render_pl9_parity_markdown(packet: dict) -> str: bhavesh_pages = _list(bhavesh_pack.get('pages')) if bhavesh_pages: lines.extend([ - '### Bhavesh 宫主逐宫解释', + '### House-lord interpretation' if _lang == 'en' else '### Bhavesh 宫主逐宫解释', '', ]) for row in bhavesh_pages: if not isinstance(row, dict): continue sections = _dict(row.get('sections')) - title = f"第{row.get('house')}宫主:{_token(row.get('lord'))}" + title = ( + f"House {row.get('house')} lord: {_token(row.get('lord'))}" + if _lang == 'en' + else f"第{row.get('house')}宫主:{_token(row.get('lord'))}" + ) lines.extend([f'#### {title}', '']) for key in ('house_context', 'lord_placement', 'result', 'context', 'activation'): value = str(sections.get(key) or '').strip() @@ -2516,7 +2596,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: dosha_rows = _list(dosha_pack.get('dosha_rows')) if dosha_rows: lines.extend([ - '### Dosha 逐项事实与解释', + '### Dosha facts and interpretation' if _lang == 'en' else '### Dosha 逐项事实与解释', '', ]) for row in dosha_rows: @@ -2535,7 +2615,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: recommendations = _list(practices.get('recommendations')) if recommendations: lines.extend([ - '### 传统辅助建议', + '### Traditional supportive notes' if _lang == 'en' else '### 传统辅助建议', '', '| 对象 | 建议 | 宝石/次数 |', '|------|------|-----------|', @@ -2587,13 +2667,39 @@ def render_pl9_parity_markdown(packet: dict) -> str: '|------|----------|----------|-------------|-----------------|', ]) for row in group_rows: - effects = row.get('effects') if isinstance(row.get('effects'), list) else [] + if _lang == 'en': + effects = row.get('effects_en') if isinstance(row.get('effects_en'), list) else [] + if not effects and isinstance(row.get('effects'), list): + effects = row.get('effects') + yoga_name = row.get('name') or row.get('type') or row.get('name_cn') + combination = row.get('combination_en') or row.get('combination') or row.get('source_reference') or row.get('reason') + else: + effects = row.get('effects') if isinstance(row.get('effects'), list) else [] + yoga_name = row.get('name_cn') or row.get('name') or row.get('type') + combination = row.get('combination') or row.get('source_reference') or row.get('reason') lines.append( - f"| {_cell(row.get('name_cn') or row.get('name') or row.get('type'))} | {_cell(row.get('category') or row.get('subtype'))} | " - f"{_cell(row.get('strength'))} | {_cell(row.get('combination') or row.get('source_reference') or row.get('reason'))} | " + f"| {_cell(yoga_name)} | {_cell(row.get('category') or row.get('subtype'))} | " + f"{_cell(row.get('strength'))} | {_cell(combination)} | " f"{_cell(_public_report_text(_brief(effects, 3) or row.get('interpretation') or row.get('summary')))} |" ) lines.append('') + if yoga_pages: + lines.extend([ + '### Yoga interpretation' if _lang == 'en' else '### 瑜伽解释', + '', + ]) + for row in yoga_pages: + if not isinstance(row, dict): + continue + name = str(row.get('name') or '').strip() + sections = _dict(row.get('sections')) + if not name: + continue + lines.extend([f'#### {name}', '']) + for key in ('detected', 'theme', 'result', 'activation'): + value = str(sections.get(key) or '').strip() + if value: + lines.extend([_public_report_text(value), '']) supportive_raw = dosha_pack.get('supportive_practices') supportive_practices = _list(supportive_raw) if isinstance(supportive_raw, dict): @@ -2620,7 +2726,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: dasha = timing.get('dasha_interpretation_pack') if isinstance(timing.get('dasha_interpretation_pack'), dict) else {} dasha_pages = dasha.get('page_series') if isinstance(dasha.get('page_series'), list) else [] if dasha_pages: - lines.extend(['## 大运解释', '']) + lines.extend(['## Dasha Interpretation' if _lang == 'en' else '## 大运解释', '']) for page in dasha_pages: if not isinstance(page, dict): continue diff --git a/tests/test_report_english_edition.py b/tests/test_report_english_edition.py index 17377fa2..66b2528b 100644 --- a/tests/test_report_english_edition.py +++ b/tests/test_report_english_edition.py @@ -82,14 +82,11 @@ def test_chinese_edition_is_untouched_by_the_english_render(rendered) -> None: def test_english_edition_has_no_chinese(rendered) -> None: _, _, en, _ = rendered - # 原值:阅读版英文 han_leaks 为空 - # 新值:完整数据版英文若仍含汉字,必须是清理器留下的 source text retained 标记;清干净时汉字为空 - # 原因:BUG-1272。公开接口整份撤回英文,不把夹中文的英文交给用户;见 API 测试 - if HAN.search(en): - assert "[source text retained:" in en - else: - assert han_leaks(en) == [] - assert not HAN.search(en) + # 原值:有汉字就必须是 source text retained + # 新值:英文全文汉字数为 0 + # 原因:BUG-1272。术语补齐后,这三张虚构盘不得再留下汉字 + assert re.findall(r"[㐀-鿿豈-﫿]+", en) == [] + assert han_leaks(en) == [] def test_english_edition_passes_reader_leak_patterns(rendered) -> None: @@ -166,16 +163,13 @@ def test_api_without_languages_is_unchanged_and_with_languages_adds_english() -> assert both["markdown"] == plain["markdown"] assert both["fact_table_packet"] == plain["fact_table_packet"] assert both["reader_dasha_applicability"] == plain["reader_dasha_applicability"] - # 原值:阅读版一定带回无汉字的 markdown_en - # 新值:英文要么整份无汉字,要么 english_unavailable=han_leak 且不带 markdown_en - # 原因:BUG-1272,完整数据版英文清理后仍有汉字时走既有「英文暂不可用」 - if "markdown_en" in both: - assert not HAN.search(both["markdown_en"]) - assert not HAN.search(both.get("reader_dasha_applicability_en") or "") - assert "english_unavailable" not in both - else: - assert both["english_unavailable"] == "han_leak" - assert "markdown_en" not in both + # 原值:英文要么整份无汉字,要么 english_unavailable=han_leak 且不带 markdown_en + # 新值:虚构日盘必须带回无汉字的 markdown_en + # 原因:BUG-1272。这张盘英文已清零,接口应交付英文;人为泄漏仍由下一条测试撤回 + assert "markdown_en" in both + assert not HAN.search(both["markdown_en"]) + assert not HAN.search(both.get("reader_dasha_applicability_en") or "") + assert "english_unavailable" not in both zh_only = _api_response(packet, {**base_body, "languages": ["zh"]}) assert json.dumps(zh_only, sort_keys=True, ensure_ascii=False) == json.dumps(plain, sort_keys=True, ensure_ascii=False) diff --git a/tests/test_report_full_data_fix.py b/tests/test_report_full_data_fix.py new file mode 100644 index 00000000..6b2d93d0 --- /dev/null +++ b/tests/test_report_full_data_fix.py @@ -0,0 +1,218 @@ +"""Report completion for the full-data edition (TASK-report-full-data-fix-20261008). + +Template wording, annual field projection, the opening focus, and the two +mistranslations. Chart numbers come from the packet. These tests do not +calculate a new dasha or import PyJHora. +""" + +from __future__ import annotations + +import copy +import re + +from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown +from scripts.pl9_interpretation_packs import ( + attach_full_data_interpretation, + missing_field_count, + opening_focus_block, + section_coverage, +) +from scripts.pl9_reader_english import HAN + +FORBIDDEN_ZH = ( + "再婚", + "几段关系", + "早夭", + "一定会", + "必然", + "Neecha Bhanga Raja Yoga", + "落陷取消王", + "疾病诊断", + "疾病", +) +FORBIDDEN_EN = ( + "divorce", + "miscarriage", + "infertility", + "guarantee", + "guaranteed", +) +HALF_TRANSLATED = re.compile(r"[A-Za-z]+\s*[\u4e00-\u9fff]+s") + + +def _planet(sign: str, house: int, nakshatra: str, status: str = "Own Sign") -> dict: + return {"sign": sign, "house": house, "status": status, "nakshatra": nakshatra} + + +def _packet(language: str = "zh") -> dict: + planets = { + "Sun": _planet("Aries", 10, "Ashwini", "入旺(Exalted)"), + "Moon": _planet("Taurus", 2, "Rohini"), + "Mars": _planet("Capricorn", 7, "Exalted"), + "Mercury": _planet("Pisces", 6, "Debilitated"), + "Jupiter": _planet("Cancer", 1, "Exalted"), + "Venus": _planet("Libra", 4, "Own Sign"), + "Saturn": _planet("Aquarius", 8, "Own Sign"), + "Rahu": _planet("Gemini", 12, "Ardra", ""), + "Ketu": _planet("Sagittarius", 6, "Mula", ""), + } + lords = ["Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter"] + houses = {f"house_{index}": {"lord": lord, "cusp_sign": "Aries"} for index, lord in enumerate(lords, start=1)} + timeline = [] + for lord in ("Sun", "Moon", "Mars"): + current = lord == "Moon" + timeline.append({ + "lord": lord, + "start": "2000-01-01", + "end": "2006-01-01", + "is_current": current, + "antardasha_timeline": [{ + "lord": "Mars", + "start": "2001-01-01", + "end": "2002-01-01", + "is_current": current, + "pratyantar_dasha_timeline": [{ + "lord": "Mercury", + "start": "2001-02-01", + "end": "2001-04-01", + "is_current": current, + }], + }], + }) + return { + "report_language": language, + "birth_info": {"name": "虚构甲" if language == "zh" else "Fictional"}, + "worksheets": { + "d1_rasi_bhava": {"planets": planets, "houses": houses, "ascendant": {"sign": "Cancer"}}, + "strengths_and_scores": { + "functional_benefic_malefic": { + "functional_benefics": ["Jupiter", "Mars"], + "functional_malefics": ["Saturn"], + "functional_neutrals": ["Moon"], + }, + "shadbala": {"planets": {"Sun": {"total": 1.25}}}, + }, + "timing_and_predictive_systems": { + "dasha": {"timeline": timeline, "current_dasha": timeline[1]}, + "annual_tajika_pack": { + "solar_return": {"status": "partial_verified", "data": {"dt_local": "2026-04-07T09:00:00"}}, + "mudda_dasha": { + "status": "partial_verified", + "periods": [{"lord": "Sun", "start": "2026-04-07", "end": "2026-05-07", "months": 1}], + }, + "patyayini_dasha": {"status": "blocked", "reason": "not_in_engine"}, + "sahams": {"status": "partial_verified", "data": [{"name": "Punya", "sign": "Aries", "degree_in_sign": 1.2}]}, + "tajika_yogas": { + "status": "partial_verified", + "data": {"yogas": [{"name": "Ithasala", "present": True, "planets": ["Sun", "Moon"]}]}, + }, + "monthly_windows": [{ + "index": 1, + "lord": "Sun", + "duration_months": 1, + "start": "2026-04-07", + "end": "2026-05-07", + }], + }, + }, + "advanced_systems": { + "yoga": { + "detected_yogas": [{ + "name": "Gajakesari Yoga", + "name_cn": "象鹿瑜伽", + "category": "raja", + "effects": ["名声可见"], + "effects_en": ["visible reputation"], + "planets": ["Moon", "Jupiter"], + "combination": "Moon and Jupiter", + }, { + "name": "Neecha Bhanga Raja Yoga", + "name_cn": "落陷取消王瑜伽", + "category": "neecha_bhanga", + "effects": ["传统条件成立"], + "effects_en": ["the cancellation conditions hold"], + "planets": ["Mercury"], + }], + }, + "yogas_doshas": {"mangal_dosha": {"has_dosha": False, "mars_house": 7, "severity": "none"}}, + }, + }, + } + + +def test_t5_repairs_bhava_bala_and_planets_suffix() -> None: + raw = "\n".join([ + "# 印度占星星盘报告", + "", + "### p31-p33 Shadbala / Bhava Bala 摘要", + "", + "### p32 Bhava Bala 十二宫力量", + "", + "### p125 Natal / KP Ruling Planets", + "", + "### Upagraha and Sub-Planets Points", + "", + "### Planetary Friendship Matrix", + "", + ]) + text = _sanitize_pl9_ai_density_markdown(raw) + assert "Bhava 年龄状态" not in text + assert "六维力量 / Bhava Bala" in text + assert "Bhava Bala 十二宫力量" in text + assert "行星s" not in text + assert "本命 / KP 征象星" in text + assert HALF_TRANSLATED.search(text) is None + assert "年龄状态" not in text.split("Graha Avastha", 1)[-1] or "Baladi" not in raw + + +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 + text = render_full_data_markdown(packet) + for heading in ("大运解释", "行星、星宿与宫位解释", "宫主逐宫解释", "Dosha 逐项事实与解释", "瑜伽解释"): + assert heading in text + assert "Patyayini 没有实现" in text + assert "Mudda 月段窗口" in text + assert "blocked" not in text.lower() + for phrase in FORBIDDEN_ZH: + assert phrase not in text + assert "落陷取消(Neecha Bhanga" in text + assert "Raja Yoga" not in text + assert HALF_TRANSLATED.search(text) is None + assert "行星s" not in text + coverage = {row["section"]: row["status"] for row in section_coverage(packet)} + assert coverage["sthira"] == "out_of_scope" + assert "Sthira Dasha" not in text + assert missing_field_count(packet) <= 10 + + +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 + assert "AD" in text and "PD" in text + assert "Dasha Interpretation" in text + assert "House-lord interpretation" in text + assert "Yoga interpretation" in text + assert "Patyayini is not implemented" in text + assert "Monthly mudda windows" in text + for phrase in FORBIDDEN_EN: + assert phrase not in text.lower() + assert "Neecha Bhanga Raja Yoga" not in text + assert "not a Raja Yoga" in text + leaked = HAN.findall(text) + assert leaked == [], leaked[:12] + + +def test_chinese_render_stays_stable_when_english_uses_a_copy() -> None: + packet = _packet("zh") + before = render_full_data_markdown(copy.deepcopy(packet)) + english = copy.deepcopy(packet) + english["report_language"] = "en" + render_full_data_markdown(english) + after = render_full_data_markdown(copy.deepcopy(packet)) + assert after == before