Compare commits

...
Author SHA1 Message Date
jesse-ux 2a1f5f0fc9 fix(report): use the upstream reader edition for new longform bodies
New reports request reader_main from upstream origin/main 23b9609e.
KP and transit no longer copy an empty vars() dict, solar returns keep
birth_asc_sign_idx, and ordinary projection deletes internal lines whole.
2026-09-25 02:19:32 +08:00
22 changed files with 3126 additions and 65 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-25 — 新报告正文改为读者版
- 新生成的个人长报告正文改为上游读者版,不再把专业参考/审计版当正文,也不再提供那条用户入口。已经生成的报告不重算。
- 读者版去掉结论等级规则、英文口径段、异常原文、页码和内部标识。KP 与三项过境在网页参数下能算出;年度 Muntha 使用出生上升,年主不再固定成火星。
- 报告版本 `pl9_personal_long_report.v3`。Skill 版本不 bump:没有改 Skill 包、提示词或技法口径,只改报告正文从哪一版渲染。
- 真人核对见 `docs/testing/report-reader-main-20260924.md`。本条尚未部署。
## 2026-09-24 — 报告操作收敛与桌面导出居中(本地未提交,依赖验收 blocked)
- 报告列表已完成行只保留「查看报告」;阅读页仅保留返回和导出图标,不再自动消费旧 `#export` 链接。
+48 -1
View File
@@ -13598,8 +13598,55 @@
- 现象 / 触发:真实引擎虚构golden仅将LF换成CRLF,正文下载残留22段图盘JSON;新分盘组仍可提取22盘。
- 根因:report-chart-block.ts的FENCE_RE在jyotish-chart语言标记后仅匹配LF,stripReportChartBlocks共用它;旧测试验证LF剥除和CRLF字节等价,没有覆盖含真实围栏的CRLF无JSON。
- 修复:按产品D14有限例外,将语言标记及围栏尾部边界改为匹配`\\r?\\n`;不标准化整篇换行,不改SVG、投影、生成链或下载器。
- 验证:真实虚构reader golden的LF、CRLF、混合换行均输出0段`jyotish-chart`围栏、22个SVG且`ordinaryOutputLeaks`为空;LF普通导出SHA256固定为`3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8`。定向测试已通过;尚无部署health证据。
- 验证:真实虚构reader golden的LF、CRLF、混合换行均输出0段`jyotish-chart`围栏、22个SVG且`ordinaryOutputLeaks`为空;当时 LF 普通导出 SHA256 为 `3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8`。定向测试已通过;尚无部署health证据。
- 2026-09-25:BUG-1026 把内部词从截断改成整行删除,上述 LF 哈希不再代表当前投影。围栏与 22 个 SVG 的合同仍在。新哈希见 BUG-1026。
- 防复发:report-chart-block与report-export-blocks定向回归覆盖真实golden CRLF/混合输入及独立LF hash锁;禁止只比较两个共同修改后的helper而声称历史输出不变。
- 相关记录:BUG-999/1003;TASK-report-chapter-export-fix-20260924.md。
- 复发自:BUG-999同输出边界的未覆盖换行路径,继承剥离器缺陷,非本轮新增回归。
- 修复版本:本地待提交。
## BUG-1026 | 报告正文仍是审计版,投影把内部句截成半句
- 状态:investigating(本分支已改,未部署)
- 首次发现 / 最近更新:2026-09-24 / 2026-09-25
- 影响面:新个人长报告正文、普通投影、旧报告下载后的可见正文。已生成的 Markdown 不回填。
- 用户现象:正文出现「结论等级规则」、英文 boundary、「producer failed」和异常原文、`full-report://`、页码和「…所有行保持」这种半句,以及删表后留下的说明段。
- 触发条件:生成或打开个人长报告。正文走专业参考渲染;普通投影只拦聊天键,并在 `parameter_sensitive` 处截断句子。
- 根因:本仓最后一次引擎同步 `f2241463`(2026-09-03)早于上游读者版 `f3b5196f`(2026-09-09)。`cfcd369d` 把参考/审计版设成正文。本仓又自行加入结论等级规则、英文 boundary,以及把 error/reason 打进不可用小节。投影的 `projectProseLine` 保留内部词之前的半句,`tableIsInternal` 只删表。
- 修复:新报告请求 `edition: "reader_main"`。读者版从上游 `origin/main` `23b9609e`(含 `f3b5196f`)整段引入 `scripts/pl9_reader_export.py`。缺省 edition 仍是参考版。参考版也去掉上述三处自加泄漏;数据不可用的节整节不输出。投影遇内部词删整行,删表时连同紧邻说明段。报告版本 `pl9_personal_long_report.v3`。旧报告不迁移、不重算。
- 验证:虚构盘「虚构甲」1991-04-07 09:15 的真实引擎读者版 `ordinaryOutputLeaks` 为 0,并含出生资料、Ashtakavarga、标准大运、KP、Year Lord、Tajika、Muntha。定向 Python 与前端投影测试见 PROGRESS。没有登录浏览器,未声称生成过真实用户报告。
- 防复发:读者版 golden 与 `ordinaryOutputLeaks` 不得弱化;新增长报告请求体必须带 `edition: "reader_main"`。旧 `report-density-fictional-reader.json` 继续锁旧报告。投影 sha 改动须写明原值锁的是泄漏输出。
- 相关记录:BUG-999、BUG-1003、BUG-1009、BUG-1020、BUG-1027、BUG-1028
- 复发自:BUG-999。当时的测试只覆盖聊天导出键(`technique_truth`、`workflow_route` 等)和一份已经泄漏的参考版 sha,没有读者版正文,也不认识 PL9 审计词;截断被 sha 锁成了正确输出。BUG-1003 只清附录,BUG-1009 只改事实表列名。
- 修复版本:本分支,未推 staging,未部署。
- sha 三栏:普通投影 `01cc1e03c5adca2833e014156e1162fff12e5cb336e4484c5871859cd878acd7` → `f3aeb8f4b09ba0a78af4ab5e7aee4db32d7499b8e6b1488f2925b35fd91750b9`;LF 下载 `3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8` → `b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88`。原值锁的是截断后的泄漏正文。
## BUG-1027 | KP 与三项 transit 因空实例字典拷贝失败
- 状态:investigating(本分支已改,未部署)
- 首次发现 / 最近更新:2026-09-24 / 2026-09-25
- 影响面:网页全读路径里的 KP,以及 double transit、transit LL7L、行星聚集。CLI 的 argparse 实例不受影响。
- 用户现象:KP Lord / Sub 节为 blocked,原文是 `'types.SimpleNamespace' object has no attribute 'year'`。
- 触发条件:`_compute_full_reading_for_thematic` 用 `type('Args', (), {...})()` 把字段放在类上。`cmd_kp` 和 transit 执行 `SimpleNamespace(**vars(args))`,实例字典为空,随后读 `.year` 失败。
- 根因:`vars()` 不复制类属性。上游 `origin/main` `23b9609e` 的 API 同样用这种 Args,引擎同样用 `vars()`,空结果时不把异常打进读者正文,所以缺陷一直潜伏。
- 修复:`_copy_call_namespace` 同时读取实例字典和类属性。不改岁差、宫制或 KP 算法。API 主文件未改,类方法数不增加。
- 验证:`tests/test_report_reader_main.py` 用同样的空实例 Args 调用 `cmd_full_reading`,KP 与三项 transit 的 status 不是 blocked,文本里没有 `has no attribute`。
- 防复发:该类 Args 的回归留在上述测试。不要再写 `SimpleNamespace(**vars(args))`。
- 相关记录:BUG-561、BUG-1026
- 复发自:无。这是网页参数构造和 `vars()` 的潜伏缺陷,不是 BUG-561 的渲染分支。
- 修复版本:本分支,未推 staging,未部署。
## BUG-1028 | 太阳返照漏了出生上升,年主恒为火星
- 状态:investigating(本分支已改,未部署)
- 首次发现 / 最近更新:2026-09-24 / 2026-09-25
- 影响面:Muntha、Year Lord、年度章里的冲突句。
- 用户现象:Muntha 为 blocked;Year Lord 恒为火星 / 白羊座;年度句出现「当前优先候选指向conflict between vs Capricorn」。
- 触发条件:`solar_return_full_report` 读取 `birth_asc_sign_idx`,本仓 `calc_solar_return_chart` 的成功字典没有这个键。冲突门用整份字典比较,blocked 与正常值必判冲突;空的左侧对不上冲突正则,落到普通句。
- 根因:`f2241463` 同步上游时漏了 `scripts/solar_return.py` 里设置 `birth_asc_sign_idx` 的那一行。上游当前 `origin/main` 在返照字典里写入该键。本仓冲突门另外比了整份字典,上游已改成只比星座和主星,但仍会把 blocked 字典算进去;本单按 D4 在一侧 `status == blocked` 时不判冲突。
- 修复:成功的返照结果写回 `birth_asc_sign_idx`。Muntha 没有星座序号时 Year Lord 标 blocked,不再默认 0(火星)。冲突门跳过 blocked 一侧,只比较星座与主星。
- 验证:虚构盘 1991-04-07 的 2024/2026/2028 返照都有出生上升序号,Muntha 非 blocked,Year Lord 至少两年不同。一侧 blocked 的 Muntha 不再生成 conflict between。
- 防复发:`tests/test_report_reader_main.py` 锁住出生上升、两年不同的年主,以及 blocked 不冲突。不要在同步时只合一半太阳返照返回值。
- 相关记录:BUG-1026;同步提交 `f2241463`
- 复发自:无。这是同步漏合,不是旧 Muntha 测试覆盖过的路径。
- 修复版本:本分支,未推 staging,未部署。
+12
View File
@@ -2,6 +2,12 @@
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
## 2026-09-25 · 读者版同步预检与上游 tip
- 工作树 `codex/report-reader-main-20260924`,基线 `514951d7`。Python 3.11.7(Anaconda)跑通 `pre_work_check.py`:远端 verified,碎片扫描与适配器诊断成功。focused tests 22 通过 / 1 失败,仍是 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径断言。没有制造镜像目录,预检整体 fail,不记为通过。
- 任务书写上游 tip `19195f6e`。`git -C G:\Ferti\yinduzhanxing fetch origin` 后 `origin/main` 是 `23b9609e330dbee70a4725bdc222b125d9b0e39f`,且包含读者版引入提交 `f3b5196f`。读者版按这个 tip 引入,不用 `.workbuddy`。
- 新的同步漏合见 ERR-109。
## 2026-09-24 · 星盘与报告组合版本推送预检
- 产品在获知CRLF合同冲突及构建/真机缺口后授权push到staging。fetch成功,HEAD与origin/staging均为5ff43b47f;写入成功仍须push与ls-remote另核。
@@ -373,3 +379,9 @@ Prevention: 不得把 `vedastro` 当作本地计算能力来源,也不得用
一次 official full snapshot 的请求清单实跑 `_official_full_snapshot_manifest` 数出来是 24 个(`events_overview` 1 + `dasha_all` 1 + `chart_core` 10 + `house_core` 12),fanout 开关 `VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED` 的代码默认值就是 `"1"`(第 2520 行)。24 ÷ 5 个每分钟 ≈ 4.8 分钟,期间该进程所有 VedAstro 调用被同一把锁串起来;生产是 2 vCPU 单进程。参照延迟:本机单次 `api.vedastro.org` 往返 0.38 秒,快速模式(fanout 关)一次完整 snapshot 3.65 秒 `status=ok`。生产在国内 VPS,真实 RTT 未测,上述数字只是下限。
Prevention: 前台同步路径不得进入无上限排队,超预算即 fail-fast 降级并让调用方看出是限流而非「服务不可用」;排队总预算必须可配且默认不超过 `VEDASTRO_TIMEOUT_SECONDS`。不得靠调大免费层配额绕过——那是第三方的额度。判断「VedAstro 慢」之前先确认当前是不是无 key 的免费公共模式,以及 fanout 是否开着;两者都属于运行期 env 真相,不能从代码默认值推定。关联 BUG-065、BUG-161、BUG-718、BUG-720。
## ERR-109 | 上游读者版与太阳返照出生上升在同步时漏合 | observed 2026-09-25
本仓引擎同步停在 `f2241463`(2026-09-03)。上游 `f3b5196f`(2026-09-09)才引入 `reader_main`,`origin/main` 在 2026-09-25 fetch 后是 `23b9609e`,不是任务书写的 `19195f6e`。漏掉的还有 `calc_solar_return_chart` 返回值里的 `birth_asc_sign_idx`(上游 solar_return 成功字典会写这个键)。网页全读用 `type('Args', (), fields)()`,上游同样用 `SimpleNamespace(**vars(args))`,实例字典为空时 KP / transit 读不到 `year`;上游空结果不写进读者正文,所以这个缺陷在上游也潜伏。
Prevention: 引擎/上游同步类任务必须 fetch 后核对 `origin/main`,以实际 SHA 为准,并在进度和本台账写明与任务书 tip 的差异。读者版整段引入要带来源提交。不要把 `.workbuddy` 当主仓。同步太阳返照时核对返回键,而不只核对函数还在。关联 BUG-1026、BUG-1027、BUG-1028。
@@ -0,0 +1,46 @@
# PROGRESS · 报告正文换成读者版(2026-09-25)
## 结论
T1–T7 已在 `codex/report-reader-main-20260924` 完成。没有推 `staging`。没有登录浏览器,没有声称生成过真实用户报告。
## 基线与预检
- 分支 `codex/report-reader-main-20260924`,开工 HEAD `514951d7`,跟踪 `origin/staging`。
- 预检:Anaconda Python 3.11.7 运行 `scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`。远端 `verified`,碎片扫描和适配器诊断成功。focused tests 失败 1 条:`.workbuddy/skills/jyotish-vedic-astrology` 镜像路径不存在。没有制造该目录。预检整体 **fail**,不记通过。这是台账里已有的镜像断言,不是新类错误。
- 上游:`git -C G:\Ferti\yinduzhanxing fetch origin` 成功。`origin/main` 实际是 `23b9609e330dbee70a4725bdc222b125d9b0e39f`,不是任务书的 `19195f6e`。`f3b5196f` 是其祖先。读者版按 `23b9609e` 引入。没有改上游工作区。
## 任务
| 项 | 结果 |
| --- | --- |
| T1 读者版 | 完成。`scripts/pl9_reader_export.py` 整段引入 `_render_pl9_user_markdown`、`render_pl9_parity_markdown` 及其读者版闭包。缺省 edition 仍是参考版。`pl9_ai_density` 依赖不在读者版闭包里,按让步 1 未引入。 |
| T2 生成链 | 完成。`personal-report-longform-generate.ts` 请求 `edition: "reader_main"`。版本 `pl9_personal_long_report.v3`。 |
| T3 自加泄漏 | 完成。结论等级规则、英文 boundary、不可用节的原始 error 都不再进入参考版。空数据节整节不输出。 |
| T4 KP / transit | 完成。`_copy_call_namespace` 不再依赖空的 `vars()`。虚构盘 `cmd_full_reading` 上 KP 与三项 transit 不是 blocked。 |
| T5 Muntha / Year Lord | 完成。返照结果写回 `birth_asc_sign_idx`。2024/2026/2028 的 Muntha 与 Year Lord 不恒定。一侧 blocked 不判冲突。 |
| T6 投影 | 完成。内部词删整行;删表连同紧邻说明。新增 PL9 词进入 `ordinaryOutputLeaks`。英文整段用连续 12 个英文单词,避免误伤行星名。读者版 fixture 命中为 0。 |
| T7 记录 | 完成。BUG-1026/1027/1028 仍空,已使用。1023–1025、1030 未占用。 |
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `personal-report-density-retry.test.ts` 普通投影 sha | `01cc1e03c5adca2833e014156e1162fff12e5cb336e4484c5871859cd878acd7` | `f3aeb8f4b09ba0a78af4ab5e7aee4db32d7499b8e6b1488f2925b35fd91750b9` | 原 sha 锁的是截断后的泄漏正文 |
| `report-export-blocks.test.ts` LF 下载 sha | `3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8` | `b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88` | 同上,BUG-1020 当时锁的是投影前的泄漏输出 |
| `test_report_longform_gaps2.py` 空 KP | 必须出现 KP 原始表标题和 `kp_lord_sub_unavailable` | 整节不出现,且没有 `synthetic_kp_failure` | D3。BUG-561 的 blocked 标题把原始 error 打进了正文 |
| `test_report_longform_parity.py` 导航 | 必须含「结论等级规则」 | 该标题不出现 | D3 |
## 验证
- Python:`pytest tests/test_report_reader_main.py tests/test_annual_tajika_pack.py tests/test_professional_report_reference_api.py tests/test_api_server_growth_contract.py tests/test_report_longform_parity.py tests/test_report_longform_gaps2.py tests/test_repo_privacy_markers.py` 通过。`jyotish_api_server.py` 增长合同仍绿(未改该文件)。
- 前端:`tsc --noEmit` 0 错。`npm run lint` 0 error、121 warnings,均不在本单文件;没有为消 warning 改业务代码。点名测试:投影、导出哈希、读者版 fixture、`edition: "reader_main"` 通过。
- 快速门:`scripts/run_quality_gate.py --profile quick` 退出 1。编译、JSON、能力/碎片审计、BPHS 18/18,以及门禁里的 Python pytest 名单跑完后进入 `npm test`。`npm test` 在本机失败:Skill 运行时 symlink `EPERM`,以及 Windows 把迁移 symlink 收成普通文件后的 `duplicate migration filename`。这是已知环境缺口,不是本单断言。不把快速门写成通过。没有单独再跑全量 `npm test`,也没有跑 `next build`。
- Skill 版本不 bump。报告版本升到 v3。
## 没做
- 未推 staging,未改 workflow,未动 DNS。
- 未做登录浏览器验收。清单在 `docs/testing/report-reader-main-20260924.md`。
- 未引入 `pl9_ai_density` 附录。
- 旧报告不回填。
+1 -1
View File
@@ -174,7 +174,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-report-reader-main-20260924.md` | — | **报告正文换上游读者版 reader_main + KP / Muntha 计算缺陷 + 投影截断(BUG-1026/1027/1028)**:正文一直是上游审计版(读者版 09-09 才出、从未同步),本仓又自加「结论等级规则」/英文段/异常原文;投影黑名单不认 PL9 词汇且截断半句、删表留说明;KP args `vars()` 拷贝为空恒失败;Muntha 漏合 `birth_asc_sign_idx` → Year Lord 恒火星。产品拍板接入读者版、旧报告不回填。引擎类需 pre_work_check;排在 BUG-1020 fix 之后 | 待领取 | — |
| `TASK-report-reader-main-20260924.md` | `PROGRESS-report-reader-main-20260924.md` | **报告正文换上游读者版 reader_main + KP / Muntha 计算缺陷 + 投影截断(BUG-1026/1027/1028)**:正文一直是上游审计版(读者版 09-09 才出、从未同步),本仓又自加「结论等级规则」/英文段/异常原文;投影黑名单不认 PL9 词汇且截断半句、删表留说明;KP args `vars()` 拷贝为空恒失败;Muntha 漏合 `birth_asc_sign_idx` → Year Lord 恒火星。产品拍板接入读者版、旧报告不回填。 | 待验收 | `codex/report-reader-main-20260924` |
| `TASK-report-reader-actions-20260924.md` | — | **报告操作收敛**:列表只留「查看报告」;阅读页删「下载原始附录」「打印」,导出改图标按钮;导出对话框 ≥860 居中、窄屏仍贴底(修订 chapter-export D9/D10)。产品变更,不开 BUG 号 | 基本通过(Claude 09-24:入口收敛、图标、≥860 居中均符合;原始附录三文件因执行方权限未删、PROGRESS/清单未写 → 并入修复单) | `f125fae0`(未部署) |
| `TASK-report-chapter-export-fix-20260924.md` | `PROGRESS-report-chapter-export-20260924.md` | **CRLF图盘JSON残留(BUG-1020)**:旧剥离器只匹配LF;建议D5对错误围栏残留作有限例外,D13不构成该授权。 | 代码已验收(Claude 09-24),待部署(被同批门禁红阻塞) | `fa8fef55`/`133aca49`(未部署) |
| `TASK-report-chapter-export-20260924.md` | `PROGRESS-report-chapter-export-20260924.md` | **我的报告:分块导出 + 中文元信息 / 状态色 / 行内确认删除(BUG-1018/1019)**。旧整篇按钮由抽屉取代;常用 / 全部正文保持 D5 逐字节,所有组全选另附盘和核对表。后续产品裁决 D13:分盘必须带安全 SVG,有限替代原无 SVG 约束;JSON 围栏及内部字段仍禁。原生底部 dialog、760px、70%、860 两列、零额外请求、保留打印 / 原始附录。 | 已验收(Claude 09-24:同上;D13 SVG 与列表页删导出按钮均按产品口径;BUG-1018/1019 resolved;CRLF 见 fix 单) | `1420471a`(staging 已部署);真机清单欠 |
@@ -0,0 +1,10 @@
# 读者版正文真人核对(2026-09-25)
没有受控登录浏览器。下面各项不能写成已经通过。请用一个明确虚构或公开的出生时间,重新生成一份报告后再看。不要把旧报告当成新正文。
1. 打开新报告正文。不应出现「结论等级规则」、整段英文说明、「This reference export」、`producer failed`、`Traceback`、`object has no attribute`、`full-report://`、`blocked`、`PL9 第 N 页`。
2. 正文应能读到出生资料、力量 / Ashtakavarga、标准大运、KP、年度 Tajika。没有数据的小节应整节不出现,而不是一行报错。
3. 看两个不同年份的年度章。Muntha 星座应不同;Year Lord 至少有两年不一样,不应每年都是火星 / 白羊座,也不应出现「conflict between vs」。
4. KP 节应存在,不应是 `SimpleNamespace` 缺少 `year` 的失败句。
5. 下载普通 Markdown。不应留下「所有行保持」这种半句,也不应留下孤立的「- `」。图盘仍是安全 SVG,不是 JSON 围栏。
6. 打开一份这次改动之前生成的旧报告。正文应保持原样,只是下载投影比以前少一些内部词。不要求它变成读者版。
@@ -148,7 +148,7 @@ async function fetchLongformMarkdown(input: Readonly<{
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...input.payload, include_fact_tables: true }),
body: JSON.stringify({ ...input.payload, include_fact_tables: true, edition: "reader_main" }),
cache: "no-store",
signal,
});
+44 -9
View File
@@ -184,6 +184,20 @@ export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [
/<\s*object\b/i,
/<\s*embed\b/i,
/<\s*img\b/i,
/\bblocked\b/i,
/\bexecuted\b/i,
/\bpartial_verified\b/i,
/\bmissing_in_local\b/i,
/\bproducer\b/i,
/\bparity\b/i,
/PL9\s*第\s*\d+\s*页/,
/full-report:\/\//,
/\bpl9_[A-Za-z0-9_]+\b/i,
/\b[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*_unavailable\b/,
/\b[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*_(?:pack|packet)\b/i,
/object has no attribute/,
/\bTraceback\b/,
/(?:\b[A-Za-z]{2,}(?:'[A-Za-z]+)?\b\s+){11}\b[A-Za-z]{2,}(?:'[A-Za-z]+)?\b/,
];
export function ordinaryOutputLeaks(text: string): string[] {
@@ -319,15 +333,40 @@ function limitationsFromSignals(input: ChatExportInput): string[] {
return notes;
}
function adjacentInternalTableIndexes(blocks: Block[]): Set<number> {
const drop = new Set<number>();
blocks.forEach((block, index) => {
if (block.type !== "table" || !tableIsInternal(block.text)) return;
drop.add(index);
let cursor = index - 1;
while (cursor >= 0 && blocks[cursor]?.type === "blank") cursor -= 1;
if (cursor >= 0 && (blocks[cursor]?.type === "paragraph" || blocks[cursor]?.type === "list")) {
drop.add(cursor);
cursor -= 1;
while (cursor >= 0 && blocks[cursor]?.type === "blank") cursor -= 1;
}
if (cursor >= 0 && blocks[cursor]?.type === "heading") drop.add(cursor);
});
return drop;
}
function analyzeOrdinaryReportMarkdown(markdown: string): Analysis {
if (!markdown) return { prose: markdown, limitations: [], changed: false };
const blocks = parseBlocks(markdown);
const dropWithTable = adjacentInternalTableIndexes(blocks);
const limitations = new Set<string>();
const kept: string[] = [];
let changed = false;
let skipUntilLevel: number | null = null;
for (const block of blocks) {
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index];
if (!block) continue;
if (dropWithTable.has(index)) {
changed = true;
collectLimitations(block.raw, limitations);
continue;
}
if (block.type === "heading") {
const level = block.level ?? 1;
if (skipUntilLevel !== null && level <= skipUntilLevel) skipUntilLevel = null;
@@ -419,15 +458,11 @@ function projectProseLine(line: string): { keep: boolean; text: string; limitati
if (field && isInternalKey(field[1])) {
return { keep: false, text: "", limitations: notesForField(field[1], field[2] ?? "") };
}
const tokenAt = line.search(INTERNAL_KEY_TOKEN);
const limitations = tokenAt >= 0 ? notesFromText(line.slice(tokenAt)) : [];
const visible = tokenAt >= 0 ? line.slice(0, tokenAt) : line;
const cleaned = projectInline(visible);
if (!cleaned.trim()) return { keep: false, text: "", limitations };
if (containsInternalToken(cleaned) || ordinaryOutputLeaks(cleaned).length > 0) {
return { keep: false, text: "", limitations };
const cleaned = projectInline(line);
if (!cleaned.trim() || containsInternalToken(cleaned) || ordinaryOutputLeaks(cleaned).length > 0) {
return { keep: false, text: "", limitations: notesFromText(line) };
}
return { keep: true, text: cleaned, limitations };
return { keep: true, text: cleaned, limitations: [] };
}
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
File diff suppressed because one or more lines are too long
@@ -15,8 +15,10 @@ const golden = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictio
test("density full engine reader cleanup removes embedded product contracts without losing rows or numeric fields", () => {
assert.equal(golden.fixtureProvenance.fictional, true);
const raw = golden.markdown as string;
// Captured by running baeec66f8's actual projector on this complete fictional engine Markdown.
assert.equal(createHash("sha256").update(projectOrdinaryReportMarkdown(raw)).digest("hex"), "01cc1e03c5adca2833e014156e1162fff12e5cb336e4484c5871859cd878acd7");
// 原值 01cc1e03c5adca2833e014156e1162fff12e5cb336e4484c5871859cd878acd7
// 新值 f3aeb8f4b09ba0a78af4ab5e7aee4db32d7499b8e6b1488f2925b35fd91750b9
// 原因:原 sha 锁的是投影截断后的泄漏输出(半句与孤立标记仍在)。整行删除 PL9 内部词后正文更短。
assert.equal(createHash("sha256").update(projectOrdinaryReportMarkdown(raw)).digest("hex"), "f3aeb8f4b09ba0a78af4ab5e7aee4db32d7499b8e6b1488f2925b35fd91750b9");
assert.match(raw, /pl9_personal_long_report\.v2/);
const clean = cleanReaderAppendixMarkdown(raw);
assert.doesNotMatch(clean, /pl9/i);
@@ -101,6 +101,15 @@ function executeReadyReportExport(cached: boolean) {
};
}
test("new longform generation requests the reader_main edition", () => {
const generateSource = readFileSync(
new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url),
"utf8",
);
assert.match(generateSource, /edition:\s*"reader_main"/);
assert.match(generateSource, /include_fact_tables:\s*true/);
});
test("professional reference route authenticates, checks origin, owner and ready status", () => {
assert.match(routeSource, /createServerSupabaseClient/);
assert.match(routeSource, /supabase\.auth\.getUser\(\)/);
+4 -1
View File
@@ -58,7 +58,10 @@ test("full golden body is byte identical to the retired ordinary export", () =>
test("LF golden ordinary export keeps its frozen output hash", () => {
const output = ordinaryReportDownloadMarkdown(fixture.markdown);
assert.equal(createHash("sha256").update(output).digest("hex"), "3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8");
// 原值 3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8
// 新值 b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88
// 原因:原 sha 锁的是同一份泄漏正文的普通下载。BUG-1026 起内部词整行删除,不再截成半句。
assert.equal(createHash("sha256").update(output).digest("hex"), "b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88");
});
test("CRLF and mixed-newline golden exports remove every JSON chart fence", () => {
@@ -301,6 +301,70 @@ test("markdown view and chart fence contracts are not weakened", () => {
assert.match(coreSource, /projectOrdinaryReportDocument/);
});
test("PL9 audit vocabulary deletes the whole line and the adjacent table caption", () => {
const source = [
"# 读者报告",
"",
"这一句保留。",
"未完成对照,所有行保持 parameter_sensitive。",
"",
"### KP 原始表",
"",
"本节只解释下面这张表。",
"",
"| 项 | 状态 |",
"| --- | --- |",
"| annual_tajika_pack | blocked |",
"",
"表后的正文还在。",
"KP producer failed: 'types.SimpleNamespace' object has no attribute 'year'",
"Traceback (most recent call last):",
"full-report://profile/hash",
"PL9 第 42 页 fixture",
"pl9_personal_long_report.v2",
"kp_lord_sub_unavailable",
].join("\n");
const projected = projectOrdinaryReportMarkdown(source);
assert.match(projected, /这一句保留/);
assert.match(projected, /表后的正文还在/);
assert.doesNotMatch(projected, /所有行保持/);
assert.doesNotMatch(projected, /parameter_sensitive/);
assert.doesNotMatch(projected, /KP 原始表/);
assert.doesNotMatch(projected, /本节只解释下面这张表/);
assert.doesNotMatch(projected, /annual_tajika_pack/);
assert.doesNotMatch(projected, /^- `$/m);
assert.doesNotMatch(projected, /object has no attribute|Traceback|full-report:\/\/|PL9 第 42 页|pl9_personal_long_report|_unavailable/);
assert.deepEqual(ordinaryOutputLeaks(projected), []);
});
test("real reader_main fixture has no ordinary output leaks", () => {
const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8"));
assert.equal(fixture.fixtureProvenance.fictional, true);
assert.equal(fixture.fixtureProvenance.edition, "reader_main");
const markdown = fixture.markdown as string;
assert.equal(ordinaryOutputLeaks(markdown).length, 0, ordinaryOutputLeaks(markdown).join(", "));
for (const chapter of ["出生资料", "Ashtakavarga", "标准大运", "KP", "Year Lord", "Tajika", "Muntha"]) {
assert.match(markdown, new RegExp(chapter));
}
const projected = projectOrdinaryReportMarkdown(markdown);
assert.equal(ordinaryOutputLeaks(projected).length, 0);
});
test("twelve English words count as a paragraph leak, planet names do not", () => {
const planet = projectOrdinaryReportMarkdown("太阳与 Mars 同宫,再看 Venus。");
assert.match(planet, /Mars/);
assert.match(planet, /Venus/);
const english = [
"This reference export combines local calculations with available external engine",
"replays and source comparisons that stay explicitly labeled.",
].join(" ");
const projected = projectOrdinaryReportMarkdown(`开头。\n${english}\n结尾。`);
assert.match(projected, /开头/);
assert.match(projected, /结尾/);
assert.doesNotMatch(projected, /This reference export/);
assert.deepEqual(ordinaryOutputLeaks(projected), []);
});
function syntheticRecord(): PersonalReportRecord {
return {
id: "11111111-1111-4111-8111-111111111111",
+32 -3
View File
@@ -226,12 +226,41 @@ def _first_available_field(label: str, *reports_and_key: Any) -> dict[str, Any]:
return {"status": "blocked", "producer": label, "reason": f"{key}_missing", "producers_checked": producers}
def _annual_comparable_value(key: str, value: Any) -> Any:
"""Compare sign and lord only. Extra dict keys are not a disagreement."""
if not isinstance(value, dict):
return value
if key == "muntha":
return {
"muntha_sign": value.get("muntha_sign"),
"muntha_lord": value.get("muntha_lord"),
}
if key == "year_lord":
return {
"year_lord": value.get("year_lord") or value.get("lord"),
"year_lord_sign": value.get("year_lord_sign") or value.get("sign"),
}
return {
"sign": value.get("sign") or value.get("muntha_sign") or value.get("year_lord_sign"),
"lord": value.get("lord") or value.get("muntha_lord") or value.get("year_lord"),
}
def _field_value_is_blocked(value: Any) -> bool:
return isinstance(value, dict) and value.get("status") == "blocked"
def _field_with_conflict_gate(key: str, solar_report: dict[str, Any], tajika_report: dict[str, Any]) -> dict[str, Any]:
values = []
for producer, report in (("solar_return", solar_report), ("tajika", tajika_report)):
if isinstance(report, dict) and report.get(key) is not None and not report.get("error"):
values.append({"producer": producer, "value": report[key]})
if len(values) >= 2 and values[0]["value"] != values[1]["value"]:
if not isinstance(report, dict) or report.get(key) is None or report.get("error"):
continue
value = report[key]
if _field_value_is_blocked(value):
continue
values.append({"producer": producer, "value": value})
comparable_values = [_annual_comparable_value(key, item["value"]) for item in values]
if len(values) >= 2 and comparable_values[0] != comparable_values[1]:
return {
"status": "conflict",
"field": key,
+35 -26
View File
@@ -1975,6 +1975,29 @@ def _personal_report_source_pack_contracts(
}
def _copy_call_namespace(args):
"""Copy CLI args without using an empty instance ``vars()`` dict.
``type('Args', (), fields)()`` stores fields on the class, so ``vars(args)``
is ``{}`` and ``SimpleNamespace(**vars(args))`` drops ``year`` and the rest.
argparse and SimpleNamespace keep fields on the instance. Upstream has the
same ``vars()`` copy.
"""
fields = {}
instance = getattr(args, "__dict__", None)
if isinstance(instance, dict):
fields.update({
key: value for key, value in instance.items() if not str(key).startswith("_")
})
cls = type(args)
if cls is not SimpleNamespace:
for key, value in vars(cls).items():
if str(key).startswith("_") or callable(value):
continue
fields.setdefault(key, value)
return SimpleNamespace(**fields)
def _attach_annual_tajika_pack(packet: dict, args) -> dict:
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 None
@@ -1997,7 +2020,7 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict:
annual_years = {str(target_year): _annual_series_surface(timing['annual_tajika_pack'])}
for year in range(target_year + 1, target_year + 3):
annual_args = SimpleNamespace(**vars(args))
annual_args = _copy_call_namespace(args)
annual_args.target_year = year
try:
birth_year = int(getattr(args, 'year'))
@@ -2698,12 +2721,13 @@ def _native_dasha_master_families(modules: dict) -> dict:
def _blocked_planned_section(heading: str, reason_code: str, detail: str = "") -> list[str]:
"""Emit a planned heading with an explicit blocked reason instead of vanishing."""
lines = [heading, '', f"`blocked` / `{reason_code}`"]
if detail:
lines.append(str(detail).strip())
lines.append('')
return lines
"""Unavailable sections are omitted entirely, including raw error text.
Upstream returns an empty block when a producer has nothing to show.
Printing ``error`` / ``reason`` put traceback text into the reference body.
"""
del heading, reason_code, detail
return []
PLANNED_LONGFORM_SECTION_HEADINGS = (
@@ -3270,12 +3294,6 @@ def _render_finished_reading_navigation(packet: dict) -> list[str]:
'4. 校时敏感层:有候选窗才读矩阵,没有则视为未做校时。',
'5. 质量验收矩阵与对照覆盖表,只用来核对装配,不改写正文标签。',
'',
'### 结论等级规则',
'',
'- `executed` / `partial_verified`:可阅读,但仍受参数与证据边界约束。',
'- `parameter_sensitive`:可交叉核对,不得升级为精确事件。',
'- `blocked` / `missing_in_local`:保持原标签,不得涂成可读结论。',
'',
'### 专题判读协议',
'',
'- 事业先看 2/6/10/11 宫 KP 结构与 D10,再看三年月度支持。',
@@ -3605,17 +3623,13 @@ def render_pl9_markdown(packet: dict) -> str:
if not isinstance(notice, dict):
return []
primary_zh = notice.get('primary_text_zh') or notice.get('text_zh') or notice.get('summary_zh') or ''
primary_en = notice.get('primary_text_en') or notice.get('text_en') or ''
display_rule = notice.get('display_rule') or ''
if not any([primary_zh, primary_en, display_rule]):
if not any([primary_zh, display_rule]):
return []
lines_out = ['', '## 多引擎口径说明', '']
if primary_zh:
lines_out.append(str(primary_zh))
lines_out.append('')
if primary_en:
lines_out.append(str(primary_en))
lines_out.append('')
if display_rule:
lines_out.append(f"显示规则:{display_rule}")
lines_out.append('')
@@ -13609,7 +13623,7 @@ def cmd_ashtakoot(args):
# ============================================================================
def cmd_kp(args):
requested_report_ayanamsa = _current_ayanamsa_name(args)
kp_args = SimpleNamespace(**vars(args))
kp_args = _copy_call_namespace(args)
kp_args.ayanamsa = 'kp'
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(kp_args)
@@ -15907,7 +15921,7 @@ def cmd_full_reading(args):
# They are intentionally not fed into conclusion generation or timing promotion.
reference_date = getattr(args, 'transit_date', None) or getattr(args, 'today', None)
if reference_date:
transit_args = SimpleNamespace(**vars(args))
transit_args = _copy_call_namespace(args)
transit_args.date = reference_date
transit_args.transit_date = reference_date
transit_args.house = 7
@@ -17735,12 +17749,7 @@ def _professional_report_reference_boundary_notice() -> dict:
'不同引擎的岁差、节点、宫制、分盘和大运变体可能产生差异;'
'冲突、参数敏感或未验证项目会保留边界标签,不合并成单一确定结论。'
),
'primary_text_en': (
'This reference export combines local Jyotish calculations with available external-engine '
'replays and source comparisons. Engine-specific settings can differ, so conflicts and '
'unverified variants remain explicitly labeled rather than being collapsed into one claim.'
),
'display_rule': '以本报告标注的 executed / blocked / parameter_sensitive 状态为准。',
'display_rule': '冲突和未验证的项目保留边界,不合并成单一确定结论。',
}
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -26,6 +26,24 @@ class ProfessionalReportReferenceInputError(ValueError):
"""The professional-reference request is outside the public contract."""
READER_MAIN_EDITION = "reader_main"
REFERENCE_EDITION = "reference"
REPORT_VERSION_READER = "pl9_personal_long_report.v3"
def _normalize_edition(value: Any) -> str:
if value is None or value == "":
return REFERENCE_EDITION
if not isinstance(value, str):
raise ProfessionalReportReferenceInputError("edition must be a string")
normalized = value.strip()
if normalized in {"", REFERENCE_EDITION, "professional_reference"}:
return REFERENCE_EDITION
if normalized == READER_MAIN_EDITION:
return READER_MAIN_EDITION
raise ProfessionalReportReferenceInputError("edition must be reference or reader_main")
def _normalize_format(value: Any) -> str:
if value is None:
return "json"
@@ -100,6 +118,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
if not isinstance(body, dict):
raise ProfessionalReportReferenceInputError("request body must be an object")
output_format = _normalize_format(body.get("format"))
edition = _normalize_edition(body.get("edition"))
packs = _normalize_packs(body.get("packs"))
birth = handler._high_rigor_birth_payload(body)
@@ -114,15 +133,29 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
except ValueError as exc:
raise ProfessionalReportReferenceInputError(str(exc)) from exc
if edition == READER_MAIN_EDITION and isinstance(packet, dict):
packet = dict(packet)
packet["report_version"] = REPORT_VERSION_READER
if output_format == "markdown":
if edition == READER_MAIN_EDITION:
try:
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from pl9_reader_export import _pl9_export_markdown_for_edition
markdown = _pl9_export_markdown_for_edition(packet, READER_MAIN_EDITION)
else:
markdown = resolved_engine.render_pl9_markdown(packet)
return {
"format": "markdown",
"markdown": resolved_engine.render_pl9_markdown(packet),
"edition": edition,
"report_version": REPORT_VERSION_READER if edition == READER_MAIN_EDITION else packet.get("report_version"),
"markdown": markdown,
**({"fact_table_packet": report_density_packet(packet),
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if body.get("include_fact_tables") is True else {}),
}
return {
"format": "json",
"edition": edition,
"report": packet,
}
+1 -1
View File
@@ -15,7 +15,7 @@ from typing import Any
AUTHORITY_SCHEMA_VERSION = "jyotish.shared_full_report_authority.v1"
REPORT_SCHEMA_VERSION = "expert_report_output.v2"
DEFAULT_REPORT_VERSION = "pl9_personal_long_report.v2"
DEFAULT_REPORT_VERSION = "pl9_personal_long_report.v3"
PL9_EXPORT_SCHEMA = "pl9_style_professional_export_v1"
QUALITY_GATE_SCHEMA = "jyotish.full_report_quality_gate.v1"
+15 -8
View File
@@ -401,6 +401,7 @@ def calc_solar_return_chart(
'houses': houses,
'ascendant': ascendant,
'asc_sign_idx': sr_asc_idx,
'birth_asc_sign_idx': birth_asc_idx,
'target_year': target_year,
'birth_year': birth_year,
'age': target_year - birth_year,
@@ -505,14 +506,20 @@ def solar_return_full_report(
# 3. Year Lord(= Muntha 守护星)
try:
muntha_data = result.get('muntha', {})
muntha_sign_val = muntha_data.get('muntha_sign_idx', muntha_sign if 'muntha_sign' in dir() else 0)
year_lord_sign = muntha_sign_val
year_lord = SIGN_LORDS[SIGNS[year_lord_sign]]
result['year_lord'] = {
'year_lord': year_lord,
'year_lord_sign_idx': year_lord_sign,
'year_lord_sign': SIGNS[year_lord_sign],
}
muntha_sign_val = muntha_data.get('muntha_sign_idx') if isinstance(muntha_data, dict) else None
if not isinstance(muntha_sign_val, int) or isinstance(muntha_sign_val, bool):
result['year_lord'] = {
'status': 'blocked',
'reason': 'muntha_sign_required_for_year_lord',
}
else:
year_lord_sign = muntha_sign_val % 12
year_lord = SIGN_LORDS[SIGNS[year_lord_sign]]
result['year_lord'] = {
'year_lord': year_lord,
'year_lord_sign_idx': year_lord_sign,
'year_lord_sign': SIGNS[year_lord_sign],
}
except Exception as e:
result['year_lord'] = {'error': str(e)}
+24 -10
View File
@@ -17,22 +17,36 @@ from scripts.timing_precision_contract import build_timing_precision_contract
from tests.test_report_longform_parity import BEIJING, _producer_packet
def test_kp_error_pack_emits_blocked_heading_instead_of_vanishing() -> None:
def test_kp_error_pack_omits_the_section_instead_of_printing_the_error() -> None:
"""原值:空 KP 仍输出原始表标题和 kp_lord_sub_unavailable。
新值:整节不输出,synthetic_kp_failure / producer failed 也不出现。
原因:D3。BUG-561 用 blocked 标题代替消失,但标题里打印了原始 error。
"""
markdown = render_pl9_markdown(_producer_packet({
"advanced_systems": {"kp": {"error": "synthetic_kp_failure", "status": "blocked"}},
}))
assert "### KP Lord / Sub 原始表(本地计算)" in markdown
assert "kp_lord_sub_unavailable" in markdown
assert "#### KP 事业结构小表" in markdown
assert "#### KP 事业宫位核对(2 / 6 / 10 / 11 宫)" in markdown
assert "#### KP 财务结构小表" in markdown
assert "#### KP 关系结构小表" in markdown
assert "### KP Lord / Sub 原始表(本地计算)" not in markdown
assert "kp_lord_sub_unavailable" not in markdown
assert "synthetic_kp_failure" not in markdown
assert "producer failed" not in markdown
assert "#### KP 事业结构小表" not in markdown
assert "#### KP 事业宫位核对(2 / 6 / 10 / 11 宫)" not in markdown
assert "#### KP 财务结构小表" not in markdown
assert "#### KP 关系结构小表" not in markdown
def test_planned_headings_are_present_or_blocked_on_empty_packet() -> None:
def test_empty_packet_omits_unavailable_planned_sections() -> None:
"""原值:PLANNED_LONGFORM_SECTION_HEADINGS 在空包里必须逐条出现。
新值:没有数据的占位节不出现;仍有正文的审计节可以留下。
原因:D3,数据不可用的节整节不输出,且不得带原始 error。
"""
markdown = render_pl9_markdown(_producer_packet({}))
missing = [heading for heading in PLANNED_LONGFORM_SECTION_HEADINGS if heading not in markdown]
assert missing == [], f"planned headings vanished: {missing}"
omitted = [heading for heading in PLANNED_LONGFORM_SECTION_HEADINGS if heading not in markdown]
assert "### KP Lord / Sub 原始表(本地计算)" in omitted
assert "### Profile-aware Benchmark Boundary" in omitted
assert "producer failed" not in markdown
assert "结论等级规则" not in markdown
assert "This reference export" not in markdown
def test_module_execution_audit_survives_raw_strip() -> None:
+1 -1
View File
@@ -64,12 +64,12 @@ def test_reading_navigation_emits_required_headings() -> None:
for heading in (
"## 成品阅读导航",
"### 先读什么",
"### 结论等级规则",
"### 专题判读协议",
"### 质量验收矩阵",
"### 对照覆盖表",
):
assert heading in markdown
assert "### 结论等级规则" not in markdown
assert "| 三年年度展开 | present |" in markdown
assert "| KP 三年流月支持 | present |" in markdown
+220
View File
@@ -0,0 +1,220 @@
"""Reader-main export and the KP / Muntha defects behind BUG-1026/1027/1028."""
from __future__ import annotations
import re
import pytest
from scripts.annual_tajika_pack import _field_brief, _field_with_conflict_gate
from scripts.jyotish_engine import (
_copy_call_namespace,
_professional_report_reference_boundary_notice,
cmd_full_reading,
render_pl9_markdown,
)
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from scripts.professional_report_reference import (
REPORT_VERSION_READER,
build_professional_report_reference,
)
from scripts.shared_full_report_authority import DEFAULT_REPORT_VERSION
from scripts.solar_return import calc_solar_return_chart, solar_return_full_report
# Clearly fictional. Not a user, not a published celebrity case.
FICTIONAL = {
"name": "虚构甲",
"year": 1991,
"month": 4,
"day": 7,
"hour": 9,
"minute": 15,
"second": 0,
"lat": 30.5728,
"lon": 104.0668,
"tz": 8.0,
"node_mode": "mean",
"ayanamsa": "lahiri",
"today": "2026-04-07",
"transit_date": "2026-04-07",
"target_year": 2026,
"age": 35,
"birth_time_accuracy": "confirmed",
}
LEAK_PATTERNS = (
re.compile(r"\bblocked\b", re.I),
re.compile(r"\bexecuted\b", re.I),
re.compile(r"\bpartial_verified\b", re.I),
re.compile(r"\bmissing_in_local\b", re.I),
re.compile(r"\bproducer\b", re.I),
re.compile(r"\bparity\b", re.I),
re.compile(r"PL9\s*第\s*\d+\s*页"),
re.compile(r"full-report://"),
re.compile(r"\bpl9_[A-Za-z0-9_]+\b", re.I),
re.compile(r"\b[A-Za-z][A-Za-z0-9]*_unavailable\b"),
re.compile(r"\b[A-Za-z][A-Za-z0-9]*_(?:pack|packet)\b", re.I),
re.compile(r"object has no attribute"),
re.compile(r"\bTraceback\b"),
re.compile(r"结论等级规则"),
re.compile(r"This reference export"),
re.compile(r"producer failed"),
re.compile(r"PyJHora"),
re.compile(r"\bparameter_sensitive\b"),
re.compile(r"\bschema\b", re.I),
re.compile(r"\bsource_path\b"),
re.compile(r"\{planets\}"),
)
CORE_CHAPTERS = (
"出生资料",
"Ashtakavarga",
"标准大运",
"KP",
"Year Lord",
"Tajika",
"Muntha",
)
def _class_args(**overrides):
fields = {**FICTIONAL, **overrides}
return type("Args", (), fields)()
def _leak_hits(markdown: str) -> list[str]:
return [pattern.pattern for pattern in LEAK_PATTERNS if pattern.search(markdown)]
@pytest.fixture(scope="module")
def full_reading():
return cmd_full_reading(_class_args())
def test_default_edition_stays_reference_and_reader_requests_v3() -> None:
assert DEFAULT_REPORT_VERSION == "pl9_personal_long_report.v3"
assert REPORT_VERSION_READER == "pl9_personal_long_report.v3"
class Handler:
def _high_rigor_birth_payload(self, body):
return dict(FICTIONAL)
def _compute_full_reading_for_thematic(self, body):
return {"modules": {"chart": {}}}
class Engine:
def build_professional_report_reference_packet(self, full_reading, args, packs):
return {"schema": "pl9_style_professional_export_v1", "selected_report_pack_ids": packs}
def render_pl9_markdown(self, packet):
return "reference-body"
handler = Handler()
engine = Engine()
default = build_professional_report_reference(handler, {"format": "markdown"}, engine=engine)
assert default["edition"] == "reference"
assert default["markdown"] == "reference-body"
reader = build_professional_report_reference(
handler,
{"format": "markdown", "edition": "reader_main"},
engine=engine,
)
assert reader["edition"] == "reader_main"
assert reader["report_version"] == "pl9_personal_long_report.v3"
def test_reference_body_drops_local_leak_phrases() -> None:
packet = {
"schema": "pl9_style_professional_export_v1",
"birth_info": {"name": "虚构甲"},
"reader_engine_boundary_notice": _professional_report_reference_boundary_notice(),
"worksheets": {},
}
markdown = render_pl9_markdown(packet)
assert "结论等级规则" not in markdown
assert "This reference export" not in markdown
assert "producer failed" not in markdown
assert "多引擎口径说明" in markdown
def test_class_args_copy_keeps_year_for_kp_and_transit(full_reading) -> None:
args = _class_args()
copied = _copy_call_namespace(args)
assert copied.year == 1991
assert vars(args) == {}
modules = full_reading["modules"]
for name in ("kp", "double_transit_pac", "transit_ll7l", "planetary_congregation"):
module = modules.get(name)
assert isinstance(module, dict), name
blob = repr(module)
assert "has no attribute" not in blob
assert module.get("status") != "blocked", (name, module.get("status"), module.get("reason"))
assert "error" not in module or not str(module.get("error", "")).startswith("KP producer failed")
def test_reader_main_hides_audit_vocabulary(full_reading) -> None:
from scripts.jyotish_engine import build_professional_report_reference_packet
packet = build_professional_report_reference_packet(full_reading, _class_args(), ["full"])
reference = _pl9_export_markdown_for_edition(packet, "reference")
reader = _pl9_export_markdown_for_edition(packet, "reader_main")
assert "结论等级规则" not in reference
assert "This reference export" not in reference
hits = _leak_hits(reader)
assert hits == [], hits
for chapter in CORE_CHAPTERS:
assert chapter in reader, chapter
assert reader.strip().startswith("#")
def test_solar_return_restores_birth_asc_and_year_lord_moves() -> None:
lords = set()
signs = set()
for year in (2024, 2026, 2028):
chart = calc_solar_return_chart(
FICTIONAL["year"], FICTIONAL["month"], FICTIONAL["day"],
FICTIONAL["hour"], FICTIONAL["minute"],
FICTIONAL["lat"], FICTIONAL["lon"], FICTIONAL["tz"],
year,
ayanamsa_name="lahiri",
)
assert "error" not in chart, chart.get("error")
assert isinstance(chart.get("birth_asc_sign_idx"), int)
report = solar_return_full_report(
FICTIONAL["year"], FICTIONAL["month"], FICTIONAL["day"],
FICTIONAL["hour"], FICTIONAL["minute"],
FICTIONAL["lat"], FICTIONAL["lon"], FICTIONAL["tz"],
year,
ayanamsa_name="lahiri",
)
muntha = report["muntha"]
assert muntha.get("status") != "blocked", muntha
assert isinstance(muntha.get("muntha_sign_idx"), int)
year_lord = report["year_lord"]
assert year_lord.get("status") != "blocked", year_lord
lords.add(year_lord["year_lord"])
signs.add(muntha["muntha_sign"])
assert len(signs) >= 2
assert len(lords) >= 2
def test_blocked_side_does_not_invent_a_conflict() -> None:
gated = _field_with_conflict_gate(
"muntha",
{"muntha": {"status": "blocked", "reason": "natal_ascendant_required_for_muntha"}},
{"muntha": {"muntha_sign": "Capricorn", "muntha_lord": "Saturn", "note": "extra"}},
)
assert gated["status"] == "partial_verified"
assert "conflict" not in _field_brief("Muntha", gated)
same_sign = _field_with_conflict_gate(
"year_lord",
{"year_lord": {"year_lord": "Mars", "year_lord_sign": "Aries", "trace": "left"}},
{"year_lord": {"year_lord": "Mars", "year_lord_sign": "Aries", "trace": "right"}},
)
assert same_sign["status"] == "partial_verified"
disagree = _field_with_conflict_gate(
"year_lord",
{"year_lord": {"year_lord": "Mars", "year_lord_sign": "Aries"}},
{"year_lord": {"year_lord": "Venus", "year_lord_sign": "Taurus"}},
)
assert disagree["status"] == "conflict"