Files
Jyotisha/docs/tasks/TASK-pl9-render-split-20261013.md
T
Jesse_ChenandClaude Opus 5.5 0be4cb299c docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward)
Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources,
home-sidebar-rerender, api-disk-cache-retention, report-reference-perf,
consult-prep-parallel, frontend-dead-code.
Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab,
consult-route-split, pl9-render-split, python-hygiene, dasha-unify,
api-server-service-extract, data-layer-types, contracts-helpers-dedupe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:34:35 +08:00

188 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 拆分 6,917 行的报告渲染函数(第一阶段:搬出无依赖的内部函数)(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」重构条目「PL9 渲染器拆分」
- 执行方:**Grok(Windows + Docker)**,分支 `codex/pl9-render-split-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**无 BUG 编号**(纯重构,无缺陷),记录写 `docs/tasks/PROGRESS-pl9-render-split-20261013.md`(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
- 开工前置:**`TASK-report-reference-perf-20261013` 已合入 `origin/staging`**(两单都改 `scripts/jyotish_engine.py`,必须串行)
- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。
## 1. 基线
- 写作基线:`origin/staging` `77057a819`;开工基线:前置单合入后的 `origin/staging` head(行号会漂移,**一律按符号定位**)。
- 对象:`scripts/jyotish_engine.py::render_pl9_markdown`(写作时第 3475–10391 行,单参数 `packet`)。
## 2. 事故实证(`77057a819`,AST / symtable 统计,脚本见 §9)
### 2.1 规模
| 函数 | 行数 | 嵌套具名函数 | 函数内 import 语句 |
| --- | --- | --- | --- |
| `jyotish_engine.py::render_pl9_markdown` | 6,917 | **148**(直接 18 个,其余在 `if producer:` 分支里) | **7** |
| `jyotish_engine.py::cmd_full_reading` | 1,554 | 4 | 48 |
| `jyotish_engine.py::build_pl9_style_export_packet` | 934 | 6 | 5 |
| `pl9_reader_export.py::render_pl9_parity_markdown` | 2,549 | 23 | 7 |
| `pl9_full_data_export.py::_render_pl9_ai_density_raw_data_markdown_en` / `_zh` | 685 / 84 | 5 / 3 | 4 / 0 |
| `pl9_reader_english.py::render_pl9_user_markdown_en` | 58 | 0 | 2 |
| 擦洗:`_sanitize_pl9_ai_density_markdown` / `_en` / `pl9_reader_export.py::_strip_pl9_user_engineering_markers` | 451 / 389 / 258 | — | 4 / 2 / 6 |
对排查报告的修正:
- 「函数内 import 165 处」不成立:`render_pl9_markdown` 内只有 7 条;**整个 `jyotish_engine.py` 所有顶层函数内**合计 156 条。
- `_auxiliary_dasha_section`(761 行)**不是**同文件的独立函数,而是 `render_pl9_markdown` 内部的嵌套函数(写作时第 7829 行起)。
### 2.2 结构
- 函数体只有 26 条顶层语句;其中 `if isinstance(producer, dict) and producer:` 一条占 6,582 行、含 130 个嵌套函数,**无 else 分支**。
- 最外层嵌套函数 119 个,共 5,012 行;其余约 1,900 行是内联拼接语句。
- 最大的几个嵌套函数:`_auxiliary_dasha_section` 761、`_strength_ashtakavarga_section` 326、`_kp_lord_sub_section` 278、`_chart_atlas_section` 187、`_kp_three_year_monthly_section` 185、`_jaimini_special_section` 161、`_future_annual_focus_sections` 160、`_guided_topic_value_sentence` 151、`_profile_aware_benchmark_appendix` 142。
### 2.3 闭包依赖(symtable,148 个嵌套具名函数)
| 类别 | 个数 |
| --- | --- |
| 无任何自由变量 | 28 |
| 自由变量只有别的嵌套辅助函数 | 43 |
| 依赖外层数据变量 | 77(共 48 个不同变量,最多的是 `worksheets` 17、`advanced_sheet` 13、`packet` 9、`timing_sheet` 9) |
| 对外层变量赋值(`nonlocal`) | 0 |
- 按「辅助函数也能一起搬」做传递闭包,**最外层可直接搬的有 33 个、共 443 行**(例如 `_unwrap`、`_envelope_list`、`_envelope_dict`、`_bool_text`、`_md_cell`、`_render_compact_payload`、`_workbook_readable_*`、`_section_evidence_boundary`、`_profile_aware_benchmark_appendix`、`_chart_planets_from_positions`、`_kp_house_focus_text`、`_compose_narrative_paragraph`、`_has_executed_payload`、`_append_unique_text` 等)。
- 被引用最多的辅助函数 `_humanize_reader_token`(72 处)只依赖 `planet_label_cn`(函数体开头的**字面量常量字典**,从不修改)和模块级 `SIGNS_CN`。把 `planet_label_cn` 升成模块常量后,它和依赖它的 `_humanize_reader_group`、`_display_rashi_from_value`、`_humanize_reader_summary_line`、`_sign_text`、`_planet_list_text` 等也变成可搬。
- **陷阱:`_degree_text` 定义了两次**(写作时第 3672 行与第 6377 行,后者在 `if producer:` 分支内,同属 `render_pl9_markdown` 作用域)。两版行为不同(空值返回 `'-'` vs `_md_cell(value)`)。闭包按**调用时**查名字,所以分支执行到第二个定义之后,所有引用 `_degree_text` 的闭包——包括定义在前面的——都会调用第二版。随手把它们提成两个模块函数,会悄悄改变输出。
### 2.4 谁在调用(实证判断:不是死路径,所以第一阶段做搬移,不做删除)
| 调用方 | 生产是否触达 | 说明 |
| --- | --- | --- |
| `build_professional_report_reference_packet` → `evaluate_full_report(delivered, render_pl9_markdown(delivered))` | **是,每份个人长报告都跑** | 虚构盘实测 0.05 s,`if producer:` 分支执行了 3,692 行;生成的质量门结果不进 `full_data` 响应(见 `TASK-report-reference-perf-20261013` §4 观察项) |
| `professional_report_reference.py` 的 `edition=reference` + `markdown` | 否(前端只发 `full_data`) | 接口仍对外 |
| `pl9_reader_export.py::_reference_markdown` | 非 `full_data` edition 时 | |
| CLI `pl9-export --format markdown` / `--archive-dir`、PDF 导出(`jyotish_engine.py` 中 `render_pl9_markdown(packet)` 后接 reportlab 那个函数)、`mcp_server.py` 的 `pl9-export` | 开发 / 研究工具 | |
| 测试 | — | 8 个测试文件直接调用:`test_report_reader_main`、`test_report_longform_parity`、`test_full_report_quality_gate`、`test_report_chart_block`、`test_report_longform_gaps2`、`test_full_reading_conditional_dashas`、`test_professional_report_reference_api`,另有若干文件把它当桩 |
结论:删除需要先由产品决定「`full_data` 路径不再跑质量门」并下线 `reference` edition,本单不做(列入后续)。
## 3. 根因
`render_pl9_markdown` 从上游 PL9 对标报告逐页追加而来,每补一页就在同一个函数里再加一个闭包和一段 `lines.append`,从未拆分。闭包共享约 48 个外层局部变量,导致无法单测单章、无法复用,也让同名重定义(`_degree_text`)这类隐性行为难以察觉。**不是回归,是从未设计。** `docs/BUG_HISTORY.md` 相关:BUG-610(围栏构造失败只丢该张围栏、不得打断整份报告——搬移时这段 `try` 结构必须原样保留)、BUG-693 / 694(质量门依赖这份 markdown 做 `reader_section:*` 检查)。
## 4. 决策记录
- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。
- 本单是**大型重构的第一阶段**:只把**不依赖外层数据**的嵌套函数原样搬到新包 `scripts/report_render/`,不改逻辑、不改输出、不删任何调用方。
- 不推翻任何既有红线。删除死路径(`reference` edition、`full_data` 路径的质量门)**本单不做,需拍板**。
## 5. 硬红线
1. **输出逐字节一致**:对 §6 T1 的语料,搬移前后 `render_pl9_markdown` 的输出 sha256 全部相同;`render_full_data_markdown` 中英两版与 `/api/professional_report_reference` 生产请求体的整份响应也必须相同。差一个字节即未通过。
2. **纯搬移**:函数体逐字复制(只允许把闭包里引用的辅助函数名改为从新模块导入、把 `planet_label_cn` 改为引用模块常量),不改名(保持下划线私有名)、不改参数、不改默认值、不「顺手」修任何逻辑、文案、空白或类型注解。
3. **`_degree_text` 及其全部调用者本阶段不搬**;同理,任何在 `render_pl9_markdown` 作用域内被定义两次以上的名字都不搬(开工先用 §9 脚本列出所有重名)。
4. 被搬走的函数如果读模块级全局(如 `SIGNS_CN`、`SIGNS`),从定义它们的叶子模块导入,或改成显式参数;**`scripts/report_render/` 不得在模块顶层 import `jyotish_engine`**(会成环)。不得引入 `jhora` / `mcp_server` 导入(`tests/test_runtime_jhora_import_scan.py`、`tests/test_runtime_import_boundaries.py`)。
5. 导入写法沿用仓库惯例的双路径(`try: from scripts.report_render.x import …` / `except ModuleNotFoundError: from report_render.x import …`),保证 `scripts/` 直跑与包导入都可用;新包要有 `__init__.py`(`pyproject.toml` 已 `include = ["scripts*"]`,镜像 `COPY scripts` 整目录)。
6. 改动规模:diff(`git diff --stat` 增删合计)**< 1,500 行**;超出就按让步顺序少搬。
7. `scripts/jyotish_api_server.py` 不改;不改 `.gitea/workflows/**`;不动表结构与迁移;不升级依赖;不顺手修 warning;不碰前端。
8. 改既有断言按「原值 / 新值 / 原因」三栏写进 PROGRESS;测试总数不得低于开工时。开工先 grep 按源码文本断言 `jyotish_engine.py` 的测试(已知:`tests/test_calculation_profile_contract.py`、`tests/test_full_reading_saham_contract.py`、`tests/test_full_reading_conditional_dashas.py`、`tests/test_runtime_import_boundaries.py` 读源文件文本),确认被搬走的字符串没有被它们断言。
9. 差分输入必须是真实引擎现算的虚构盘包,不得手造包形状。
## 6. 任务分解
### T1 · 差分脚手架(先做)
- 新增 `scripts/research/report_render_split_diff.py`:
1. 在当前工作树用真实引擎为语料建包,并 **pickle** 到仓库外临时目录(必须用 pickle 不用 JSON:包里有元组和非字符串键;而且 `shared_full_report_authority` 带墙钟 `generated_at`,会写进 markdown 的「生成时间」行,所以新旧两边必须渲染**同一份**包)。
2. 子进程在**基线工作树**(`git worktree add` 一个只读的 `origin/staging` 检出,开工前置命令里建)里加载 pickle,跑基线的 `render_pl9_markdown`,输出每份 sha256;当前进程跑新代码,同样输出;逐项对比,不同即退出码 1,并打印第一处差异附近 5 行。
3. 两边都设 `PYTHONHASHSEED=0`;先做一次「基线对基线」对照,两次必须相同,否则脚本报错退出(说明有非确定性来源,必须先查清)。
- 语料:`tests/test_report_english_edition.py::CASES` 的 day / night / south 三张盘,再加 1990-01-01 12:00 新德里 28.6139/77.2090 +5.5 虚构盘;每张盘渲染三种包:①`build_professional_report_reference_packet` 交付包(`packs=[]`),②同包去掉 `personal_report_producer`(走非 producer 路径),③`_apply_pl9_pack_selection` 选非 full 的一个 pack(若有)。另附 `render_full_data_markdown` 中英两版的 sha(防止共享辅助函数的改动波及)。
- 屏蔽 PyJHora 跑一轮(生产镜像依赖),不屏蔽跑一轮。
- 验收:脚本在未搬移的代码上退出码 0;Grok 自跑。
### T2 · 建包与常量
- 新建 `scripts/report_render/__init__.py`(空或只放说明)与以下模块(按「章节」分,第一阶段只放通用辅助):
- `scripts/report_render/envelope.py`:`_unwrap`、`_envelope_list`、`_envelope_dict`、`_bool_text`、`_source_dict`、`_has_executed_payload`
- `scripts/report_render/text.py`:`PLANET_LABEL_CN`(由 `planet_label_cn` 原字面量升成)、`_md_cell`、`_render_compact_payload`、`_humanize_reader_token` 及只依赖它的 humanize 族、`_workbook_readable_*`、`_compose_narrative_paragraph`、`_append_unique_text`、`_value_text`
- `scripts/report_render/sections_misc.py`:`_status_from_report_pack_section`、`_section_evidence_boundary`、`_profile_aware_benchmark_appendix`、`_domi_pl9_current_gap_appendix`、`_chart_planets_from_positions`、`_kp_house_focus_text`、`_high_value_theme_authority_sentence`、`_kp_conflict_adjudication_sentence`、`_prashna_exact_cusp_boundary_sentence`、`_render_boundary_packet_summary_line`
- 实际清单以 §9 脚本在开工基线上的输出为准(上面是写作时的 33 个可搬函数 + `planet_label_cn` 解锁的那批);`_degree_text`、`_sign_degree_text`、`_longitude_text` 及所有重名或调用重名的函数排除。
- `render_pl9_markdown` 里删掉被搬走的 `def`,在函数开头(或模块顶部)按红线 5 的双路径导入同名函数;`planet_label_cn` 局部变量改为 `planet_label_cn = PLANET_LABEL_CN`(保留局部名,避免改动其余引用)。
- 不改:任何依赖外层数据变量的嵌套函数(77 个,后续阶段改显式参数)、内联拼接语句、`if producer:` 分支结构、BUG-610 的 `try` 包裹。
- 验收:
- T1 脚本全部语料新旧 sha 相同、退出码 0(屏蔽与不屏蔽 PyJHora 各一轮)。
- `python -m pytest tests/test_report_reader_main.py tests/test_report_longform_parity.py tests/test_full_report_quality_gate.py tests/test_report_chart_block.py tests/test_report_longform_gaps2.py tests/test_full_reading_conditional_dashas.py tests/test_professional_report_reference_api.py tests/test_report_english_edition.py tests/test_report_full_data_fix.py tests/test_reader_appendix_language.py tests/test_runtime_import_boundaries.py tests/test_runtime_jhora_import_scan.py tests/test_calculation_profile_contract.py tests/test_full_reading_saham_contract.py -q` 全绿。
- 新测试 `tests/test_report_render_package.py`:①`scripts.report_render` 各模块可以在**未导入** `jyotish_engine` 的干净进程里导入(子进程检查 `sys.modules`);②`PLANET_LABEL_CN` 与原字面量逐项相等;③对真实引擎渲染结果里出现过的输入,抽 5 个搬出的函数做「新模块函数 == 引擎内仍可访问的同名引用」等值断言。
- `render_pl9_markdown` 行数下降(预期约 600~900 行),在 PROGRESS 记下前后行数与嵌套函数数。
### T3 · 记录
- `docs/tasks/PROGRESS-pl9-render-split-20261013.md`:搬移清单(函数名 → 新模块)、排除清单与原因(尤其重名)、T1 sha 对照表、前后行数、diff 规模。
- 不写 BUG_HISTORY(无缺陷);`CHANGELOG.md` 不写(用户不可感知);`frontend/DESIGN.md` 不改。
## 7. 让步顺序
1. 必保:T1 差分脚手架 + `envelope.py`(最小、最稳)。
2. 再做:`text.py`(含 `PLANET_LABEL_CN` 与 humanize 族,收益最大:`_humanize_reader_token` 被 72 处引用)。
3. 最先砍:`sections_misc.py`。
4. diff 将超 1,500 行时停止继续搬,剩余写进后续阶段清单。
5. 任何一个函数搬移后 sha 不同、一轮内查不清:撤回该函数,写进 PROGRESS 的排除清单,不得改测试或改输出来凑一致。
## 8. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑(工作树 `.venv`) | §6 T2 的 pytest 列表 + `tests/test_report_render_package.py`;`python scripts/research/report_render_split_diff.py --baseline <基线工作树> --mask-pyjhora` 与不屏蔽各一次(退出码 0)。Python 快速门、前端全量**不跑** |
| Claude 验收 | 上述全部 + `--profile quick` 快速门 + 全量 `pytest` 与开工基线逐名比对 + Linux 下复跑差分脚本(屏蔽 PyJHora)+ 核对 `git diff --stat` < 1,500 行与搬移逐字(`git diff --color-moved=zebra` 抽查) |
| 不需要 | 前端 `tsc` / `lint` / `npm test`(无前端改动);`test:db` |
## 9. 开工前置命令
```bash
git fetch origin --prune
# 前置单必须已合入(文件存在即视为合入;同时看提交日志)
git cat-file -e origin/staging:docs/tasks/PROGRESS-report-reference-perf-20261013.md && echo "前置已合入" || echo "前置未合入:停止,回报 Claude"
git log origin/staging --oneline | grep -i "report-reference-perf\|BUG-1335" | head -3
git worktree add -b codex/pl9-render-split-20261013 .worktrees/pl9-render-split-20261013 origin/staging
git worktree add --detach .worktrees/pl9-render-split-baseline origin/staging # 差分用的只读基线,交付后删除
cd .worktrees/pl9-render-split-20261013
git log -1 --format=%h
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -1 # 仅核对,本单不占 BUG 号
grep -rn "jyotish_engine.py\").read_text\|jyotish_engine.py').read_text\|getsource" tests | head -20
```
开工统计(重新数一遍,写进 PROGRESS;数字与 §2 出入较大时先回报再动手):
```python
# python - < this (在工作树根目录跑)
import ast, symtable, collections
src = open("scripts/jyotish_engine.py", encoding="utf-8").read()
tree = ast.parse(src)
fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "render_pl9_markdown")
defs = [d for d in ast.walk(fn) if isinstance(d, ast.FunctionDef) and d is not fn]
print("lines", fn.end_lineno - fn.lineno + 1, "nested defs", len(defs),
"imports", sum(isinstance(d, (ast.Import, ast.ImportFrom)) for d in ast.walk(fn)))
print("duplicate names", [k for k, v in collections.Counter(d.name for d in defs).items() if v > 1])
top = symtable.symtable(src, "jyotish_engine.py", "exec")
render = next(c for c in top.get_children() if c.get_name() == "render_pl9_markdown")
def walk(t):
for c in t.get_children():
yield c; yield from walk(c)
real = {(d.name, d.lineno) for d in defs}
for c in walk(render):
if (c.get_name(), c.get_lineno()) in real:
print(c.get_name(), c.get_lineno(), sorted(c.get_frees()))
```
## 10. 交付物清单
- 代码:`scripts/report_render/__init__.py`、`envelope.py`、`text.py`、(可选)`sections_misc.py`;`scripts/jyotish_engine.py`(只删被搬函数并加导入);`scripts/research/report_render_split_diff.py`。
- 测试:`tests/test_report_render_package.py`。
- 记录:`docs/tasks/PROGRESS-pl9-render-split-20261013.md`。
- 回报:分支 head SHA、搬移 / 排除清单、差分 sha 表(屏蔽与不屏蔽 PyJHora)、前后行数、diff 规模。
## 11. 后续阶段(本单不做)
1. **第二阶段**:77 个依赖外层数据的嵌套函数改显式参数(建议引入一个只读上下文对象承载 `packet`、`worksheets`、`advanced_sheet`、`timing_sheet` 等 48 个变量),按章节搬到 `scripts/report_render/sections_*.py`;先处理 `_degree_text` 重名(明确每个调用点在运行时实际调用的版本,拆成两个不同名函数);`_auxiliary_dasha_section`(761 行)单独一轮。
2. **第三阶段**:渲染器按受众参数统一——`render_pl9_markdown`(参考版)、`pl9_reader_export.py::render_pl9_parity_markdown`、`pl9_full_data_export.py::_render_pl9_ai_density_raw_data_markdown_zh/_en`、`pl9_reader_english.py::render_pl9_user_markdown_en` 同一份数据四套渲染,合并为「一个渲染器 + 受众 / 语言参数」。
3. **第四阶段**:擦洗函数退役——`_sanitize_pl9_ai_density_markdown`(451 行)、`_sanitize_pl9_ai_density_markdown_en`(389 行)、`_strip_pl9_user_engineering_markers`(258 行)把「先写工程标签再用正则擦」改为渲染时直接写面向读者的文字;逐字节 golden 守门,有意的文案变化单独立单。
4. **第五阶段**:`cmd_full_reading`(1,554 行、函数内 import 48 条)按计算阶段拆分;`build_pl9_style_export_packet`(934 行)同法。
5. **需产品拍板后另开单**:`full_data` 路径是否继续跑结果无人读取的质量门(每份报告调用一次 `render_pl9_markdown`);`reference` edition 与 PDF 导出是否下线。两者都定下来后,`render_pl9_markdown` 可能整体降级为开发工具甚至删除。