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
188 lines
20 KiB
Markdown
188 lines
20 KiB
Markdown
# 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` 可能整体降级为开发工具甚至删除。
|