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