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

20 KiB
Raw Blame History

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. 开工前置命令

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 -  < 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 可能整体降级为开发工具甚至删除。