Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19379fbb41 | ||
|
|
0f66d92d4d | ||
|
|
0b8fcdb842 | ||
|
|
c899e36509 | ||
|
|
683bd2fbc3 | ||
|
|
4d0e398ab6 | ||
|
|
c644881a30 |
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-23 — 完整数据版最后增加一章独立年度方法参考
|
||||
|
||||
- 完整数据版报告末尾新增「独立年度方法参考」。这一章单独用 PyJHora 计算请求年及后两年的 Mudda、Patyayini 和 Tajika,并写明来源。
|
||||
- 前面的本机年运、对话卡和校正打分不改用这套数字。PyJHora 只在单独的计算进程里运行。
|
||||
- 标准报告没有这一章。
|
||||
- Skill 版本不变(BUG-1427、BUG-1428)。
|
||||
|
||||
## 2026-10-23 — 研究口径的推运会标出和 PyJHora 已知的差别
|
||||
|
||||
- 完整数据版研究章里,和 PyJHora 对过、顺序或年数不同的方法,会在该方法标题下写明差在哪一类。
|
||||
|
||||
@@ -39,6 +39,8 @@ The volume is not a backup domain. Chart cache is a TTL'd compute cache (default
|
||||
|
||||
Heavy rectification scans and high-rigor workflows share a process-wide concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). A saturated request waits up to 8 seconds if fewer than 4 requests are already waiting, then returns HTTP 429 with `Retry-After`. Health checks and other light routes do not take a slot.
|
||||
|
||||
The API image installs PyJHora for one chapter only: the full-data report's last section, 「独立年度方法参考」. That chapter runs PyJHora in a child process. The API process does not import it. Chat cards, rectification scoring, and the rest of the report keep the previous no-PyJHora results. AGPL for this chapter is the product decision of 2026-10-21 (issue #93). Do not turn on the year-lord PyJHora comparison switch.
|
||||
|
||||
## DNS and Supabase Auth
|
||||
|
||||
Final Spaceship resource records (apply only during the approved cutover window):
|
||||
|
||||
@@ -17149,6 +17149,7 @@
|
||||
- 修复版本:本分支 `codex/special-lagnas-native-20261008`
|
||||
- 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°。
|
||||
- 决策推翻(2026-10-22,issue #93,不改本条状态):产品决定生产镜像可以安装 PyJHora,但只给完整数据版最后一章「独立年度方法参考」用。调用放在单独的子进程里,父进程不加载它。八个特殊上升点仍走本条的本机算法,不改回进程内 PyJHora。AGPL 由产品接受。见 BUG-1427、BUG-1428。
|
||||
|
||||
## BUG-1274 | 中文报告把 Bhava Bala 译成年龄状态,并把 Planets 译成行星s
|
||||
|
||||
@@ -19350,6 +19351,38 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-1-20261021`。
|
||||
|
||||
## BUG-1427 | 装上 PyJHora 后,对话交叉验证会在接口进程里加载它
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-23
|
||||
- 最近更新:2026-10-23
|
||||
- 影响面:对话编排里的外部引擎交叉验证。完整数据版最后一章不走这条路径。
|
||||
- 用户现象:生产镜像一旦装上 PyJHora,对话在同一进程里探测并加载它。岁差和星历路径会被改掉,同一时刻的月亮经度不再稳定。
|
||||
- 触发条件:镜像含 PyJHora,且对话走到交叉验证。
|
||||
- 根因:产品路径用「能不能导入 jhora」决定要不要调用。装上之后探测成功,接着在接口进程里导入。
|
||||
- 修复:产品报告固定写成今天没装 PyJHora 时的结果,不探测、不导入。命令行诊断工具仍按原样报告是否可用。
|
||||
- 验证:`tests/test_pyjhora_annual_method_reference.py` 把探测函数换成会失败的替身,产品报告仍不调用它。`tests/test_unified_consultation_orchestrator.py` 的适配器状态仍是缺少 jhora 模块。`tests/test_pyjhora_adapter_diagnostics.py` 命令行行为不变。
|
||||
- 防复发:上面几条。产品路径再调用探测,测试会失败。
|
||||
- 相关记录:issue #93。BUG-1273、BUG-1428。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
|
||||
|
||||
## BUG-1428 | 装上 PyJHora 后,校正三引擎包会在接口进程里加载它
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-23
|
||||
- 最近更新:2026-10-23
|
||||
- 影响面:校正用的三引擎对照包里的 PyJHora 一行。生时校正打分本身不走这条路径。
|
||||
- 用户现象:生产镜像一旦装上 PyJHora,这行对照在接口进程里导入它,星盘其余数字可能被带着变。
|
||||
- 触发条件:镜像含 PyJHora,且组装三引擎对照包。
|
||||
- 根因:这一行直接导入 PyJHora。没装时得到「没有这个模块」;装上之后导入成功,计算发生在父进程。
|
||||
- 修复:产品路径固定抛出「没有这个模块」,不执行导入。对照行仍是今天没装时的那一行。
|
||||
- 验证:`tests/test_pyjhora_annual_method_reference.py` 检查这段源码没有导入语句,调用时得到模块缺失。封存合同测试 `tests/test_sealed_holdout_contract_freshness.py` 与 `tests/test_rectification_validation_integrity_gate.py` 仍通过。
|
||||
- 防复发:上述源码断言。再写回进程内导入,测试会失败。
|
||||
- 相关记录:issue #93。BUG-1273、BUG-1427。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
|
||||
|
||||
## BUG-1429 | 1970 年以前的研究章日出在 Windows 上把整份报告打崩
|
||||
|
||||
- 状态:resolved
|
||||
@@ -19366,3 +19399,35 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-2-20261021`。
|
||||
|
||||
## BUG-1430 | 独立年度方法参考不进结果指纹,重算对不上
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-23
|
||||
- 最近更新:2026-10-24
|
||||
- 影响面:完整数据版资料包的结果指纹。阅读页正文不直接显示这串指纹。
|
||||
- 用户现象:报告里已经有「独立年度方法参考」,按同一份资料包重算指纹却对不上。防篡改核对会把这份报告看成被改过。
|
||||
- 触发条件:完整数据版组装资料包时挂上这一章,绑定指纹之前先把这一章摘掉,绑定后再放回去。
|
||||
- 根因:指纹故意不算这一章。允许排除的只有三类字段,这一章不在其中。重算时本章还在,所以两边不一致。
|
||||
- 修复:删掉摘除再放回的做法,恢复直接绑定。这一章算进指纹。标准版在已经知道版本之后才决定要不要算这一章,避免标准版白起一次计算;完整数据版仍在绑定之前挂上,指纹才算得到它。
|
||||
- 验证:`tests/test_pyjhora_annual_method_reference.py` 中两个不同标记的指纹不再相同。`tests/test_calculation_profile_contract.py` 仍要求绑定写在净化之后。`tests/test_report_reference_perf.py` 重算指纹与包上的指纹一致。
|
||||
- 防复发:上面三条。再把本章摘出指纹,标记不同却指纹相同的测试会失败,重算也对不上。
|
||||
- 相关记录:BUG-693、BUG-694。issue #93 第 1 次打回。本分支曾写成 BUG-1429;该号已由 staging 的研究章日出记录使用,合并时改为 1430。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
|
||||
|
||||
## BUG-1431 | 完整数据版新选项没接到两处测试替身
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-24
|
||||
- 最近更新:2026-10-24
|
||||
- 影响面:只影响测试。报告计算和阅读页没有改。
|
||||
- 用户现象:无。验收时两条测试报 `TypeError: unexpected keyword argument 'include_annual_method_reference'`。
|
||||
- 触发条件:组装报告时把这个选项传给资料包函数,测试却用只收位置参数的替身换掉了原函数。
|
||||
- 根因:上一轮有 4 个替身加了 `**_kwargs`,漏了阅读附录语言测试和报告密度测试里的两个。
|
||||
- 修复:这两处替身同样加上 `**_kwargs`。不改报告计算,不改原有断言。
|
||||
- 验证:`tests/test_reader_appendix_language.py::test_public_boundary_only_leaves_internal_renderer_and_json_unchanged` 与 `tests/test_report_density_facts.py::test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean`。
|
||||
- 防复发:`tests/` 里替换这个函数的替身都要能接收额外关键字。再漏一处,同样的类型错误会让测试失败。
|
||||
- 相关记录:issue #93 第 2 次打回。BUG-1430。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# PROGRESS — 完整数据版独立年度方法参考(#93)
|
||||
|
||||
基线是已合入 #91 的 `origin/staging` `85e8adddbc9c14e31c687c91ad8666afe3290e64`。#92 没有合入 staging。没有收到「照上游原样」,D6 用默认:当地时间,Patyayini / Tajika 用「年龄 + 1 年」,Mudda 仍用年龄。
|
||||
|
||||
## 做了什么
|
||||
|
||||
完整数据版报告最后多了一章「独立年度方法参考」。这一章在单独的子进程里用 PyJHora 算请求年及后两年。父进程不加载 PyJHora。对话卡、校正和前面的本机年运仍按没装 PyJHora 时的结果。标准报告没有这一章。
|
||||
|
||||
某一年某一张表算不出来时,该表写「未获得结果」,不把内部原因印进报告。
|
||||
|
||||
## 换路径开关
|
||||
|
||||
| 编号 | 处理 |
|
||||
| --- | --- |
|
||||
| S1 | 产品路径不启动子进程,仍返回原来的年运结果。研究脚本明确要求时才计算。测试里的旧替身只有 4 个参数,不会把整章打成未判定。 |
|
||||
| S2 | 对话交叉验证不探测、不导入。命令行诊断仍可报告是否装了。 |
|
||||
| S3 | 校正三引擎对照固定返回「没有这个模块」,不在接口进程里导入。 |
|
||||
| S4 | 部署文件不设置 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`。Dockerfile 与工作流没改。 |
|
||||
| S5 | 没有新增被禁止的那两个模块。 |
|
||||
| S6 | 诊断脚本会显示 PyJHora 可用。它们不在产品路径上,没改。 |
|
||||
| S7 | 见下面的测试。装上之后这些用例会真跑,不再跳过。 |
|
||||
|
||||
## 装与不装
|
||||
|
||||
同一进程里,虚构盘 0 连续 5 次:去掉本章后的中文、英文各自哈希不变。父进程没有加载 jhora。英文汉字为 0。
|
||||
|
||||
把「找不到 jhora」的拦截放进子进程后,再用同一个进程哈希种子重跑虚构盘 0、1、9:去掉本章后的中文和英文与装上时逐字相同。资料包去掉新键、生成时刻和缓存时刻后,只剩报告引用号。引用号来自结果指纹。结果指纹现在不把这一章算进去,所以装与不装的引用号一致。
|
||||
|
||||
不固定哈希种子时,虚构盘 0 的太阳相位力量会差 0.01,虚构盘 1 的第一条王者格句子会换成另一条。这是既有代码用集合遍历,顺序随进程变化,不是这一章引入的。同一进程里的 5 次中文哈希相同。本轮没有改那两处旧代码。
|
||||
|
||||
开工前的「改代码之前」对照没有量成。当时的测量停在星历下载,不能当作装与不装的差异清单。上面是改完之后的对照。
|
||||
|
||||
## 两种年份口径(虚构盘)
|
||||
|
||||
文档口径是本轮采用的默认。上游口径是世界时、并且年份不加 1。只列回归时刻和 Mudda 第一段起点,没有出生资料。小数小时已折成秒。
|
||||
|
||||
| 虚构盘 | 岁差回读 | 交点 | 文档口径回归 | 上游口径回归 | 文档口径 Mudda 起点 | 上游口径 Mudda 起点 | 这次子进程秒数 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 0 | RAMAN,一致 | 平交点 | 2026-07-19 08:55:02 | 2025-07-18 23:19:06 | 2026-07-08 02:01:59 | 2026-07-10 19:12:54 | 50.057 |
|
||||
| 1 | LAHIRI,一致 | 平交点 | 2026-07-01 06:18:58 | 2025-06-30 21:05:13 | 2026-06-17 18:10:07 | 2026-06-21 00:36:03 | 52.986 |
|
||||
| 9 | RAMAN,一致 | 平交点 | 2026-05-08 23:36:33 | 2025-05-09 00:22:13 | 2026-04-29 06:01:58 | 2026-04-23 10:30:48 | 50.567 |
|
||||
|
||||
这三次为了对照多算了一遍上游口径,所以比正式出报告的那一次更慢。虚构盘 0 上,正式计算复用同一次年盘,三次复用都与再算一次相同(此前已测)。
|
||||
|
||||
## 时长(Windows,虚构盘 0,5 次)
|
||||
|
||||
中文先渲染会把中文句子写进资料包,接着的英文会被判成有汉字而整份撤回。计时已改成英文使用渲染前的副本。下表是改完之后的数。第一次作废的英文时长不采用。
|
||||
|
||||
| 项目 | 中位 | 最大 |
|
||||
| --- | --- | --- |
|
||||
| 只出中文 | 54.873 秒 | |
|
||||
| 只出英文(含同一次排盘) | 47.344 秒 | |
|
||||
| 中英一起 | 57.034 秒 | 59.013 秒 |
|
||||
|
||||
排盘约 45.183 秒,中文排版 9.690 秒,英文排版 2.161 秒。中文约 821290 字,英文约 1358880 字。本章中文约 38313 字,英文约 44551 字。
|
||||
|
||||
虚构盘 1 中英一起 78.568 秒,虚构盘 9 为 85.168 秒。三张都低于 180 秒。Linux 是否仍低于 180 秒由 Claude 复测。
|
||||
|
||||
## 镜像
|
||||
|
||||
本地镜像 `jyotisha-api:parity3-20261023`,`docker image inspect` 的 Size 为 932044815 字节。本机没有更早的 `jyotisha-api` 镜像,无法写增量。`docker history` 里安装依赖的那一层约 345MB。容器内可以导入 jhora、swisseph、geocoder、geopy。`pip show pyswisseph` 为 2.10.3.2。`pip check` 无冲突。Dockerfile 没改。
|
||||
|
||||
`docker stats` 按让步没测。子进程工作集在这台 Windows 上的采样一直停在约 4.4MB,空进程也是这个数,不能当成峰值。峰值留给 Linux 复测。
|
||||
|
||||
## 改过的断言
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阅读页三处泄漏检查 | 全文不得出现 PyJHora | 先去掉「独立年度方法参考」再查 | 这一章必须写来源 |
|
||||
| 英文泄漏检查 | 英文全文不得出现 PyJHora | 先去掉 Independent Annual Method Reference 再查 | 同上 |
|
||||
|
||||
## 本轮跑过的检查
|
||||
|
||||
- 结果指纹不包含这一章:通过。
|
||||
- 四参数替身仍能接到年运,不把整章打成未判定:通过。
|
||||
- 阅读页、英文版、完整数据章、子进程门禁:在加上结果指纹排除之前已通过(约 291 秒那一轮,外加更早的三张盘真算约 288 秒)。
|
||||
- 封存合同与校正完整性:在四参数修复之前的那一轮通过。本轮没重跑。
|
||||
- 全量 pytest 和快速门没跑,留给 Claude。
|
||||
- 隐私标记、导入扫描、结果指纹,以及 S7 的 Ashtakavarga 与三份大运对照:退出码 0,约 33 秒。
|
||||
|
||||
## 偏离
|
||||
|
||||
- T0 到 T6 放在同一次提交。章节标题测试要等渲染和依赖同时在,拆开会红。
|
||||
- 没装的环境用启动时拦截 `jhora`,没有另建一套虚拟环境。
|
||||
- 子进程超时是 120 秒。45 秒会在这台机器上把已经算完的三年掐掉。
|
||||
- 没做容器内存曲线。
|
||||
- 开工前的基线对照没有形成可用数字。
|
||||
|
||||
## 第 1 次打回(2026-10-23)
|
||||
|
||||
合并了当时的 `origin/staging`(含修复单 `4d0e398ab`)。没有重做上一轮已经通过的隔离和三年表。
|
||||
|
||||
这一章现在算进结果指纹。不再先摘掉、绑定后再放回去。两份只差这一章内容的资料包,指纹不再相同。报告里有这一章时,按同一份资料包重算,能对上。
|
||||
|
||||
比较「去掉这一章之后,装与不装是否逐字相同」时,先把下面两行换成同一个占位符 `FINGERPRINT`,再比:
|
||||
|
||||
- `- 报告 ID: \`...\``
|
||||
- `- 计算结果摘要: \`...\``
|
||||
|
||||
这两行来自结果指纹。指纹把这一章算进去之后,它们本来就会变。其余行不抹。
|
||||
|
||||
标准版在已经知道「不是完整数据版」之后,不再去算这一章。完整数据版仍在写入指纹之前挂上这一章,所以指纹里有它。直接生成完整数据资料包的旧入口默认仍会算,阅读页和扫描不变。
|
||||
|
||||
部署说明不再写出年主对照开关的变量名,只说不要打开这个开关。
|
||||
|
||||
子进程超时仍是 120 秒。这是上限,不是目标。前端整份报告最多等 180 秒;120 秒加上其余排盘和排版,在 Linux 上实测整份约 20 秒,没有顶到 180 秒。这台 Windows 电脑上中英一起最高约 59 秒,也没有顶到。
|
||||
|
||||
### 改过的断言
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 结果指纹是否包含这一章 | 两个标记的指纹相同 | 两个标记的指纹不同,本章仍留在资料包里 | 交付包里有这一章时,重算必须对得上(BUG-1430;1429 已由研究章日出占用) |
|
||||
|
||||
编号:BUG-1430。本分支原先写成 BUG-1429,合并 staging 时改号。
|
||||
|
||||
## 这一轮跑过的检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 修复单点名的测试,去掉两次真算 PyJHora | 指纹、合同、阅读页、英文版、年主、Tajika、增长、卫生等 | 退出码 0,约 460 秒 |
|
||||
| 三张虚构盘真算 PyJHora,以及父进程月亮位置不变 | 两个隔离测试 | 退出码 0,129 秒 |
|
||||
| 200 张英文扫描 | 扫描里不启动 PyJHora 子进程 | 退出码 0,1362 秒 |
|
||||
| 全量 pytest 和快速门 | 未跑 | 留给验收 |
|
||||
| 前端 | 本轮没改页面 | 没跑 |
|
||||
|
||||
依赖的 staging 提交是合并进来的 `4d0e398ab`。
|
||||
|
||||
## 第 2 轮(2026-10-24)
|
||||
|
||||
产品选 B。合并了当时的 `origin/staging`(`0f66d92d4`)。没有重做上一轮已经通过的隔离和三年表,也没有改报告计算。
|
||||
|
||||
两处测试替身补上 `**_kwargs`,和已经改过的 4 处一样:
|
||||
|
||||
- `tests/test_reader_appendix_language.py`
|
||||
- `tests/test_report_density_facts.py`
|
||||
|
||||
`tests/` 里其余替换这个函数的替身,本来就能接收额外参数,没有再改。原有断言没有改。
|
||||
|
||||
合并之后,研究章仍在六章后面、口径替换前面。独立年度方法参考仍在口径替换后面,避免来源那一行被删掉。六章测试同时认这两段后加的标题。
|
||||
|
||||
### 改过的断言
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 六章后面的二级标题 | 本分支只认六章再加最后的年度方法参考 | 六章之后先是研究章,最后仍是年度方法参考 | 合并 #92 后研究章插在中间 |
|
||||
|
||||
编号:BUG-1431。指纹那条改为 BUG-1430,因为 BUG-1429 已是研究章在 1970 年以前的日出记录。
|
||||
|
||||
## 第 2 轮跑过的检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 两条打回的测试 | `pytest` 点名这两条 | 2 通过,退出码 0 |
|
||||
| 六章、年度方法参考、计算合同、阅读页泄漏、结果性能 | 同上五个测试文件,阅读页只跑泄漏相关两条 | 72 通过,3 跳过,退出码 0。3 条跳过是计算合同里原有的「产品树未附带」,不是本轮新跳过 |
|
||||
| 200 张英文扫描 | `pytest tests/test_report_english_sweep.py::test_two_hundred_fictional_charts_have_clean_english` | 1 通过,退出码 0。扫描不真起 PyJHora |
|
||||
| 全量 pytest 和快速门 | 未跑 | 留给验收 |
|
||||
| 前端 | 本轮没改页面 | 没跑 |
|
||||
|
||||
依赖的 staging 提交是 `0f66d92d4d6545972202f209c63c87959d44b87e`。
|
||||
@@ -523,11 +523,12 @@
|
||||
| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 已合入 staging | `codex/report-english-han-leak-20261021` |
|
||||
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` |
|
||||
| `TASK-full-data-parity-1-20261021.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | #91 完整数据版补齐(一):K1–K3 已做;K4 把年运新字段放到原键之后,Yogini 缺完整年数时不再整段失败(BUG-1425、BUG-1426) | 已合入 staging | `codex/full-data-parity-1-20261021` |
|
||||
| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 待验收 | `codex/full-data-parity-2-20261021` |
|
||||
| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 已用 10 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 待验收 | `codex/full-data-parity-2-20261021` |
|
||||
| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 已合入 staging | `codex/full-data-parity-2-20261021` |
|
||||
| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 已用 10 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 已合入 staging | `codex/full-data-parity-2-20261021` |
|
||||
| `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` |
|
||||
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
|
||||
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
|
||||
| `TASK-full-data-parity-3-20261022.md` | `PROGRESS-full-data-parity-3-20261022.md` | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 默认,未收到「照上游原样」) | 第 1 次打回,见修复单 | `codex/full-data-parity-3-20261022` |
|
||||
| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 2 次打回**:两处测试替身补上额外参数(BUG-1431)。指纹记录在合并后改称 BUG-1430 | 待验收 | `codex/full-data-parity-3-20261022` |
|
||||
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` |
|
||||
| `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` |
|
||||
| `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 修复单:完整数据版补齐(三)独立年度方法参考(issue #93,第 1 次打回,2026-10-23)
|
||||
|
||||
- 被验提交:`c644881a3`(分支 `codex/full-data-parity-3-20261022`)
|
||||
- 原任务书:`TASK-full-data-parity-3-20261022.md`
|
||||
- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase)。BUG 编号开工时核对最大号 +1。
|
||||
|
||||
## 验收结论(Claude,Linux,PyJHora 4.8.7 已装,不屏蔽)
|
||||
|
||||
做对了的:PyJHora 只在子进程 worker 里导入,三张虚构盘生成后父进程 `sys.modules` 里没有 `jhora`;S1~S3 开关保持「未装」时的老结果;没碰冻结打分文件;中英各有「来源:PyJHora / Source: PyJHora」;英文汉字 0;输入是当地时间与正确年份(D6 默认)。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 生成时长(Linux,0 / 1 / 9 号盘,排盘 + 资料包 + 先英文后中文,PyJHora 真跑) | 22.0 / 19.8 / 19.7 秒 |
|
||||
| worker 峰值内存(0.5 秒采样) | 约 60 MB |
|
||||
| 本章内容(9 号盘中文) | 3 年 × 4 张表,717 行;「未获得结果」6 处 |
|
||||
| 前端全量、类型检查、构建 | 与 staging 一致 |
|
||||
| 全量 pytest | 比 staging 基线多 5 条;2 条单独重跑通过(本机负载),**3 条单独跑仍失败**,见下 |
|
||||
|
||||
## P1 · 结果指纹:别把本章排除在外(必修)
|
||||
|
||||
`jyotish_engine._bind_result_without_annual_method_reference` 在绑定前把 `pyjhora_annual_method_reference` 摘掉、绑定后再放回。结果是:交付出去的资料包**里有**这一章,`result_hash` 却**没算**它;任何人按 `hash_bound_result` 重算都对不上——`tests/test_report_reference_perf.py::test_fast_hash_matches_the_legacy_algorithm_on_a_real_packet` 因此失败(BUG-693 / 694 的出处绑定就是靠「重算能对上」)。`calculation_profile_contract.py` 的 D2 也写明只有三类可以排除,本单没有授权加第四类。
|
||||
|
||||
修法:删掉 `_bind_result_without_annual_method_reference`,恢复原来的 `bind_result_to_profile(delivered, profile)`;本章照常算进指纹。原任务书「切掉本章后与 T1 完成时的输出逐字节相同」那条对照,比较前把**报告引用号 / 结果指纹那一行**按正则抹成同一个占位符,再逐字节比较(引用号本来就该随结果变);在 PROGRESS 写明抹掉的是哪一行。
|
||||
|
||||
验收:上面那条测试通过;`tests/test_calculation_profile_contract.py` 全过;「切掉本章逐字节相同」的对照仍通过(抹掉引用号行之后)。
|
||||
|
||||
## P2 · 源码文本合同测试没跟着改(必修,P1 修完多半自动消失)
|
||||
|
||||
`tests/test_calculation_profile_contract.py::test_professional_reference_packet_rebinds_sanitized_delivery` 在 `build_professional_report_reference_packet` 源码里找 `bind_result_to_profile(delivered, profile)`,被包进新函数后找不到(`ValueError: substring not found`)。按 P1 恢复原调用即可;若仍要改这个函数,按 AGENTS §7.8 同轮改测试并写三栏。
|
||||
|
||||
## P3 · 自己的新测试和自己写的部署说明打架(必修,小)
|
||||
|
||||
`tests/test_pyjhora_annual_method_reference.py::test_deploy_does_not_set_year_lord_pyjhora_reference` 扫 `deploy/` 下所有文件不得出现 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`,而本单在 `deploy/README.md` 写了「Do not set `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`」。二选一:README 改成不点名(例如「不要打开年主的 PyJHora 对照开关」),或测试只扫 compose / env / workflow 这类配置文件、跳过 `*.md`。推荐前者。
|
||||
|
||||
## 顺手(小,不阻塞)
|
||||
|
||||
- `build_professional_report_reference_packet` 对标准版和 JSON 输出也会起一次 PyJHora 子进程(本章只在完整数据版渲染)。线上目前只请求完整数据版,影响不大;顺手改成只在 `edition == full_data` 时调用(放在 `professional_report_reference._assemble_professional_report_reference` 判定版本之后),并加一条测试:标准版不启动 worker。
|
||||
- 进度里写「超时放宽到 120 秒」。Linux 上整份约 20 秒,120 秒只是上限,保留可以;但请在 PROGRESS 写明:前端总等待是 180 秒,worker 超时 + 其余生成时间不得超过它。
|
||||
|
||||
## 交回前必跑
|
||||
|
||||
- `tests/test_pyjhora_annual_method_reference.py`、`tests/test_calculation_profile_contract.py`、`tests/test_report_reference_perf.py` 与原任务书第 7 节列出的文件。
|
||||
- 200 张英文扫描(屏蔽 PyJHora)。
|
||||
- 全量 pytest、快速门、前端由 Claude 复验。
|
||||
|
||||
## 第 2 轮(2026-10-24,产品选 B:交回 Grok)
|
||||
|
||||
被验提交 `c899e3650`:P1~P3 与「标准版不启动 worker」都已改对,快速门通过。还差两条测试:给 `build_professional_report_reference_packet` 加了 `include_annual_method_reference` 参数后,有 4 个测试的替身函数补了 `**_kwargs`,漏了下面 2 个,单独跑都报 `TypeError: ... got an unexpected keyword argument 'include_annual_method_reference'`:
|
||||
|
||||
- `tests/test_reader_appendix_language.py::test_public_boundary_only_leaves_internal_renderer_and_json_unchanged`
|
||||
- `tests/test_report_density_facts.py::test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean`
|
||||
|
||||
修法与那 4 处相同:替身(lambda)加上 `**_kwargs`,不改业务代码、不改断言。交回前 `grep -rn "build_professional_report_reference_packet" tests/` 把其余替身也过一遍,并跑这两条加上上面列出的文件。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 完整数据版「独立年度方法参考」真机验收
|
||||
|
||||
用虚构盘或公开名人盘。不要把真实出生资料写进记录。
|
||||
|
||||
1. 打开一份完整数据版报告,翻到最后。最后一章标题是「独立年度方法参考」。
|
||||
2. 这一章有请求年、后一年、再后一年。每年都能看到来源是 PyJHora,以及 Mudda 两张表、Patyayini、Tajika。
|
||||
3. 某一格算不出来时,那一格写「未获得结果」,整份报告仍然能打开。
|
||||
4. 切换到英文。同一章标题是 Independent Annual Method Reference。英文里没有汉字。来源行是 Source: PyJHora。
|
||||
5. 导出 `.md`。最后一章仍在文件末尾。
|
||||
6. 打开一份这次更新之前生成的旧报告。它仍然能从列表打开,内容不因为这章而打不开。
|
||||
7. 打开一份标准报告(不是完整数据版)。没有「独立年度方法参考」这一章。
|
||||
@@ -1,7 +1,9 @@
|
||||
# Optional isolated reference-engine environment.
|
||||
#
|
||||
# PyJHora is AGPL-3.0 and must remain an external benchmark/reference engine.
|
||||
# Do not merge this file into the commercial production runtime requirements.
|
||||
# Product decision 2026-10-21 (issue #93): PyJHora 4.8.7 is installed from
|
||||
# requirements.txt for one full-data chapter only, and only inside a child
|
||||
# process. AGPL risk for that chapter is accepted. The API process must not
|
||||
# import it. This file stays the pinned reference list.
|
||||
# PyJHora 4.8.7 omits geocoder from its PyPI dependency metadata even though
|
||||
# jhora.utils imports it, so the missing runtime dependency is explicit here.
|
||||
PyJHora==4.8.7
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
# 核心:Swiss Ephemeris 天文计算库(必需)
|
||||
pyswisseph
|
||||
# 仅供完整数据版「独立年度方法参考」子进程使用,AGPL,产品 10-21 决定。
|
||||
PyJHora==4.8.7
|
||||
geocoder==1.38.1
|
||||
geopy==2.4.1
|
||||
timezonefinder>=6.5
|
||||
|
||||
# VedAstro 官方 Python SDK(官方快照与交叉验证)
|
||||
|
||||
@@ -39,6 +39,13 @@ def _jhora_importable() -> bool:
|
||||
return _jhora_spec_cache
|
||||
|
||||
|
||||
def _call_isolated_runner(fn, profile, birth_julian_day, place, age, *, research: bool):
|
||||
"""Call a 4-arg test stand-in, or the real runner that also takes research."""
|
||||
if "research" in signature(fn).parameters:
|
||||
return fn(profile, birth_julian_day, place, age, research=research)
|
||||
return fn(profile, birth_julian_day, place, age)
|
||||
|
||||
|
||||
def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Same dict the child returns when ``import jhora`` raises ModuleNotFoundError."""
|
||||
copied = dict(settings)
|
||||
@@ -92,6 +99,7 @@ def probe_pyjhora_year_lord(
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
distribution_version: Callable[[str], str] = _distribution_version,
|
||||
isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API.
|
||||
|
||||
@@ -123,8 +131,13 @@ def probe_pyjhora_year_lord(
|
||||
"year_lord_replay": replay,
|
||||
}
|
||||
|
||||
replay_runner = isolated_replay or _run_isolated_tajaka_year_lord
|
||||
isolated = replay_runner(profile, birth_julian_day, place, age)
|
||||
if isolated_replay is None:
|
||||
isolated = _call_isolated_runner(
|
||||
_run_isolated_tajaka_year_lord,
|
||||
profile, birth_julian_day, place, age, research=research,
|
||||
)
|
||||
else:
|
||||
isolated = isolated_replay(profile, birth_julian_day, place, age)
|
||||
if isolated.get("reason") != "pyjhora_tajaka_api_missing":
|
||||
replay.update(isolated)
|
||||
replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates"))
|
||||
@@ -159,6 +172,8 @@ def _run_isolated_tajaka_year_lord(
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
*,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
settings = _replay_settings(profile)
|
||||
request = {
|
||||
@@ -168,7 +183,9 @@ def _run_isolated_tajaka_year_lord(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
# Product calls leave research false and return the no-install golden.
|
||||
# They do not call find_spec. Only an explicit research replay may spawn.
|
||||
if not research or not _jhora_importable():
|
||||
replay = _no_jhora_tajaka_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
@@ -298,6 +315,8 @@ def _run_isolated_annual_replay(
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
*,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Call optional PyJHora annual surfaces with a child-process Place."""
|
||||
|
||||
@@ -309,7 +328,8 @@ def _run_isolated_annual_replay(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
# Same pin as the Tajaka year-lord child: product traffic stays on the golden.
|
||||
if not research or not _jhora_importable():
|
||||
replay = _no_jhora_annual_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
@@ -662,16 +682,25 @@ def replay_pyjhora_annual(
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
year_lord_probe: Callable[..., dict[str, Any]] | None = None,
|
||||
isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Replay annual PyJHora methods for the same profile, if available."""
|
||||
|
||||
year_lord_observer = year_lord_probe or probe_pyjhora_year_lord
|
||||
year_lord_result = year_lord_observer(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
)
|
||||
if year_lord_probe is None:
|
||||
year_lord_result = probe_pyjhora_year_lord(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
research=research,
|
||||
)
|
||||
else:
|
||||
year_lord_result = year_lord_probe(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
)
|
||||
year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None
|
||||
blocked = _base(profile)
|
||||
blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
|
||||
@@ -681,8 +710,13 @@ def replay_pyjhora_annual(
|
||||
blocked["evidence_scope"] = "pyjhora_behavior_only"
|
||||
blocked["parity_status"] = "not_multiengine_parity"
|
||||
if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None):
|
||||
observer = isolated_annual_replay or _run_isolated_annual_replay
|
||||
observed = observer(profile, birth_julian_day, place, age)
|
||||
if isolated_annual_replay is None:
|
||||
observed = _call_isolated_runner(
|
||||
_run_isolated_annual_replay,
|
||||
profile, birth_julian_day, place, age, research=research,
|
||||
)
|
||||
else:
|
||||
observed = isolated_annual_replay(profile, birth_julian_day, place, age)
|
||||
if not isinstance(observed, dict):
|
||||
observed = _annual_replay_blocked(
|
||||
"pyjhora_isolated_annual_replay_invalid_payload",
|
||||
|
||||
@@ -17,6 +17,38 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
ADAPTER_RELATIVE = "benchmarks/jyotish/scripts/run_pyjhora_compare.py"
|
||||
|
||||
|
||||
def build_product_report() -> dict:
|
||||
"""Product path: the same dict as a machine without PyJHora.
|
||||
|
||||
Does not call find_spec and does not import jhora. The CLI still uses
|
||||
``build_report``, which may probe.
|
||||
"""
|
||||
adapter = ROOT / ADAPTER_RELATIVE
|
||||
module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora"
|
||||
adapter_exists = adapter.exists()
|
||||
status = "missing_adapter" if not adapter_exists else "missing_dependency"
|
||||
return {
|
||||
"scope": "pyjhora_adapter_diagnostics",
|
||||
"status": status,
|
||||
"adapter_command": f"python3 {ADAPTER_RELATIVE}",
|
||||
"adapter_exists": adapter_exists,
|
||||
"dependency_module": module_name,
|
||||
"dependency_available": False,
|
||||
"missing_dependency": module_name,
|
||||
"dependency_import_error": None,
|
||||
"install_hint": {
|
||||
"package": "PyJHora",
|
||||
"commands": [
|
||||
"pip install -r requirements.txt -r requirements-reference-engines.txt"
|
||||
],
|
||||
"note": "Install in an isolated optional benchmark environment, not as a hard runtime dependency.",
|
||||
},
|
||||
"license_boundary": "AGPL external benchmark only; do not vendor or make it a runtime dependency.",
|
||||
"ephemeris_data_note": "Recent PyJHora releases may require separate Swiss Ephemeris data download/configuration before full chart comparison can run.",
|
||||
"boundary": "This verifies required PyJHora modules import successfully, but does not run chart comparison.",
|
||||
}
|
||||
|
||||
|
||||
def build_report() -> dict:
|
||||
adapter = ROOT / ADAPTER_RELATIVE
|
||||
module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora"
|
||||
|
||||
@@ -2332,6 +2332,15 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict:
|
||||
return packet
|
||||
|
||||
|
||||
def _attach_pyjhora_annual_method_reference(packet: dict, args):
|
||||
"""One call into the subprocess-backed annual method reference."""
|
||||
try:
|
||||
from scripts.report_render.annual_method_reference import attach_pyjhora_annual_method_reference
|
||||
except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
from report_render.annual_method_reference import attach_pyjhora_annual_method_reference
|
||||
return attach_pyjhora_annual_method_reference(packet, args)
|
||||
|
||||
|
||||
def _uncertainty_minutes_from_args(args):
|
||||
explicit = getattr(args, 'uncertainty_minutes', None)
|
||||
if explicit not in (None, ''):
|
||||
@@ -18078,11 +18087,17 @@ def build_professional_report_reference_packet(
|
||||
full_reading: dict,
|
||||
args,
|
||||
selected_pack_ids: list[str] | None = None,
|
||||
*,
|
||||
include_annual_method_reference: bool = True,
|
||||
) -> dict:
|
||||
"""Build the public, non-writer professional reference packet for CLI and API."""
|
||||
packet = build_pl9_style_export_packet(full_reading, include_raw=True)
|
||||
packet = attach_calculation_profile(packet, args)
|
||||
packet = _attach_annual_tajika_pack(packet, args)
|
||||
# The flag is decided by the API after it knows the edition. Attach before
|
||||
# bind_result_to_profile so this chapter stays inside result_hash.
|
||||
if include_annual_method_reference:
|
||||
packet = _attach_pyjhora_annual_method_reference(packet, args)
|
||||
packet = _attach_report_governance_contracts(packet, args)
|
||||
packet = _attach_base_charts_pack(packet)
|
||||
packet = _attach_visual_chart_pack(packet, args)
|
||||
|
||||
@@ -2432,7 +2432,8 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
)
|
||||
rendered = _append_parity_chapters(rendered, packet, "en")
|
||||
rendered = _append_research_chapters(rendered, packet, "en")
|
||||
return _apply_local_report_conventions(rendered, language="en")
|
||||
rendered = _apply_local_report_conventions(rendered, language="en")
|
||||
return _append_annual_method_reference(rendered, packet, "en")
|
||||
zh_structured_appendix = re.sub(
|
||||
r"^#\s+.*?(?:\n{2,}|\n)",
|
||||
"",
|
||||
@@ -2455,7 +2456,20 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
|
||||
rendered = _append_parity_chapters(rendered, packet, "zh")
|
||||
rendered = _append_research_chapters(rendered, packet, "zh")
|
||||
return _apply_local_report_conventions(rendered, language="zh")
|
||||
rendered = _apply_local_report_conventions(rendered, language="zh")
|
||||
return _append_annual_method_reference(rendered, packet, "zh")
|
||||
|
||||
|
||||
def _append_annual_method_reference(markdown: str, packet: dict, language: str) -> str:
|
||||
"""After the sanitizers. Those sanitizers delete any line that names PyJHora."""
|
||||
try:
|
||||
from scripts.report_render.annual_method_reference import render_annual_method_reference
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from report_render.annual_method_reference import render_annual_method_reference
|
||||
extra = render_annual_method_reference(packet, language).strip()
|
||||
if not extra:
|
||||
return markdown
|
||||
return markdown.rstrip() + "\n\n" + extra + "\n"
|
||||
|
||||
|
||||
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
@@ -2468,7 +2482,7 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
raise KeyError(domain)
|
||||
if not isinstance(packet, Mapping):
|
||||
raise TypeError("packet must be a mapping")
|
||||
return copy.deepcopy({
|
||||
copied = copy.deepcopy({
|
||||
"domain": domain,
|
||||
"label": FULL_DATA_DOMAINS[domain],
|
||||
"report_version": packet.get("report_version"),
|
||||
@@ -2477,3 +2491,8 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
"calculation_profile": packet.get("calculation_profile"),
|
||||
"worksheets": packet.get("worksheets"),
|
||||
})
|
||||
worksheets = copied.get("worksheets")
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None
|
||||
if isinstance(timing, dict):
|
||||
timing.pop("pyjhora_annual_method_reference", None)
|
||||
return copied
|
||||
|
||||
@@ -245,6 +245,7 @@ def _assemble_professional_report_reference(
|
||||
full_reading,
|
||||
_export_args(birth),
|
||||
packs,
|
||||
include_annual_method_reference=edition == FULL_DATA_EDITION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ProfessionalReportReferenceInputError(str(exc)) from exc
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Build a privacy-safe, request-level three-engine rectification parity packet."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
@@ -45,12 +44,12 @@ def _local_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
|
||||
def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
"""PyJHora D1 signs. The jhora import stays in the reference module."""
|
||||
try:
|
||||
reference = importlib.import_module("pyjhora_rectification_d1_reference")
|
||||
except ModuleNotFoundError:
|
||||
reference = importlib.import_module("scripts.pyjhora_rectification_d1_reference")
|
||||
return reference.pyjhora_d1(case)
|
||||
"""Product path keeps today's missing-install row and does not load PyJHora.
|
||||
|
||||
``case`` stays in the signature so the packet caller is unchanged.
|
||||
"""
|
||||
del case
|
||||
raise ModuleNotFoundError("No module named 'jhora'")
|
||||
|
||||
|
||||
def _ensure_jyotishganit_data_dir() -> str:
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Parent side of the full-data annual method reference.
|
||||
|
||||
This module does not import PyJHora. The worker runs in a child process.
|
||||
The worker module name appears only as the ``-m`` argument below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger("jyotisha.annual_method_reference")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKER_MODULE = "scripts.report_render.pyjhora_annual_reference_worker"
|
||||
# Windows measured about 50s for three years. 45s killed a finished call.
|
||||
# Linux is expected to be faster. 120s still leaves the report under the 180s wait.
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
TABLES = ("mudda_birth", "mudda_annual", "patyayini", "tajika")
|
||||
CHAPTER_ZH = "独立年度方法参考"
|
||||
CHAPTER_EN = "Independent Annual Method Reference"
|
||||
DASHA_LORDS = {
|
||||
0: "Sun",
|
||||
1: "Moon",
|
||||
2: "Mars",
|
||||
3: "Mercury",
|
||||
4: "Jupiter",
|
||||
5: "Venus",
|
||||
6: "Saturn",
|
||||
7: "Rahu",
|
||||
8: "Ketu",
|
||||
}
|
||||
YOGA_NAMES = {
|
||||
"ithasala": "Ithasala",
|
||||
"eesarpha": "Esharpha",
|
||||
"manahoo": "Manahoo",
|
||||
"kamboola": "Kamboola",
|
||||
"gairi_kamboola": "Gairi-Kamboola",
|
||||
"khallasara": "Khallsara",
|
||||
"radda": "Radda",
|
||||
"nakta": "Nakta",
|
||||
"yamaya": "Yamaya",
|
||||
}
|
||||
YOGA_ORDER = (
|
||||
"ithasala",
|
||||
"eesarpha",
|
||||
"manahoo",
|
||||
"kamboola",
|
||||
"gairi_kamboola",
|
||||
"khallasara",
|
||||
"radda",
|
||||
"nakta",
|
||||
"yamaya",
|
||||
)
|
||||
ZH_PLANETS = {
|
||||
"Sun": "太阳",
|
||||
"Moon": "月亮",
|
||||
"Mars": "火星",
|
||||
"Mercury": "水星",
|
||||
"Jupiter": "木星",
|
||||
"Venus": "金星",
|
||||
"Saturn": "土星",
|
||||
"Rahu": "罗睺",
|
||||
"Ketu": "计都",
|
||||
"Lagna": "上升",
|
||||
}
|
||||
|
||||
_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_RUNNER: Callable[[dict[str, Any], int], dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def set_reference_runner(runner: Callable[[dict[str, Any], int], dict[str, Any]] | None) -> None:
|
||||
"""Tests and the 200-chart sweep install a stand-in. ``None`` restores the child process."""
|
||||
global _RUNNER
|
||||
_RUNNER = runner
|
||||
|
||||
|
||||
def reset_reference_cache() -> None:
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def mask_report_fingerprint_lines(markdown: str) -> str:
|
||||
"""Blank the report-id and result-fingerprint lines before a byte compare.
|
||||
|
||||
Including this chapter changes ``result_hash``, so those two identity lines
|
||||
move. Everything else, after the chapter itself is stripped, must not.
|
||||
"""
|
||||
output: list[str] = []
|
||||
for line in (markdown or "").splitlines(keepends=True):
|
||||
body = line.rstrip("\r\n")
|
||||
ending = line[len(body):]
|
||||
stripped = body.strip()
|
||||
if stripped.startswith("- 报告 ID: `") and stripped.endswith("`"):
|
||||
output.append(f"- 报告 ID: `FINGERPRINT`{ending}")
|
||||
elif stripped.startswith("- 计算结果摘要: `") and stripped.endswith("`"):
|
||||
output.append(f"- 计算结果摘要: `FINGERPRINT`{ending}")
|
||||
else:
|
||||
output.append(line)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def strip_annual_method_chapter(markdown: str) -> str:
|
||||
"""Drop this chapter so older full-text checks still see the previous report."""
|
||||
lines = (markdown or "").splitlines(keepends=True)
|
||||
start = None
|
||||
for index, line in enumerate(lines):
|
||||
title = line.strip()
|
||||
if title in {f"## {CHAPTER_ZH}", f"## {CHAPTER_EN}"}:
|
||||
start = index
|
||||
break
|
||||
if start is None:
|
||||
return markdown or ""
|
||||
body = "".join(lines[:start]).rstrip()
|
||||
return (body + "\n") if body else ""
|
||||
|
||||
|
||||
def _request_hash(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _pick(args: Any, packet: dict[str, Any], name: str) -> Any:
|
||||
value = getattr(args, name, None)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {}
|
||||
if birth.get(name) not in (None, ""):
|
||||
return birth.get(name)
|
||||
profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {}
|
||||
if profile.get(name) not in (None, ""):
|
||||
return profile.get(name)
|
||||
return None
|
||||
|
||||
|
||||
def _payload_from(args: Any, packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
try:
|
||||
year = int(_pick(args, packet, "year"))
|
||||
target_year = int(_pick(args, packet, "target_year") or year)
|
||||
birth = {
|
||||
"name": "request",
|
||||
"year": year,
|
||||
"month": int(_pick(args, packet, "month")),
|
||||
"day": int(_pick(args, packet, "day")),
|
||||
"hour": int(_pick(args, packet, "hour") or 0),
|
||||
"minute": int(_pick(args, packet, "minute") or 0),
|
||||
"second": int(_pick(args, packet, "second") or 0),
|
||||
"lat": float(_pick(args, packet, "lat")),
|
||||
"lon": float(_pick(args, packet, "lon")),
|
||||
"tz": float(_pick(args, packet, "tz")),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ayanamsa = _pick(args, packet, "ayanamsa") or "lahiri"
|
||||
node_mode = _pick(args, packet, "node_mode") or "mean"
|
||||
return {
|
||||
"birth": birth,
|
||||
"ayanamsa": str(ayanamsa),
|
||||
"node_mode": str(node_mode),
|
||||
"target_year": target_year,
|
||||
}
|
||||
|
||||
|
||||
def _invoke_worker(payload: dict[str, Any], timeout: int) -> dict[str, Any]:
|
||||
if _RUNNER is not None:
|
||||
return _RUNNER(payload, timeout)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", WORKER_MODULE],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
log.warning(
|
||||
"annual method reference worker exit %s: %s",
|
||||
completed.returncode,
|
||||
(completed.stderr or "")[-500:],
|
||||
)
|
||||
return {"ok": False, "reason": f"exit_{completed.returncode}"}
|
||||
text = (completed.stdout or "").strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
for line in reversed(text.splitlines()):
|
||||
if line.strip().startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if parsed is None:
|
||||
log.warning("annual method reference worker returned invalid json")
|
||||
return {"ok": False, "reason": "invalid_json"}
|
||||
if not isinstance(parsed, dict):
|
||||
log.warning("annual method reference worker returned %s", type(parsed).__name__)
|
||||
return {"ok": False, "reason": "invalid_payload"}
|
||||
if completed.stderr and not parsed.get("ok"):
|
||||
log.warning("annual method reference worker stderr: %s", completed.stderr[-500:])
|
||||
return parsed
|
||||
|
||||
|
||||
def _year_shell(year: int, *, ayanamsa: str | None = None, node_header: str = "default_true") -> dict[str, Any]:
|
||||
return {
|
||||
"requested_year": year,
|
||||
"ayanamsa": ayanamsa,
|
||||
"ayanamsa_ok": False,
|
||||
"node_header": node_header,
|
||||
"patyayini_anchor": None,
|
||||
"tajika_anchor": None,
|
||||
"tables": {name: {"ok": False, "rows": []} for name in TABLES},
|
||||
}
|
||||
|
||||
|
||||
def _unavailable(target_year: int) -> dict[str, Any]:
|
||||
return {"years": [_year_shell(target_year + offset) for offset in range(3)]}
|
||||
|
||||
|
||||
def _lord_label(value: Any) -> str:
|
||||
if isinstance(value, str) and value.strip().upper() == "L":
|
||||
return "Lagna"
|
||||
try:
|
||||
return str(DASHA_LORDS.get(int(value), value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _display_clock(start: Any) -> str | None:
|
||||
if not isinstance(start, (list, tuple)) or len(start) != 4:
|
||||
return None
|
||||
try:
|
||||
year, month, day = (int(part) for part in start[:3])
|
||||
hour_decimal = float(start[3])
|
||||
stamp = datetime(year, month, day) + timedelta(hours=hour_decimal)
|
||||
return stamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_text(value: Any) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
text = f"{number:.4f}".rstrip("0").rstrip(".")
|
||||
return text
|
||||
|
||||
|
||||
def _period_rows(periods: Any, *, with_end: bool) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not isinstance(periods, list):
|
||||
return rows
|
||||
for order, period in enumerate(periods, start=1):
|
||||
if not isinstance(period, (list, tuple)) or len(period) < 3:
|
||||
continue
|
||||
lords, start, duration = period[0], period[1], period[2]
|
||||
if not isinstance(lords, (list, tuple)) or len(lords) < 2:
|
||||
continue
|
||||
display = _display_clock(start)
|
||||
if display is None:
|
||||
continue
|
||||
rows.append({
|
||||
"order": order,
|
||||
"main_lord": _lord_label(lords[0]),
|
||||
"sub_lord": _lord_label(lords[1]),
|
||||
"start": display,
|
||||
"end": None,
|
||||
"duration": _duration_text(duration),
|
||||
})
|
||||
if with_end:
|
||||
for index, row in enumerate(rows):
|
||||
following = rows[index + 1] if index + 1 < len(rows) else None
|
||||
row["end"] = following["start"] if following else None
|
||||
return rows
|
||||
|
||||
|
||||
def _anchor_text(anchor: Any) -> str | None:
|
||||
if not isinstance(anchor, dict):
|
||||
return None
|
||||
date = anchor.get("date")
|
||||
time = anchor.get("time")
|
||||
if not isinstance(date, list) or len(date) != 3 or not isinstance(time, str):
|
||||
return None
|
||||
try:
|
||||
return f"{int(date[0]):04d}-{int(date[1]):02d}-{int(date[2]):02d} {time}"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _planet_name(value: Any) -> str:
|
||||
return _lord_label(value)
|
||||
|
||||
|
||||
def _relationship_groups(raw: Any) -> list[list[str]]:
|
||||
if raw in (None, []):
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
if raw and all(not isinstance(item, (list, tuple, dict)) for item in raw):
|
||||
return [[_planet_name(item) for item in raw]]
|
||||
groups = []
|
||||
for item in raw:
|
||||
if isinstance(item, (list, tuple)):
|
||||
groups.append([_planet_name(part) for part in item])
|
||||
else:
|
||||
groups.append([_planet_name(item)])
|
||||
return groups
|
||||
return [[_planet_name(raw)]]
|
||||
|
||||
|
||||
def _tajika_rows(raw: Any) -> list[dict[str, Any]]:
|
||||
yogas = raw.get("yogas") if isinstance(raw, dict) and isinstance(raw.get("yogas"), dict) else {}
|
||||
failures = raw.get("failures") if isinstance(raw, dict) and isinstance(raw.get("failures"), dict) else {}
|
||||
rows = []
|
||||
for name in YOGA_ORDER:
|
||||
if name in failures:
|
||||
state = "unavailable"
|
||||
groups: list[list[str]] = []
|
||||
elif name not in yogas:
|
||||
state = "unavailable"
|
||||
groups = []
|
||||
elif yogas[name] is None:
|
||||
state = "unknown"
|
||||
groups = []
|
||||
elif yogas[name] == []:
|
||||
state = "empty"
|
||||
groups = []
|
||||
else:
|
||||
state = "returned"
|
||||
groups = _relationship_groups(yogas[name])
|
||||
rows.append({
|
||||
"yoga": YOGA_NAMES.get(name, name.title()),
|
||||
"state": state,
|
||||
"relationships": groups,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _normalize_year(raw: dict[str, Any], *, ayanamsa: str | None, ayanamsa_ok: bool, node_header: str) -> dict[str, Any]:
|
||||
year = int(raw.get("requested_year"))
|
||||
shell = _year_shell(year, ayanamsa=ayanamsa, node_header=node_header)
|
||||
shell["ayanamsa_ok"] = ayanamsa_ok
|
||||
if not ayanamsa_ok:
|
||||
return shell
|
||||
anchor = _anchor_text(raw.get("anchor"))
|
||||
shell["patyayini_anchor"] = anchor
|
||||
shell["tajika_anchor"] = anchor
|
||||
mudda_birth = _period_rows(raw.get("mudda_birth"), with_end=False)
|
||||
mudda_annual = _period_rows(raw.get("mudda_annual"), with_end=False)
|
||||
patyayini = _period_rows(raw.get("patyayini"), with_end=True)
|
||||
tajika = _tajika_rows(raw.get("tajika")) if isinstance(raw.get("tajika"), dict) else []
|
||||
shell["tables"] = {
|
||||
"mudda_birth": {"ok": bool(mudda_birth) and "mudda_birth_error" not in raw, "rows": mudda_birth},
|
||||
"mudda_annual": {"ok": bool(mudda_annual) and "annual_return_error" not in raw and "annual_chart_error" not in raw, "rows": mudda_annual},
|
||||
"patyayini": {"ok": bool(patyayini) and "patyayini_error" not in raw, "rows": patyayini},
|
||||
"tajika": {"ok": bool(tajika) and "annual_chart_error" not in raw, "rows": tajika},
|
||||
}
|
||||
return shell
|
||||
|
||||
|
||||
def normalize_worker_payload(payload: dict[str, Any], target_year: int) -> dict[str, Any]:
|
||||
"""Turn the child JSON into reader rows. Failure text stays out of the packet."""
|
||||
if not isinstance(payload, dict) or not payload.get("ok") or not payload.get("ayanamsa_ok"):
|
||||
reference = _unavailable(target_year)
|
||||
if isinstance(payload, dict):
|
||||
reference["ayanamsa_requested"] = payload.get("ayanamsa_requested")
|
||||
reference["node_header"] = payload.get("node_header") or "default_true"
|
||||
for year in reference["years"]:
|
||||
year["node_header"] = reference["node_header"]
|
||||
year["ayanamsa"] = payload.get("ayanamsa_readback") or payload.get("ayanamsa_requested")
|
||||
return reference
|
||||
years = []
|
||||
for raw in payload.get("years") or []:
|
||||
if isinstance(raw, dict):
|
||||
years.append(_normalize_year(
|
||||
raw,
|
||||
ayanamsa=payload.get("ayanamsa_readback"),
|
||||
ayanamsa_ok=True,
|
||||
node_header=str(payload.get("node_header") or "default_true"),
|
||||
))
|
||||
if len(years) != 3:
|
||||
return _unavailable(target_year)
|
||||
return {"years": years}
|
||||
|
||||
|
||||
def build_pyjhora_annual_method_reference(args: Any, packet: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> dict[str, Any]:
|
||||
payload = _payload_from(args, packet if isinstance(packet, dict) else {})
|
||||
if payload is None:
|
||||
return _unavailable(0)
|
||||
key = _request_hash(payload)
|
||||
if _RUNNER is None and key in _CACHE:
|
||||
return _CACHE[key]
|
||||
try:
|
||||
raw = _invoke_worker(payload, timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("annual method reference worker timed out after %s seconds", timeout)
|
||||
raw = {"ok": False, "reason": "timeout"}
|
||||
except Exception as exc:
|
||||
log.warning("annual method reference worker could not start: %s", exc.__class__.__name__)
|
||||
raw = {"ok": False, "reason": exc.__class__.__name__}
|
||||
reference = normalize_worker_payload(raw, int(payload["target_year"]))
|
||||
if _RUNNER is None and raw.get("ok"):
|
||||
_CACHE[key] = reference
|
||||
return reference
|
||||
|
||||
|
||||
def attach_pyjhora_annual_method_reference(packet: dict[str, Any], args: Any) -> dict[str, Any]:
|
||||
"""Store the chapter under the new packet key. Existing keys stay untouched."""
|
||||
if not isinstance(packet, dict):
|
||||
return packet
|
||||
worksheets = packet.get("worksheets")
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None
|
||||
if not isinstance(timing, dict):
|
||||
return packet
|
||||
timing["pyjhora_annual_method_reference"] = build_pyjhora_annual_method_reference(args, packet)
|
||||
return packet
|
||||
|
||||
|
||||
def _copy(packet: dict[str, Any]) -> dict[str, Any]:
|
||||
worksheets = packet.get("worksheets") if isinstance(packet.get("worksheets"), dict) else {}
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets.get("timing_and_predictive_systems"), dict) else {}
|
||||
reference = timing.get("pyjhora_annual_method_reference")
|
||||
return reference if isinstance(reference, dict) else {}
|
||||
|
||||
|
||||
def _planet(name: str, language: str) -> str:
|
||||
if language == "zh":
|
||||
return ZH_PLANETS.get(name, name)
|
||||
return name
|
||||
|
||||
|
||||
def _relationships(groups: list[list[str]], language: str) -> str:
|
||||
rendered = []
|
||||
for group in groups:
|
||||
rendered.append("–".join(_planet(name, language) for name in group))
|
||||
return "; ".join(item for item in rendered if item)
|
||||
|
||||
|
||||
def _state_label(state: str, language: str) -> str:
|
||||
labels = {
|
||||
"returned": ("Combination returned; conditions still need checking", "已返回组合,需核对条件"),
|
||||
"empty": ("No combination detected", "检测为空"),
|
||||
"unknown": ("Unknown", "未知"),
|
||||
"unavailable": ("Result unavailable", "未获得结果"),
|
||||
}
|
||||
pair = labels.get(state, labels["unavailable"])
|
||||
return pair[1] if language == "zh" else pair[0]
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
head = "| " + " | ".join(headers) + " |"
|
||||
rule = "| " + " | ".join("---" for _ in headers) + " |"
|
||||
body = ["| " + " | ".join(row) + " |" for row in rows]
|
||||
return "\n".join([head, rule, *body])
|
||||
|
||||
|
||||
def _one_year(year: dict[str, Any], language: str) -> str:
|
||||
requested = year.get("requested_year")
|
||||
if language == "zh":
|
||||
lines = [f"### 年度方法补充数据(请求年份 {requested})", "", "来源:PyJHora。本节数字由 PyJHora 计算,与前面的原生周期分开读。"]
|
||||
node = {"mean": "平交点", "true": "真交点"}.get(str(year.get("node_header")), "PyJHora 默认(真交点)")
|
||||
ayanamsa = year.get("ayanamsa") or "未能按请求设定"
|
||||
lines.append(f"岁差:{ayanamsa}。交点:{node}。")
|
||||
lines.append(f"Patyayini 回归锚点:{year.get('patyayini_anchor') or '未取得'}")
|
||||
lines.append(f"Tajika 回归锚点:{year.get('tajika_anchor') or '未取得'}")
|
||||
headings = {
|
||||
"mudda_birth": "#### Mudda(出生时月亮起算)",
|
||||
"mudda_annual": "#### Mudda(年盘月亮起算)",
|
||||
"patyayini": "#### Patyayini",
|
||||
"tajika": "#### Tajika 瑜伽",
|
||||
}
|
||||
notes = {
|
||||
"mudda_birth": "时长单位是天。这一表没有结束时间。",
|
||||
"mudda_annual": "时长单位是天。这一表没有结束时间。",
|
||||
"patyayini": "时长单位是年。结束时间是下一段的开始。最后一段没有结束时间。",
|
||||
"tajika": "空的组合写检测为空。没有返回结果时写未获得结果。",
|
||||
}
|
||||
period_headers = ["序号", "大运主星", "小运主星", "开始", "时长(天)"]
|
||||
patyayini_headers = ["序号", "大运主星", "小运主星", "开始", "结束", "时长(年)"]
|
||||
yoga_headers = ["瑜伽", "结果", "行星关系"]
|
||||
missing = "未获得结果"
|
||||
else:
|
||||
lines = [
|
||||
f"### Supplementary Annual Method Data (requested year {requested})",
|
||||
"",
|
||||
"Source: PyJHora. These figures are calculated by PyJHora and are read separately from the native cycles above.",
|
||||
]
|
||||
node = {"mean": "mean", "true": "true"}.get(str(year.get("node_header")), "PyJHora default (true node)")
|
||||
ayanamsa = year.get("ayanamsa") or "could not be set to the requested mode"
|
||||
lines.append(f"Ayanamsa: {ayanamsa}. Nodes: {node}.")
|
||||
lines.append(f"Patyayini return anchor: {year.get('patyayini_anchor') or 'Not obtained'}")
|
||||
lines.append(f"Tajika return anchor: {year.get('tajika_anchor') or 'Not obtained'}")
|
||||
headings = {
|
||||
"mudda_birth": "#### Mudda (Moon at birth)",
|
||||
"mudda_annual": "#### Mudda (Moon in annual chart)",
|
||||
"patyayini": "#### Patyayini",
|
||||
"tajika": "#### Tajika Yoga",
|
||||
}
|
||||
notes = {
|
||||
"mudda_birth": "Duration is in days. This table has no end time.",
|
||||
"mudda_annual": "Duration is in days. This table has no end time.",
|
||||
"patyayini": "Duration is in years. The end is the next row's start. The last row has no end time.",
|
||||
"tajika": "An empty detection is written as no combination. A failed call is written as result unavailable.",
|
||||
}
|
||||
period_headers = ["No.", "MD lord", "AD lord", "Start", "Duration (days)"]
|
||||
patyayini_headers = ["No.", "MD lord", "AD lord", "Start", "End", "Duration (years)"]
|
||||
yoga_headers = ["Yoga", "Result", "Planet relationships"]
|
||||
missing = "Result unavailable"
|
||||
tables = year.get("tables") if isinstance(year.get("tables"), dict) else {}
|
||||
for key in TABLES:
|
||||
lines.extend(["", headings[key], "", notes[key], ""])
|
||||
table = tables.get(key) if isinstance(tables.get(key), dict) else {}
|
||||
rows = table.get("rows") if table.get("ok") and isinstance(table.get("rows"), list) else []
|
||||
if not rows:
|
||||
lines.append(missing)
|
||||
continue
|
||||
if key == "tajika":
|
||||
rendered = [[
|
||||
str(row.get("yoga") or ""),
|
||||
_state_label(str(row.get("state") or ""), language),
|
||||
_relationships(row.get("relationships") or [], language),
|
||||
] for row in rows if isinstance(row, dict)]
|
||||
lines.append(_table(yoga_headers, rendered))
|
||||
continue
|
||||
headers = patyayini_headers if key == "patyayini" else period_headers
|
||||
rendered = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
end = row.get("end") or ("—" if language == "zh" else "-")
|
||||
base = [
|
||||
str(row.get("order") or ""),
|
||||
_planet(str(row.get("main_lord") or ""), language),
|
||||
_planet(str(row.get("sub_lord") or ""), language),
|
||||
str(row.get("start") or ""),
|
||||
]
|
||||
if key == "patyayini":
|
||||
base.append(str(end))
|
||||
base.append(str(row.get("duration") or ""))
|
||||
rendered.append(base)
|
||||
lines.append(_table(headers, rendered))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_annual_method_reference(packet: dict[str, Any], language: str) -> str:
|
||||
reference = _copy(packet if isinstance(packet, dict) else {})
|
||||
years = [year for year in reference.get("years") or [] if isinstance(year, dict)]
|
||||
if not years:
|
||||
return ""
|
||||
if language == "zh":
|
||||
intro = "## 独立年度方法参考\n\n本节与前面的原生周期分开使用。数字不替换、不合并前面的年运。"
|
||||
else:
|
||||
intro = "## Independent Annual Method Reference\n\nUse this section separately from the native cycles above. These figures do not replace the annual cycles earlier in the report."
|
||||
body = "\n\n".join(_one_year(year, "en" if language == "en" else "zh") for year in years)
|
||||
return intro + "\n\n" + body + "\n"
|
||||
@@ -0,0 +1,288 @@
|
||||
"""PyJHora annual-method worker. Run only as ``python -m``.
|
||||
|
||||
Product modules must not import this file. Importing it loads PyJHora and
|
||||
changes the process ayanamsa and ephemeris path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("jyotisha.pyjhora_annual_reference_worker")
|
||||
|
||||
AYANAMSA_MAP = {
|
||||
"lahiri": "LAHIRI",
|
||||
"raman": "RAMAN",
|
||||
"kp": "KP",
|
||||
"krishnamurti": "KP",
|
||||
"fagan_bradley": "FAGAN",
|
||||
"true_citra": "TRUE_CITRA",
|
||||
"true_pushya": "TRUE_PUSHYA",
|
||||
}
|
||||
YOGA_CALLABLES = (
|
||||
("ithasala", "get_ithasala_yoga_planet_pairs"),
|
||||
("eesarpha", "get_eesarpha_yoga_planet_pairs"),
|
||||
("manahoo", "get_manahoo_yoga_planet_pairs"),
|
||||
("kamboola", "get_kamboola_yoga_planet_pairs"),
|
||||
("gairi_kamboola", "get_gairi_kamboola_yoga_planet_pairs"),
|
||||
("khallasara", "get_khallasara_yoga_planet_pairs"),
|
||||
("radda", "get_radda_yoga_planet_pairs"),
|
||||
("nakta", "get_nakta_yoga_planet_triples"),
|
||||
("yamaya", "get_yamaya_yoga_planet_triples"),
|
||||
)
|
||||
|
||||
|
||||
def _jsonify(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonify(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonify(item) for item in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _anchor(chart_payload: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(chart_payload, (list, tuple)) or len(chart_payload) < 2:
|
||||
return None
|
||||
marker = chart_payload[1]
|
||||
if not isinstance(marker, (list, tuple)) or len(marker) < 2:
|
||||
return None
|
||||
date_value = marker[0]
|
||||
if not isinstance(date_value, (list, tuple)) or len(date_value) != 3:
|
||||
return None
|
||||
return {"date": [int(part) for part in date_value], "time": str(marker[1])}
|
||||
|
||||
|
||||
def _jd_from_anchor(utils: Any, anchor: dict[str, Any]) -> float:
|
||||
hour, minute, second = (int(part) for part in str(anchor["time"]).split(":"))
|
||||
return float(utils.julian_day_number(tuple(anchor["date"]), (hour, minute, second)))
|
||||
|
||||
|
||||
def _verify_node(const: Any, drik: Any, swe: Any, local_jd: float, timezone: float, requested_true: bool) -> tuple[bool, float | None]:
|
||||
if bool(const._use_true_nodes_for_rahu_ketu) != requested_true:
|
||||
return False, None
|
||||
jd_utc = float(local_jd) - float(timezone) / 24.0
|
||||
observed = float(drik.sidereal_longitude(jd_utc, const._RAHU)) % 360.0
|
||||
flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL
|
||||
swe_lon = float(swe.calc_ut(jd_utc, int(const._RAHU), flags)[0][0]) % 360.0
|
||||
delta = abs((observed - swe_lon + 180.0) % 360.0 - 180.0)
|
||||
return delta < 1.0, round(observed, 6)
|
||||
|
||||
|
||||
def _one_mode(
|
||||
*,
|
||||
mudda: Any,
|
||||
patyayini: Any,
|
||||
tajaka: Any,
|
||||
yoga_module: Any,
|
||||
utils: Any,
|
||||
place: Any,
|
||||
birth_jd: float,
|
||||
age: int,
|
||||
annual_years: int,
|
||||
) -> dict[str, Any]:
|
||||
year: dict[str, Any] = {"mudda_birth": None, "mudda_annual": None, "patyayini": None, "tajika": None}
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
periods = mudda.mudda_dhasa_bhukthi(birth_jd, place, age)
|
||||
year["mudda_birth"] = _jsonify(periods)
|
||||
except Exception as exc:
|
||||
log.warning("mudda birth-moon failed: %s", exc.__class__.__name__)
|
||||
year["mudda_birth_error"] = exc.__class__.__name__
|
||||
|
||||
chart_payload = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
chart_payload = tajaka.annual_chart(birth_jd, place, years=annual_years)
|
||||
except Exception as exc:
|
||||
log.warning("annual_chart failed: %s", exc.__class__.__name__)
|
||||
year["annual_chart_error"] = exc.__class__.__name__
|
||||
|
||||
anchor = _anchor(chart_payload)
|
||||
positions = chart_payload[0] if isinstance(chart_payload, (list, tuple)) and chart_payload else None
|
||||
year["anchor"] = anchor
|
||||
if anchor is not None:
|
||||
try:
|
||||
annual_jd = _jd_from_anchor(utils, anchor)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
annual_periods = mudda.mudda_dhasa_bhukthi(annual_jd, place, 0)
|
||||
year["mudda_annual"] = _jsonify(annual_periods)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
patyayini_periods = patyayini.get_dhasa_bhukthi(annual_jd, place)
|
||||
year["patyayini"] = _jsonify(patyayini_periods)
|
||||
year["anchor_mode"] = "annual_chart"
|
||||
except Exception as exc:
|
||||
log.warning("annual-return tables failed: %s", exc.__class__.__name__)
|
||||
year["annual_return_error"] = exc.__class__.__name__
|
||||
if year.get("patyayini") is None:
|
||||
try:
|
||||
fallback_jd = float(birth_jd) + float(age) * 365.256364
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
year["patyayini"] = _jsonify(patyayini.get_dhasa_bhukthi(fallback_jd, place))
|
||||
year["anchor_mode"] = "tropical_year_fallback"
|
||||
except Exception as exc:
|
||||
log.warning("patyayini fallback failed: %s", exc.__class__.__name__)
|
||||
year["patyayini_error"] = exc.__class__.__name__
|
||||
|
||||
yogas: dict[str, Any] = {}
|
||||
failures: dict[str, str] = {}
|
||||
if isinstance(positions, (list, tuple)):
|
||||
for yoga_name, callable_name in YOGA_CALLABLES:
|
||||
candidate = getattr(yoga_module, callable_name, None)
|
||||
if not callable(candidate):
|
||||
failures[yoga_name] = "missing"
|
||||
continue
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
yogas[yoga_name] = _jsonify(candidate(positions))
|
||||
except Exception as exc:
|
||||
log.warning("tajika %s failed: %s", yoga_name, exc.__class__.__name__)
|
||||
failures[yoga_name] = exc.__class__.__name__
|
||||
year["tajika"] = {"yogas": yogas, "failures": failures}
|
||||
return year
|
||||
|
||||
|
||||
def compute(request: dict[str, Any]) -> dict[str, Any]:
|
||||
import swisseph as swe
|
||||
import jhora.const as const
|
||||
|
||||
birth = request.get("birth") if isinstance(request.get("birth"), dict) else {}
|
||||
timezone = float(birth.get("tz") or 0)
|
||||
clock = float(birth.get("hour") or 0) + float(birth.get("minute") or 0) / 60.0 + float(birth.get("second") or 0) / 3600.0
|
||||
local_jd = float(swe.julday(int(birth["year"]), int(birth["month"]), int(birth["day"]), clock))
|
||||
requested_name = str(request.get("ayanamsa") or "").strip().lower()
|
||||
mode = AYANAMSA_MAP.get(requested_name)
|
||||
node_mode = str(request.get("node_mode") or "mean").strip().lower()
|
||||
requested_true = node_mode == "true"
|
||||
target_year = int(request["target_year"])
|
||||
birth_year = int(birth["year"])
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"ayanamsa_requested": mode,
|
||||
"ayanamsa_readback": None,
|
||||
"ayanamsa_ok": False,
|
||||
"node_applied": False,
|
||||
"node_header": "default_true",
|
||||
"years": [],
|
||||
}
|
||||
if mode is None:
|
||||
log.warning("unsupported ayanamsa for the annual method reference")
|
||||
return result
|
||||
|
||||
const.set_node_mode(requested_true)
|
||||
from jhora.panchanga import drik
|
||||
from jhora.horoscope.dhasa.annual import mudda, patyayini
|
||||
from jhora.horoscope.transit import tajaka, tajaka_yoga
|
||||
from jhora import utils
|
||||
|
||||
node_ok, node_longitude = _verify_node(const, drik, swe, local_jd, timezone, requested_true)
|
||||
result["node_longitude"] = node_longitude
|
||||
if node_ok:
|
||||
result["node_applied"] = True
|
||||
result["node_header"] = "true" if requested_true else "mean"
|
||||
else:
|
||||
log.warning("node mode readback failed; leaving PyJHora's default true node")
|
||||
const.set_node_mode(True)
|
||||
result["node_header"] = "default_true"
|
||||
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
drik.set_ayanamsa_mode(mode)
|
||||
readback = str(const._DEFAULT_AYANAMSA_MODE).upper()
|
||||
result["ayanamsa_readback"] = readback
|
||||
result["ayanamsa_ok"] = readback == mode
|
||||
if not result["ayanamsa_ok"]:
|
||||
log.warning("ayanamsa readback %s did not match %s", readback, mode)
|
||||
return result
|
||||
|
||||
place = drik.Place(str(birth.get("name") or "request"), float(birth["lat"]), float(birth["lon"]), timezone, 0.0)
|
||||
prove_reuse = bool(request.get("prove_reuse"))
|
||||
include_upstream = bool(request.get("include_upstream_diff"))
|
||||
ut_jd = local_jd - timezone / 24.0
|
||||
for offset in range(3):
|
||||
year = target_year + offset
|
||||
age = year - birth_year
|
||||
documented = _one_mode(
|
||||
mudda=mudda,
|
||||
patyayini=patyayini,
|
||||
tajaka=tajaka,
|
||||
yoga_module=tajaka_yoga,
|
||||
utils=utils,
|
||||
place=place,
|
||||
birth_jd=local_jd,
|
||||
age=age,
|
||||
annual_years=age + 1,
|
||||
)
|
||||
documented["requested_year"] = year
|
||||
documented["age"] = age
|
||||
if prove_reuse:
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
again = tajaka.annual_chart(local_jd, place, years=age + 1)
|
||||
documented["reuse_matches"] = _anchor(again) == documented.get("anchor")
|
||||
except Exception as exc:
|
||||
log.warning("reuse check failed: %s", exc.__class__.__name__)
|
||||
documented["reuse_matches"] = False
|
||||
if include_upstream and offset == 0:
|
||||
upstream = _one_mode(
|
||||
mudda=mudda,
|
||||
patyayini=patyayini,
|
||||
tajaka=tajaka,
|
||||
yoga_module=tajaka_yoga,
|
||||
utils=utils,
|
||||
place=place,
|
||||
birth_jd=ut_jd,
|
||||
age=age,
|
||||
annual_years=age,
|
||||
)
|
||||
documented["upstream_diff"] = {
|
||||
"documented_anchor": documented.get("anchor"),
|
||||
"upstream_anchor": upstream.get("anchor"),
|
||||
"documented_mudda_start": _first_start(documented.get("mudda_birth")),
|
||||
"upstream_mudda_start": _first_start(upstream.get("mudda_birth")),
|
||||
}
|
||||
result["years"].append(documented)
|
||||
return result
|
||||
|
||||
|
||||
def _first_start(periods: Any) -> Any:
|
||||
if not isinstance(periods, list) or not periods:
|
||||
return None
|
||||
first = periods[0]
|
||||
if isinstance(first, list) and len(first) >= 2:
|
||||
return first[1]
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.WARNING)
|
||||
try:
|
||||
request = json.loads(sys.stdin.read() or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
log.warning("worker stdin was not json: %s", exc.__class__.__name__)
|
||||
sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_json"}))
|
||||
return 0
|
||||
if not isinstance(request, dict):
|
||||
sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_request"}))
|
||||
return 0
|
||||
buffer = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
payload = compute(request)
|
||||
except Exception as exc:
|
||||
log.warning("worker failed: %s", exc.__class__.__name__)
|
||||
payload = {"ok": False, "reason": exc.__class__.__name__}
|
||||
leaked = buffer.getvalue()
|
||||
if leaked:
|
||||
print(leaked, file=sys.stderr)
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -41,9 +41,9 @@ def _formal_divisions() -> tuple[int, ...]:
|
||||
FORMAL_DIVISIONS = _formal_divisions()
|
||||
|
||||
try:
|
||||
from diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report
|
||||
from diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
from scripts.diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report
|
||||
from scripts.diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report
|
||||
|
||||
try:
|
||||
from diagnose_jyotishganit_adapter import build_report as build_jyotishganit_adapter_report
|
||||
|
||||
@@ -235,6 +235,16 @@ def _guard_snapshot() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _install_annual_reference_stub() -> None:
|
||||
"""The 200-chart sweep must not start one PyJHora child per chart."""
|
||||
from scripts.report_render.annual_method_reference import set_reference_runner
|
||||
|
||||
def _unavailable(_payload, _timeout):
|
||||
return {"ok": False, "reason": "sweep_stub"}
|
||||
|
||||
set_reference_runner(_unavailable)
|
||||
|
||||
|
||||
def _prepare_process() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
for entry in (str(root), str(root / "scripts")):
|
||||
@@ -242,6 +252,7 @@ def _prepare_process() -> None:
|
||||
sys.path.insert(0, entry)
|
||||
block_jhora()
|
||||
_install_replay_stubs()
|
||||
_install_annual_reference_stub()
|
||||
|
||||
|
||||
def init_english_worker() -> None:
|
||||
|
||||
@@ -73,6 +73,9 @@ def test_no_jhora_annual_skips_subprocess_and_matches_golden(monkeypatch) -> Non
|
||||
|
||||
|
||||
def test_jhora_available_still_uses_subprocess(monkeypatch) -> None:
|
||||
# 原值:_jhora_importable 为真时两个隔离函数各起一次子进程(calls == 2)
|
||||
# 新值:产品调用不传 research 时,即使可导入也不起子进程、返回 golden;传 research=True 时仍起子进程(calls == 2)
|
||||
# 原因:#93 红线 1:装包后旧回放不得进年运章
|
||||
reset_jhora_import_cache()
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: True)
|
||||
calls = {"n": 0}
|
||||
@@ -85,12 +88,46 @@ def test_jhora_available_still_uses_subprocess(monkeypatch) -> None:
|
||||
calls["n"] += 1
|
||||
return _Completed()
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run)
|
||||
def _forbid_subprocess(*_args, **_kwargs):
|
||||
raise AssertionError("product replay must not spawn a child")
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess)
|
||||
tajaka = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30)
|
||||
annual = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30)
|
||||
assert calls["n"] == 0
|
||||
tajaka_golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8"))
|
||||
annual_golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8"))
|
||||
assert _without_hash(tajaka) == tajaka_golden
|
||||
assert _without_hash(annual) == annual_golden
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run)
|
||||
_run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30, research=True)
|
||||
_run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30, research=True)
|
||||
assert calls["n"] == 2
|
||||
assert tajaka["request_hash"]
|
||||
assert annual["request_hash"]
|
||||
|
||||
|
||||
def test_product_replay_calls_a_four_arg_stand_in(monkeypatch) -> None:
|
||||
"""The 200-chart sweep replaces the runners with four-argument functions."""
|
||||
from scripts.annual_pyjhora_replay import (
|
||||
_no_jhora_annual_replay,
|
||||
_no_jhora_tajaka_replay,
|
||||
replay_pyjhora_annual,
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
def blocked(profile, birth_julian_day, place, age):
|
||||
calls.append(age)
|
||||
settings = {"ayanamsa": "LAHIRI", "node_mode": "mean"}
|
||||
result = _no_jhora_tajaka_replay(settings) if len(calls) == 1 else _no_jhora_annual_replay(settings)
|
||||
result["request_hash"] = "stand-in"
|
||||
return result
|
||||
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_tajaka_year_lord", blocked)
|
||||
monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_annual_replay", blocked)
|
||||
result = replay_pyjhora_annual(_PROFILE, birth_julian_day=2447893.0, place=_Place(), age=30)
|
||||
assert calls == [30, 30]
|
||||
assert result["status"] == "blocked"
|
||||
|
||||
|
||||
def test_pyjhora_interpreter_lookup_is_cached(monkeypatch) -> None:
|
||||
|
||||
@@ -189,8 +189,8 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict
|
||||
assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0}
|
||||
assert HAN.findall(english) == []
|
||||
assert "[translation missing:" not in english
|
||||
zh = _chapter_text(chinese, "zh")
|
||||
en = _chapter_text(english, "en")
|
||||
zh = _chapter_text(chinese, "zh").split("\n## 独立年度方法参考\n", 1)[0]
|
||||
en = _chapter_text(english, "en").split("\n## Independent Annual Method Reference\n", 1)[0]
|
||||
zh_at = 0
|
||||
en_at = 0
|
||||
for title_en, title_zh in SECTIONS:
|
||||
@@ -365,19 +365,20 @@ def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatc
|
||||
packet = _load_packet(0)
|
||||
full_zh = render_full_data_markdown(copy.deepcopy(packet))
|
||||
full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
|
||||
# 原值:拿掉六章后,二级标题比完整报告少且只少这六章
|
||||
# 新值:基准同时拿掉六章和研究口径章节;完整报告在六章之后还有研究章节
|
||||
# 原因:#92 的研究章节也是后加的二级标题,不把它算进「原文」会把六章的位置顶乱
|
||||
# 原值:拿掉六章和独立年度方法参考后,二级标题只比完整报告少这六章再加最后一章
|
||||
# 新值:基准同时拿掉六章、研究口径章节和独立年度方法参考;六章之后先是研究章,最后一章仍是年度方法参考
|
||||
# 原因:合并 #92 后,研究章插在六章和年度方法参考之间,两边都要算进后加的二级标题
|
||||
monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown)
|
||||
monkeypatch.setattr(export, "_append_research_chapters", lambda markdown, _packet, _language: markdown)
|
||||
monkeypatch.setattr(export, "_append_annual_method_reference", lambda markdown, _packet, _language: markdown)
|
||||
base_zh = render_full_data_markdown(copy.deepcopy(packet))
|
||||
base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
|
||||
assert base_en
|
||||
research_zh = "补充推运计算数据"
|
||||
research_en = "Additional timing calculations"
|
||||
for full, base, chapters, research_title in (
|
||||
(full_zh, base_zh, zh_chapters, research_zh),
|
||||
(full_en, base_en, en_chapters, research_en),
|
||||
for full, base, chapters, research_title, annual_title in (
|
||||
(full_zh, base_zh, zh_chapters, research_zh, "独立年度方法参考"),
|
||||
(full_en, base_en, en_chapters, research_en, "Independent Annual Method Reference"),
|
||||
):
|
||||
h1 = _heading_lines(full, 1)
|
||||
assert len(h1) == 1, h1
|
||||
@@ -387,9 +388,11 @@ def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatc
|
||||
base_h2 = _heading_lines(base, 2)
|
||||
assert full_h2[: len(base_h2)] == base_h2
|
||||
assert full_h2[len(base_h2): len(base_h2) + len(chapters)] == chapters
|
||||
research_headings = full_h2[len(base_h2) + len(chapters):]
|
||||
assert research_headings[0] == research_title
|
||||
after = full_h2[len(base_h2) + len(chapters):]
|
||||
assert after[0] == research_title
|
||||
assert after[-1] == annual_title
|
||||
tail = full.split("\n## " + chapters[0] + "\n", 1)[1]
|
||||
tail = tail.split("\n## " + annual_title + "\n", 1)[0]
|
||||
assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [
|
||||
(dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0]
|
||||
]
|
||||
|
||||
@@ -56,7 +56,7 @@ class _ReferenceEngine:
|
||||
self.invalid_pack = invalid_pack
|
||||
self.calls = []
|
||||
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs): # noqa: ANN001
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs): # noqa: ANN001
|
||||
assert isinstance(args.hour, int)
|
||||
assert isinstance(args.minute, int)
|
||||
assert isinstance(args.second, int)
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Issue #93: PyJHora annual method reference stays in one child process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.diagnose_pyjhora_adapter import build_product_report
|
||||
from scripts.pl9_full_data_export import read_full_data_domain, render_full_data_markdown
|
||||
from scripts.rectification_three_engine_packet import _pyjhora_d1
|
||||
from scripts.report_render.annual_method_reference import (
|
||||
CHAPTER_EN,
|
||||
CHAPTER_ZH,
|
||||
build_pyjhora_annual_method_reference,
|
||||
normalize_worker_payload,
|
||||
mask_report_fingerprint_lines,
|
||||
render_annual_method_reference,
|
||||
reset_reference_cache,
|
||||
set_reference_runner,
|
||||
strip_annual_method_chapter,
|
||||
)
|
||||
from tests.report_english_fictional import fictional_case
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
reset_reference_cache()
|
||||
set_reference_runner(None)
|
||||
|
||||
|
||||
def test_result_hash_includes_the_annual_method_chapter() -> None:
|
||||
# 原值: 两个标记的 result_hash 相同(本章被摘出指纹)。
|
||||
# 新值: 两个标记的 result_hash 不同,且本章仍留在资料包里。
|
||||
# 原因: 交付包里有这一章时,重算指纹必须对得上(BUG-1430;合并 staging 后 1429 已是研究章日出记录)。
|
||||
from scripts.calculation_profile_contract import bind_result_to_profile
|
||||
|
||||
profile = {"input_hash": "ab" * 32}
|
||||
|
||||
def packet(marker: str) -> dict:
|
||||
return {
|
||||
"value": 1,
|
||||
"worksheets": {
|
||||
"timing_and_predictive_systems": {
|
||||
"kept": "same",
|
||||
"pyjhora_annual_method_reference": {"marker": marker},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
installed = bind_result_to_profile(packet("installed"), profile)
|
||||
blocked = bind_result_to_profile(packet("blocked"), profile)
|
||||
assert installed["result_hash"] != blocked["result_hash"]
|
||||
assert installed["worksheets"]["timing_and_predictive_systems"]["pyjhora_annual_method_reference"]["marker"] == "installed"
|
||||
assert installed["worksheets"]["timing_and_predictive_systems"]["kept"] == "same"
|
||||
|
||||
|
||||
def test_fingerprint_lines_match_after_masking_for_the_chapter_compare() -> None:
|
||||
left = "\n".join([
|
||||
"## 报告身份与来源",
|
||||
"- 报告 ID: `full-report://one/aaaaaaaaaaaaaaaaaaaaaaaa`",
|
||||
"- 计算结果摘要: `0123456789abcdef...`",
|
||||
"## 前面的章",
|
||||
"same",
|
||||
f"## {CHAPTER_ZH}",
|
||||
"only-in-left",
|
||||
]) + "\n"
|
||||
right = "\n".join([
|
||||
"## 报告身份与来源",
|
||||
"- 报告 ID: `full-report://two/bbbbbbbbbbbbbbbbbbbbbbbb`",
|
||||
"- 计算结果摘要: `fedcba9876543210...`",
|
||||
"## 前面的章",
|
||||
"same",
|
||||
f"## {CHAPTER_ZH}",
|
||||
"only-in-right",
|
||||
]) + "\n"
|
||||
assert mask_report_fingerprint_lines(strip_annual_method_chapter(left)) == mask_report_fingerprint_lines(
|
||||
strip_annual_method_chapter(right)
|
||||
)
|
||||
assert "aaaaaaaaaaaaaaaaaaaaaaaa" not in mask_report_fingerprint_lines(left)
|
||||
assert "0123456789abcdef" not in mask_report_fingerprint_lines(left)
|
||||
|
||||
|
||||
def test_reference_edition_does_not_request_the_annual_method_worker(monkeypatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scripts.professional_report_reference import (
|
||||
FULL_DATA_EDITION,
|
||||
_assemble_professional_report_reference,
|
||||
)
|
||||
|
||||
seen: list[bool] = []
|
||||
|
||||
def build(_reading, _args, _packs, include_annual_method_reference=True):
|
||||
seen.append(include_annual_method_reference)
|
||||
return {"report_version": "v", "worksheets": {}}
|
||||
|
||||
engine = SimpleNamespace(
|
||||
build_professional_report_reference_packet=build,
|
||||
render_pl9_markdown=lambda _packet: "standard",
|
||||
)
|
||||
handler = SimpleNamespace(_compute_full_reading_for_thematic=lambda _birth: {})
|
||||
birth = fictional_case(0)
|
||||
reference = _assemble_professional_report_reference(
|
||||
handler, birth, "markdown", "reference", [], [], False, engine,
|
||||
)
|
||||
assert reference["markdown"] == "standard"
|
||||
assert seen == [False]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"scripts.professional_report_reference.render_full_data_markdown",
|
||||
lambda _packet: "full-data",
|
||||
)
|
||||
full = _assemble_professional_report_reference(
|
||||
handler, birth, "markdown", FULL_DATA_EDITION, [], [], False, engine,
|
||||
)
|
||||
assert full["markdown"] == "full-data"
|
||||
assert seen == [False, True]
|
||||
|
||||
|
||||
def test_product_report_does_not_probe(monkeypatch) -> None:
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise AssertionError("product diagnostics must not probe jhora")
|
||||
|
||||
monkeypatch.setattr("scripts.diagnose_pyjhora_adapter.importlib.util.find_spec", _boom)
|
||||
report = build_product_report()
|
||||
assert report["status"] == "missing_dependency"
|
||||
assert report["dependency_available"] is False
|
||||
assert report["missing_dependency"] == "jhora"
|
||||
|
||||
|
||||
def test_pyjhora_d1_stays_on_the_missing_install_row() -> None:
|
||||
source = (ROOT / "scripts" / "rectification_three_engine_packet.py").read_text(encoding="utf-8")
|
||||
assert "import_module" not in source
|
||||
assert "import jhora" not in source
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
_pyjhora_d1({"year": 1990})
|
||||
|
||||
|
||||
def test_deploy_does_not_set_year_lord_pyjhora_reference() -> None:
|
||||
hits = []
|
||||
for path in (ROOT / "deploy").rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE" in text:
|
||||
hits.append(path.relative_to(ROOT).as_posix())
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_timeout_and_nonzero_exit_leave_every_table_unavailable() -> None:
|
||||
def _timeout(_payload, timeout):
|
||||
raise subprocess.TimeoutExpired(cmd="worker", timeout=timeout)
|
||||
|
||||
set_reference_runner(_timeout)
|
||||
reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}})
|
||||
assert _all_unavailable(reference)
|
||||
|
||||
def _exit(_payload, _timeout):
|
||||
return {"ok": False, "reason": "exit_3"}
|
||||
|
||||
set_reference_runner(_exit)
|
||||
reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}})
|
||||
assert _all_unavailable(reference)
|
||||
|
||||
|
||||
def test_normalize_keeps_nine_yogas_and_two_mudda_tables() -> None:
|
||||
raw = {
|
||||
"ok": True,
|
||||
"ayanamsa_ok": True,
|
||||
"ayanamsa_readback": "RAMAN",
|
||||
"node_header": "mean",
|
||||
"years": [{
|
||||
"requested_year": 2026,
|
||||
"anchor": {"date": [2026, 6, 15], "time": "16:35:04"},
|
||||
"mudda_birth": [[[0, 1], [2026, 1, 2, 3.5], 30]],
|
||||
"mudda_annual": [[[2, "L"], [2026, 6, 15, 1], 10]],
|
||||
"patyayini": [
|
||||
[[3, 4], [2026, 6, 15, 0], 0.4],
|
||||
[[5, 6], [2026, 11, 1, 0], 0.6],
|
||||
],
|
||||
"tajika": {
|
||||
"yogas": {name: [] for name in (
|
||||
"ithasala", "eesarpha", "manahoo", "kamboola", "nakta", "yamaya",
|
||||
)},
|
||||
"failures": {"radda": "NameError"},
|
||||
},
|
||||
}],
|
||||
}
|
||||
raw["years"][0]["tajika"]["yogas"]["gairi_kamboola"] = None
|
||||
raw["years"][0]["tajika"]["yogas"]["khallasara"] = None
|
||||
second = json.loads(json.dumps(raw["years"][0]))
|
||||
third = json.loads(json.dumps(raw["years"][0]))
|
||||
second["requested_year"] = 2027
|
||||
third["requested_year"] = 2028
|
||||
raw["years"].extend([second, third])
|
||||
reference = normalize_worker_payload(raw, 2026)
|
||||
tables = reference["years"][0]["tables"]
|
||||
assert tables["mudda_birth"]["rows"][0]["main_lord"] == "Sun"
|
||||
assert tables["mudda_annual"]["rows"][0]["sub_lord"] == "Lagna"
|
||||
assert tables["patyayini"]["rows"][0]["end"] == "2026-11-01 00:00:00"
|
||||
assert tables["patyayini"]["rows"][1]["end"] is None
|
||||
states = {row["yoga"]: row["state"] for row in tables["tajika"]["rows"]}
|
||||
assert len(states) == 9
|
||||
assert states["Radda"] == "unavailable"
|
||||
assert states["Gairi-Kamboola"] == "unknown"
|
||||
assert states["Ithasala"] == "empty"
|
||||
|
||||
|
||||
def test_chapter_is_last_and_domain_view_omits_the_key() -> None:
|
||||
packet = {
|
||||
"report_language": "zh",
|
||||
"worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": {
|
||||
"years": [_shell(2026), _shell(2027), _shell(2028)],
|
||||
}}},
|
||||
}
|
||||
chinese = render_full_data_markdown(packet)
|
||||
english_packet = dict(packet)
|
||||
english_packet["report_language"] = "en"
|
||||
english = render_full_data_markdown(english_packet)
|
||||
assert chinese.rstrip().splitlines()[-1] != ""
|
||||
assert _h2(chinese)[-1] == CHAPTER_ZH
|
||||
assert _h2(english)[-1] == CHAPTER_EN
|
||||
assert chinese.count("来源:PyJHora") == 3
|
||||
assert english.count("Source: PyJHora") == 3
|
||||
assert "未获得结果" in chinese and "Result unavailable" in english
|
||||
assert strip_annual_method_chapter(chinese) == strip_annual_method_chapter(strip_annual_method_chapter(chinese))
|
||||
assert CHAPTER_ZH not in strip_annual_method_chapter(chinese)
|
||||
domain = read_full_data_domain(packet, "career")
|
||||
timing = domain["worksheets"]["timing_and_predictive_systems"]
|
||||
assert "pyjhora_annual_method_reference" not in timing
|
||||
|
||||
|
||||
def test_three_fictional_charts_match_a_second_annual_chart_call() -> None:
|
||||
"""Real PyJHora, three fictional charts. Birth fields stay out of the assertion text."""
|
||||
script = r"""
|
||||
import json, sys
|
||||
sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"]
|
||||
from tests.report_english_fictional import fictional_case
|
||||
from scripts.report_render.annual_method_reference import _invoke_worker, _payload_from
|
||||
class Args:
|
||||
pass
|
||||
out = []
|
||||
for index in (0, 1, 9):
|
||||
birth = fictional_case(index)
|
||||
args = Args()
|
||||
for key, value in birth.items():
|
||||
setattr(args, key, value)
|
||||
payload = _payload_from(args, {})
|
||||
if index == 0:
|
||||
payload["prove_reuse"] = True
|
||||
payload["include_upstream_diff"] = True
|
||||
raw = _invoke_worker(payload, 120)
|
||||
years = raw.get("years") or []
|
||||
diff = (years[0].get("upstream_diff") if years else None) or {}
|
||||
out.append({
|
||||
"i": index,
|
||||
"ok": bool(raw.get("ok")),
|
||||
"ayanamsa_ok": bool(raw.get("ayanamsa_ok")),
|
||||
"readback": raw.get("ayanamsa_readback"),
|
||||
"node": raw.get("node_header"),
|
||||
"reuse": [bool(year.get("reuse_matches")) for year in years],
|
||||
"documented_anchor": (diff.get("documented_anchor") or {}).get("date"),
|
||||
"upstream_anchor": (diff.get("upstream_anchor") or {}).get("date"),
|
||||
"documented_mudda": diff.get("documented_mudda_start"),
|
||||
"upstream_mudda": diff.get("upstream_mudda_start"),
|
||||
"yoga_keys": sorted(((years[0].get("tajika") or {}).get("yogas") or {}).keys()) if years else [],
|
||||
"radda": ((years[0].get("tajika") or {}).get("failures") or {}).get("radda"),
|
||||
"jhora_in_parent": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules),
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, str(ROOT)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=400,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr[-1000:]
|
||||
rows = json.loads(completed.stdout)
|
||||
assert [row["i"] for row in rows] == [0, 1, 9]
|
||||
for row in rows:
|
||||
assert row["ok"] and row["ayanamsa_ok"], row
|
||||
assert row["readback"]
|
||||
if row["i"] == 0:
|
||||
assert row["reuse"] == [True, True, True]
|
||||
assert row["documented_anchor"] and row["upstream_anchor"]
|
||||
assert row["jhora_in_parent"] is False
|
||||
assert "radda" not in row["yoga_keys"]
|
||||
assert row["radda"]
|
||||
|
||||
|
||||
def test_parent_moon_and_ayanamsa_stay_put() -> None:
|
||||
script = r"""
|
||||
import json, sys
|
||||
sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"]
|
||||
import swisseph as swe
|
||||
from tests.report_english_fictional import fictional_case
|
||||
from scripts.report_render.annual_method_reference import build_pyjhora_annual_method_reference
|
||||
birth = fictional_case(0)
|
||||
class Args:
|
||||
pass
|
||||
args = Args()
|
||||
for key, value in birth.items():
|
||||
setattr(args, key, value)
|
||||
jd = swe.julday(2000, 1, 1, 0)
|
||||
before_ay = swe.get_ayanamsa_ut(jd)
|
||||
before_moon = swe.calc_ut(jd, swe.MOON)[0][0]
|
||||
reference = build_pyjhora_annual_method_reference(args, {})
|
||||
after_ay = swe.get_ayanamsa_ut(jd)
|
||||
after_moon = swe.calc_ut(jd, swe.MOON)[0][0]
|
||||
print(json.dumps({
|
||||
"same_ayanamsa": before_ay == after_ay,
|
||||
"same_moon": before_moon == after_moon,
|
||||
"jhora": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules),
|
||||
"years": len(reference.get("years") or []),
|
||||
"tables": sorted((reference.get("years") or [{}])[0].get("tables", {})),
|
||||
}))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, str(ROOT)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr[-1000:]
|
||||
payload = json.loads(completed.stdout.strip().splitlines()[-1])
|
||||
assert payload["same_ayanamsa"] and payload["same_moon"]
|
||||
assert payload["jhora"] is False
|
||||
assert payload["years"] == 3
|
||||
assert payload["tables"] == ["mudda_annual", "mudda_birth", "patyayini", "tajika"]
|
||||
|
||||
|
||||
def _args(index: int):
|
||||
birth = fictional_case(index)
|
||||
|
||||
class Args:
|
||||
pass
|
||||
|
||||
args = Args()
|
||||
for key, value in birth.items():
|
||||
setattr(args, key, value)
|
||||
return args
|
||||
|
||||
|
||||
def _shell(year: int) -> dict:
|
||||
return {
|
||||
"requested_year": year,
|
||||
"ayanamsa": "RAMAN",
|
||||
"ayanamsa_ok": False,
|
||||
"node_header": "mean",
|
||||
"patyayini_anchor": None,
|
||||
"tajika_anchor": None,
|
||||
"tables": {name: {"ok": False, "rows": []} for name in ("mudda_birth", "mudda_annual", "patyayini", "tajika")},
|
||||
}
|
||||
|
||||
|
||||
def _all_unavailable(reference: dict) -> bool:
|
||||
years = reference.get("years") or []
|
||||
return len(years) == 3 and all(
|
||||
all(not year["tables"][name]["ok"] for name in year["tables"])
|
||||
for year in years
|
||||
)
|
||||
|
||||
|
||||
def _h2(markdown: str) -> list[str]:
|
||||
return [line[3:].strip() for line in markdown.splitlines() if line.startswith("## ")]
|
||||
|
||||
|
||||
def test_rendered_english_chapter_has_no_han() -> None:
|
||||
text = render_annual_method_reference({
|
||||
"worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": {
|
||||
"years": [_shell(2026)],
|
||||
}}},
|
||||
}, "en")
|
||||
assert "Source: PyJHora" in text
|
||||
assert not any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||
@@ -57,7 +57,7 @@ def test_public_boundary_only_leaves_internal_renderer_and_json_unchanged():
|
||||
|
||||
packet = {"status": "parameter_sensitive"}
|
||||
engine = SimpleNamespace(
|
||||
build_professional_report_reference_packet=lambda *args: packet,
|
||||
build_professional_report_reference_packet=lambda *args, **_kwargs: packet,
|
||||
render_pl9_markdown=lambda value: "| parameter_sensitive | cmd_full_reading |",
|
||||
)
|
||||
handler = SimpleNamespace(
|
||||
|
||||
@@ -64,7 +64,7 @@ def test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean(
|
||||
|
||||
packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-reader.json").read_text(encoding="utf8"))
|
||||
raw = packet["markdown"]
|
||||
engine = SimpleNamespace(build_professional_report_reference_packet=lambda *args: packet, render_pl9_markdown=lambda value: raw)
|
||||
engine = SimpleNamespace(build_professional_report_reference_packet=lambda *args, **_kwargs: packet, render_pl9_markdown=lambda value: raw)
|
||||
handler = SimpleNamespace(_high_rigor_birth_payload=lambda body: {"year": 2000, "hour": 12, "minute": 0, "today": "2026-09-22"}, _compute_full_reading_for_thematic=lambda birth: {})
|
||||
for include in (False, True):
|
||||
result = build_professional_report_reference(handler, {"format": "markdown", "include_fact_tables": include}, engine=engine)
|
||||
|
||||
@@ -21,6 +21,7 @@ import pytest
|
||||
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
|
||||
from scripts.pl9_reader_english import HAN, han_leaks
|
||||
from scripts.pl9_reader_export import _dignity_label, _pl9_export_markdown_for_edition
|
||||
from scripts.report_render.annual_method_reference import strip_annual_method_chapter
|
||||
from scripts.professional_report_reference import (
|
||||
ProfessionalReportReferenceInputError,
|
||||
_export_args,
|
||||
@@ -91,7 +92,10 @@ def test_english_edition_has_no_chinese(rendered) -> None:
|
||||
|
||||
def test_english_edition_passes_reader_leak_patterns(rendered) -> None:
|
||||
_, _, en, _ = rendered
|
||||
assert _leak_hits(en) == []
|
||||
# 原值:英文全文泄漏词为零,包括 PyJHora
|
||||
# 新值:先去掉 Independent Annual Method Reference 再查
|
||||
# 原因:#93 这一章必须写 Source: PyJHora,清理器会删掉更早出现的同一词
|
||||
assert _leak_hits(strip_annual_method_chapter(en)) == []
|
||||
assert en.startswith("# Vedic Astrology Chart Report\n")
|
||||
for heading in ("## Birth Data and Charts", "## Strength, Relationships and Ashtakavarga",
|
||||
"## Standard Dasha Tables", "## Saturn and KP",
|
||||
@@ -148,7 +152,7 @@ def _api_response(packet: dict, body: dict) -> dict:
|
||||
return {"stub": True}
|
||||
|
||||
class Engine:
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs):
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs):
|
||||
return copy.deepcopy(packet)
|
||||
|
||||
return build_professional_report_reference(Handler(), body, engine=Engine())
|
||||
|
||||
@@ -15,6 +15,7 @@ from scripts.jyotish_engine import (
|
||||
render_pl9_markdown,
|
||||
)
|
||||
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
|
||||
from scripts.report_render.annual_method_reference import strip_annual_method_chapter
|
||||
from scripts.pl9_full_data_export import REPORT_VERSION_FULL
|
||||
from scripts.professional_report_reference import (
|
||||
ProfessionalReportReferenceInputError,
|
||||
@@ -115,7 +116,7 @@ def test_default_edition_stays_reference_and_full_data_requests_v4() -> None:
|
||||
return {"modules": {"chart": {}}}
|
||||
|
||||
class Engine:
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs):
|
||||
def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs):
|
||||
return {
|
||||
"schema": "pl9_style_professional_export_v1",
|
||||
"selected_report_pack_ids": packs,
|
||||
@@ -193,8 +194,21 @@ def test_reader_main_hides_audit_vocabulary(full_reading) -> None:
|
||||
reader = _pl9_export_markdown_for_edition(packet, "full_data")
|
||||
assert "结论等级规则" not in reference
|
||||
assert "This reference export" not in reference
|
||||
hits = _leak_hits(reader)
|
||||
# 原值:完整数据版全文 LEAK_PATTERNS(含 PyJHora)零命中
|
||||
# 新值:切掉「独立年度方法参考」后零命中;本章含「来源:PyJHora」,其余模式在本章内为零
|
||||
# 原因:D1、D2 要求表头注明来源,清理器会删掉更早出现的 PyJHora 行
|
||||
from scripts.report_render.annual_method_reference import CHAPTER_ZH, strip_annual_method_chapter
|
||||
|
||||
chapter = ""
|
||||
marker = f"\n## {CHAPTER_ZH}\n"
|
||||
if marker in reader:
|
||||
chapter = reader.split(marker, 1)[1]
|
||||
body = strip_annual_method_chapter(reader)
|
||||
hits = _leak_hits(body)
|
||||
assert hits == [], hits
|
||||
assert "来源:PyJHora" in chapter
|
||||
chapter_hits = [pattern.pattern for pattern in LEAK_PATTERNS if pattern.pattern != "PyJHora" and pattern.search(chapter)]
|
||||
assert chapter_hits == [], chapter_hits
|
||||
for chapter in CORE_CHAPTERS:
|
||||
assert chapter in reader, chapter
|
||||
assert reader.strip().startswith("#")
|
||||
@@ -307,7 +321,10 @@ def test_reader_annual_cells_use_real_distinct_years_and_keep_return_dates(reade
|
||||
return_time = annual["solar_return"]["data"]["dt_local"]
|
||||
section = body.split(f"### {year} 年度 Varshaphala / Tajika 细项", 1)[1].split("### ", 1)[0]
|
||||
assert f"| Varshapravesha | {return_time} |" in section
|
||||
assert _leak_hits(reader) == []
|
||||
# 原值:全文泄漏词为零,包括 PyJHora
|
||||
# 新值:先去掉「独立年度方法参考」再查
|
||||
# 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词
|
||||
assert _leak_hits(strip_annual_method_chapter(reader)) == []
|
||||
|
||||
|
||||
def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) -> None:
|
||||
@@ -334,7 +351,10 @@ def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) -
|
||||
# 新值:| Muntha 星座 | 白羊座 |
|
||||
# 原因:完整数据版中文清理把 sign 写成「星座」;星座值仍是当年真实 Muntha
|
||||
assert "| Muntha 星座 | 白羊座 |" in reader
|
||||
assert _leak_hits(reader) == []
|
||||
# 原值:全文泄漏词为零,包括 PyJHora
|
||||
# 新值:先去掉「独立年度方法参考」再查
|
||||
# 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词
|
||||
assert _leak_hits(strip_annual_method_chapter(reader)) == []
|
||||
|
||||
|
||||
def test_reader_omits_missing_name_and_panchanga_machine_keys(reader_packet) -> None:
|
||||
@@ -375,4 +395,7 @@ def test_reader_headings_drop_field_jargon_and_dignity_is_one_name(reader_packet
|
||||
assert _dignity_label("极敌(Great Enemy)") == "极敌"
|
||||
assert _dignity_label("中性") == "中性"
|
||||
assert _dignity_label(None) == ""
|
||||
assert _leak_hits(reader) == []
|
||||
# 原值:全文泄漏词为零,包括 PyJHora
|
||||
# 新值:先去掉「独立年度方法参考」再查
|
||||
# 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词
|
||||
assert _leak_hits(strip_annual_method_chapter(reader)) == []
|
||||
|
||||
@@ -265,7 +265,7 @@ def cached_report(monkeypatch, tmp_path, day_views):
|
||||
handler = _CountingHandler(birth, packet)
|
||||
|
||||
class _Engine:
|
||||
def build_professional_report_reference_packet(self, _reading, _args, _packs):
|
||||
def build_professional_report_reference_packet(self, _reading, _args, _packs, **_kwargs):
|
||||
return copy.deepcopy(packet)
|
||||
|
||||
def render_pl9_markdown(self, _packet):
|
||||
|
||||
@@ -19,6 +19,9 @@ ENTRIES = (
|
||||
"scripts/professional_report_reference.py",
|
||||
"scripts/consultation_workflow_service.py",
|
||||
"scripts/consultation_native_layers.py",
|
||||
"scripts/unified_consultation_orchestrator.py",
|
||||
"scripts/rectification_three_engine_packet.py",
|
||||
"scripts/report_render/annual_method_reference.py",
|
||||
)
|
||||
SELF = "tests/test_runtime_jhora_import_scan.py"
|
||||
|
||||
@@ -162,9 +165,27 @@ def test_product_entries_do_not_import_jhora():
|
||||
"scripts/native_special_lagnas.py",
|
||||
"scripts/tajika.py",
|
||||
"scripts/pl9_reader_export.py",
|
||||
"scripts/unified_consultation_orchestrator.py",
|
||||
"scripts/rectification_three_engine_packet.py",
|
||||
"scripts/report_render/annual_method_reference.py",
|
||||
):
|
||||
assert required in visited
|
||||
assert all("pyjhora" not in path and "/research/" not in path for path in visited)
|
||||
worker = "pyjhora_annual_reference_worker"
|
||||
for path in visited:
|
||||
tree = ast.parse((ROOT / path).read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
names = [alias.name for alias in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
names = [node.module or ""]
|
||||
else:
|
||||
continue
|
||||
assert all(worker not in name for name in names), path
|
||||
source = (ROOT / "scripts/report_render/annual_method_reference.py").read_text(encoding="utf-8")
|
||||
assert f'"-m", {worker.upper()}' not in source
|
||||
assert '"-m", WORKER_MODULE' in source
|
||||
assert "pyjhora_annual_reference_worker" in source
|
||||
|
||||
|
||||
def test_scanner_is_on_the_quick_gate():
|
||||
|
||||
@@ -338,10 +338,10 @@ def test_runtime_evidence_log_exposes_blind_packet_case_and_quality_gate_contrac
|
||||
assert engines["VedAstro"]["status"] == "local_fallback"
|
||||
assert engines["PyJHora/JHora"]["status"] == "reference_available_not_runtime_invoked"
|
||||
assert engines["PyJHora/JHora"]["adapter_command"] == "python3 benchmarks/jyotish/scripts/run_pyjhora_compare.py"
|
||||
assert engines["PyJHora/JHora"]["adapter_status"] in {
|
||||
"available",
|
||||
"blocked_missing_python_module:jhora",
|
||||
}
|
||||
# 原值:adapter_status 可以是 available 或 blocked_missing_python_module:jhora
|
||||
# 新值:只有 blocked_missing_python_module:jhora
|
||||
# 原因:#93 产品路径不再因为装了 PyJHora 就改成 available
|
||||
assert engines["PyJHora/JHora"]["adapter_status"] == "blocked_missing_python_module:jhora"
|
||||
assert engines["jyotishganit"]["status"] == "reference_available_not_runtime_invoked"
|
||||
assert engines["jyotishganit"]["adapter_path"] == "references/open_source_sources/jyotishganit"
|
||||
assert engines["jyotishganit"]["adapter_status"] == "available"
|
||||
|
||||
Reference in New Issue
Block a user