diff --git a/docs/research/code_health_audit_2026_10_28.md b/docs/research/code_health_audit_2026_10_28.md new file mode 100644 index 00000000..cba5d2ff --- /dev/null +++ b/docs/research/code_health_audit_2026_10_28.md @@ -0,0 +1,409 @@ +# 代码体检 2026-10-28(issue #118) + +基线 origin/staging `0f0fea325`。四路只读审计(前端加载与屎山、后端与推理速度),Claude 抽查了排在前面的结论。体检结论摘要与拆单清单见 Gitea issue #118 的评论;本文件是两份原始报告的合并稿,供拆出的小单引用数字和测法。 + +口径:「实测」= 审计机本地计时;「估」= 推算或取自 10-04~10-07 的进度记录。审计机没有模型 key、没有 PyJHora 与 VedAstro SDK,涉及这些的生产耗时都是估计。出生资料只用公开名人和一张虚构盘。 + +--- + +# 代码体检(后端 + 推理速度)· 2026-10-28 + +基线 `origin/staging` 0f0fea325(audit-20261028 工作树,只读,`git status` 干净)。 + +本机条件: +- Python 3.13,swisseph 走 `~/.local`。 +- 没有 PyJHora、没有 VedAstro SDK。VedAstro 网络关闭。 +- 没有模型 key。 + +标注口径: +- 「实测」= 本机计时。 +- 「估」= 推算,或取自 10-04 ~ 10-07 的进度记录。 +- 出生资料只用两组:公开名人(Einstein、Helen Keller)和一张虚构盘。 + +## 结论先行 + +1. **咨询最慢的一段仍是答题步推理。** 「推理 token 1 万已修」实际上没生效:开关 `CONSULT_ANSWER_REASONING_EFFORT` 默认不设,compose 里也没有,A/B 一直卡在 BLOCKED。 +2. **后端可以一张单拿下的最大块是长报告冷生成,实测 15–23 s。** + - 已有的提速开关 `JYOTISH_REPORT_RENDER_FAST` 输出逐字节相同,本机实测快 27%,却默认关着。 + - 另有约 9 s(profile 口径)花在整包 deepcopy 和 5 次重复哈希上。 +3. **10-21 PyJHora 进了生产(#93)以后,10-15「装不上就不起子进程」的省法在生产上不再生效。** 报告和非 skip 排盘每次都会起 PyJHora 子进程(估)。 +4. **咨询每次调 `consultation_workflow`,只要 VedAstro 不命中,就起 3 个 Python 子进程。** + - 本机实测:0.55 s 里有 0.40 s 花在这上面。 + - 生产上最多等 1.5 s。 + - 失败结果只缓存 300 s。 +5. 两条链路都还有「第 0 步模型调用」的白耗: + - 校正:强制先调一次 read-case。 + - 咨询:第 0 步开着思考,分类又串行挡在前面。 +6. 屎山方面: + - 226 个顶层脚本(约 3.4 万行)从运行入口不可达。 + - 20 个模块按 2~3 个名字各加载一份。 + - Vimshottari 仍有两套起算口径,差 1 天。 + +## 问题清单(按影响从大到小) + +| # | 位置 | 问题 | 量化影响 | 建议改法(一张小单) | 验收标准 | 风险 | +|---|---|---|---|---|---|---| +| 1 | `frontend/src/mastra/consultation-tools.ts`:`consultAnswerReasoningEffort` / `answerEffortOptions`(183–207 行);`BLOCKED.md` 第 23 行 | 答题步推理强度开关不设就返回 `undefined`。compose 里没设,A/B(TASK-answer-effort-ab)因为没有 key 一直没跑。10-04 量到的「答题步推理 1 万 token」实际上还在 | 估:答题步推理 token 中位 9.5–11.9k,约 45–60 s(10-04 实测记录)。这是咨询最慢的一段 | 在有 key 的 staging 跑完 answer-effort-ab 的 T3。产品定档后写进 compose(或 `.env`),代码不改 | `/admin/usage` 分段列里,第 1 步推理 token 中位数和写正文用时都下降;回答质量盲评不劣于基线 | 解读深度下降;部分供应商不认这个参数 | +| 2 | `scripts/report_perf_flags.py`:`RENDER_FAST_DEFAULT = False` | 渲染快捷路径输出逐字节相同,但因为没达到当时定的 35% 门槛就默认关着 | 实测(虚构盘,full_data zh+en,缓存关):关 15.75 / 15.27 s,开 11.20 / 11.64 s,**快 27%**。两边整份响应 sha256 相同(`59c793e0b28c`,5,262,387 B)。HTTP 冷跑 23.3 s / 6.1 MB,命中 0.08 s | 把默认值改成 True,或在 compose 里设 `JYOTISH_REPORT_RENDER_FAST=1`。保留旧路径作回退 | 用 `scripts/research/report_reference_perf_ab.py`(屏蔽 / 不屏蔽 PyJHora 各跑一遍):四张盘五字段 sha 相同;端到端 −25% 以上 | 低。快捷路径已有逐字节对照测试 | +| 3 | `scripts/pl9_full_data_export.py:read_full_data_domain`(2475 行);`scripts/calculation_profile_contract.py:hash_bound_result`(567 行)/ `_drop_key_recursive`(463 行) | 长报告里有两处重复劳动。① 一份报告调 10 次 `read_full_data_domain`,每次都把整包 `worksheets` deepcopy 一遍。② 溯源哈希算 5 次,每次都递归删键再哈希 | 实测(cProfile 口径,总 40.8 s):deepcopy 合计 8.9 s,其中 `read_full_data_domain` 5.3 s;`hash_bound_result` 5 次共 4.3 s,`_drop_key_recursive` 调了 407 万次。按无 profile 折算约 4–6 s / 份(估) | ① 同一个包只深拷一次,各领域共用只读视图;或者只拷该领域要的 worksheet 子树。② 同一个结果对象的绑定哈希按 `id` 记一次,或在 5 处调用的上游只算一次 | 冷生成再降 25% 以上;markdown、json 两版与改前逐字节相同;`result_binding` 哈希值不变 | ① 下游若原地改了拷贝,会串改共享数据。要先 grep 写入点,或改用 MappingProxy 防写 | +| 4 | `scripts/report_render/annual_method_reference.py:_invoke_worker`(169 行);`scripts/annual_pyjhora_replay.py`(202 行子进程);`scripts/tajika.py`(1517 / 1579 行);`requirements.txt:7 PyJHora==4.8.7` | 10-15 的 BUG-1333/1334 靠「父进程导入不了 jhora 就不起子进程」省时间。10-21 #93 把 PyJHora 装进了生产,这个省法在生产上不再生效。现在每份报告、每次非 skip 的 `/api/chart` 都会起:年度回放子进程(超时 30 s)、Tajika 子进程、年度方法 worker(每次新开 `python -m`)。worker 起之前也不查 jhora 在不在 | 实测(本机没装 jhora,子进程秒退):一份报告 5 次 `subprocess.run`,等待 5.3 s,其中 worker 4.8 s。估:生产上每个子进程要重新 import jhora 和 swisseph,每个 1–3 s,单份报告多花 5–10 s | 起一个常驻 PyJHora 子进程,或进程内单例:一次 import,多次调用,按请求哈希缓存结果。另外 worker 也要先查 `find_spec`。放在新模块,主文件不增长 | 屏蔽 / 不屏蔽 PyJHora 两种口径下输出逐字节相同;一份报告的 `subprocess.run` 次数从 5 降到 0 或 1;冷生成时间下降 | 当初隔离进程是为了防 PyJHora 全局状态(岁差模式)污染主进程。常驻进程每次调用前都要显式重设岁差 | +| 5 | `scripts/vedastro_foreground.py`:`ForegroundVedastroSession`;`scripts/vedastro_service_adapter.py:_run_official_full_snapshot_case`(2549 行,依次起 capability runner、python bridge、capability catalog 三个子进程) | 咨询每次调 `/api/consultation_workflow`,只要快照不命中,就新开 3 个 Python 解释器。只有 `official_verified` 才写进前台快照缓存(`is_cacheable_gateway`);失败结果只在 adapter 层留 300 s。免费层限流时,几乎每轮每个领域都要重新等 | 实测(同一张盘换分钟,5 次中位):join 1.5 s 时 0.546 s,join 0 时 0.143 s,**VedAstro 占 0.40 s / 次**,3 个子进程。估:生产装了 SDK,又要走外网,每次未命中封顶 1.5 s;一问两个领域就是 3 s | 前台快照加负缓存:失败或限流按盘和日期记一整天。capability catalog(只用于覆盖率展示)挪到后台。可选:把三段子进程合成一个常驻 worker | 同一张盘当天第二次调用,`vedastro_gateway` 字段与改前一致,耗时 ≤0.15 s;成功路径输出逐字节不变 | 负缓存期内 VedAstro 恢复了也看不到。TTL 要和产品定 | +| 6 | `frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts:259` 的 `prepareStep`(stepNumber 0 只开放 `rectification-read-case`) | 校正每轮固定 5 次串行模型调用:分类、read-case、记证据、设焦点、写正文,都开思考 8192。其中第 0 步只是为了调 read-case,结果是确定的 | 估:每轮省 1 次带思考的模型调用,约 3–8 s(子代理按记录估)。09-26 记为 D6「另议」,至今没做 | 服务端先执行 read-case,把结果作为合成的工具调用和结果放进 bootstrap,模型从第 1 步开始 | `stepCount` 少 1;轮次中位耗时下降;`case.loaded` 回执和 Skill 收据各只写一次;read-case 投影逐字节不变 | 要改 Skill「第一步调用」的原文;涉及 BUG-282/937 的步骤约束 | +| 7 | 咨询:`consultationNatalPrepareStep` 与 `consultationGenerationSettings`(`thinking: "enabled"`);`route.ts` 中 `classifyConsultationTurn`(约 721 行) | 第 0 步开着思考,但只用来发一个工具调用。分类(超时 ≤3 s)又排在扣点和写入问题之后串行等 | 估:第 0 步 ≤5 s(10-04 记录);分类 ≤3 s | (a) 第 0 步关思考,或扣点后立刻按已选主题预开算(复用时要比对领域参数)。(b) 分类和 prepare / append 同时开始 | 分段列里第 0 步耗时下降;`prep.classify` 不再在首字关键路径上 | (a) 产品曾搁置过;(b) 判为寒暄时要撤销已经开始的工作 | +| 8 | `scripts/consult_full_data_cache.py`:`get_or_build` / `_read_cached`;`pl9_full_data_export.read_full_data_domain` | 咨询卡缓存命中时也不便宜:每个领域每轮都要读 5.5 MB JSON,再整包 deepcopy。缓存键含服务器当天日期,所以每天首问冷算。缓存文件权限是 0644,含出生资料,没跟随 0600 规则 | 实测:命中路径 `attach_consult_full_data` 0.425 s(profile)/ 约 0.2 s(正常),其中 JSON 解析 0.06 s、deepcopy 0.2 s;HTTP 冷首问 5.4 s。估:跨日首问 4.4–7.3 s(10-07 记录) | ① 进程内按 key 做小 LRU 缓存已解析的包(2–4 份),读视图时不再深拷。② 进入咨询页或 `runTurn` 开头不等待地发一次 `consult_card_warm_only`。③ 写缓存改用 `write_private_json` | 命中路径 ≤0.05 s / 领域;跨日首问 `consult_card_facts.timing.hit=true`;`consult_card_facts` 逐字节不变;新文件权限 0600 | LRU 占内存,5 MB × N,要在 4 GB 主机上核算;预热占重计算名额 | +| 9 | `scripts/jyotish_api_server.py:27/10885`,`ThreadingHTTPServer` 单进程 | 重计算(v5 score、block_scan、报告)都是纯 Python CPU,受 GIL 限制。2 vCPU 的第二核在 Python 侧基本闲置,`JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 等于两人抢一个核 | 实测(子代理):121 候选打分单跑 1.19 s;两路并发各 2.35–2.6 s(约 2.2 倍) | 新模块里给 v5 score / block_scan / 报告开进程池,`max_workers` = 重计算并发数,主文件只做薄注册 | 两路并发各 ≤1.3 倍单跑;规范化 JSON 与改前一致 | 内存(每个 worker 带一份引擎);worker 必须固定 `PYTHONHASHSEED`(ERR-111) | +| 10 | 校正交付期:`jyotish_api_server.py:_attach_vedastro_minute_sensitive_to_score`(约 428–515 行);前端 `score-persist.ts:persistableReceipt`(约 330 行) | 交付期 score 内部最多等 3 s 快照,随后前端又串行调 `/v5/vedastro-validate`(超时 12 s)。Python 侧区间扫描依次发,每次 20 s,前端放弃后它还在跑,并占着重计算名额。状态不是 passed 就每次重跑 | 估:交付期一轮最坏约 15 s;失败时每轮重复 | validate 结果按(证据指纹, 候选对)做负缓存;区间扫描按分钟 / 领域 / 日期缓存;Python 侧超时收到不超过前端的超时 | 同一状态第二次调用耗时接近 0;VedAstro 成功时回执逐字节不变 | 负缓存期内看不到恢复 | +| 11 | 咨询窗口问法:`consultation-tools.ts:executeWindowConsultation` | `/api/declared_window_chart` 和 `/api/rectification/v5/range_reading` 串行,但后者不依赖前者的结果 | 估:0.3–1 s / 轮 | 改成 `Promise.all` | 工具耗时 ≈ 两者中较长的一个;输出不变 | 低 | +| 12 | 开关默认值:`frontend/src/lib/consult/prep-flags.ts`(`CONSULT_PREP_PARALLEL` 关,`MODEL_VERSION_CACHE_TTL_MS` 0,`CONSULT_DOMAIN_CONCURRENCY` 1);`api_heavy_compute_gate.py`(`JYOTISH_GATE_CHART_OVERVIEW` 关) | compose 里都没设,`.env` 按要求没读。关着时:首帧前多等一次审核(接阿里云时最多 1.5 s);两领域问题串行(每领域 0.75–1.66 s) | 估:每轮 0.1–3 s,取决于审核供应商和领域数 | 先在服务器核对 `.env` 的 `MODERATION_PROVIDER`。是 aliyun 就开 `CONSULT_PREP_PARALLEL=on`。领域并发等第 9 条做完再开 | `consultation-prep-v1` 日志里首帧前耗时下降 | 领域并发会让一个用户占满 2 个重计算名额 | +| 13 | 校正:`dasha_transition_proximity.py:164-181`、`rectification/event_probes.py:1812` | 大运起点表每个候选重复算 3 遍(vim 两遍、那罗延一遍,引导窗口再一遍) | 实测(子代理):`_vim_start_dates` 479 次、`_narayana_start_dates` 542 次(121 候选);约占打分 20%,估 −0.15–0.25 s / 121 候选 | 静态上下文顶层每个候选建一张全量表,两处按年份过滤取用 | 两函数调用次数各 ≤121;11 个场景 sha256 不变 | 要按研究协议重新冻结(ERR-110) | +| 14 | `scripts/jyotish_api_server.py:_load_local_module`(2533 行,用 `_jyotish_local_` 另起一份模块),再加上 `scripts.X` / `X` 两种 import 写法 | 同一个文件按 2~3 个名字各加载一份。各份的模块级缓存(lru_cache、memo 字典、`_jhora_spec_cache` 等)互不共享,`isinstance` 跨份也会失败 | 实测:一次咨询 + 一次排盘后,105 个脚本模块里有 20 个重复加载(共 2.8 万行),`jyotish_engine` 加载了 3 份。内存只多几 MB,主要是缓存命中被拆散 | `_load_local_module` 先查 `sys.modules` 里的 `scripts.` / ``,有就复用;统一 `sys.path` 口径。只改加载器,不动调用点 | 重复加载数 20 → 0;增长护栏不涨;全量 pytest 与基线失败清单一致 | 有个别测试依赖「独立副本」去打补丁,要跑全量 | +| 15 | `scripts/dasha_analyzer.py:build_dasha_timeline`(85 行,`strptime("%Y-%m-%d")`)与 `scripts/jyotish_engine.py:cmd_dasha`(约 12290 行,用真实出生时刻) | Vimshottari 还剩两套起算口径。`/api/chart`(经 `domain_calculation_service.compute_vimshottari_timeline`)和校正探针从**出生当天 0 点**起算;报告和完整解读从**真实出生时刻**起算。`dasha_analyzer` 里另有一份年数表字面量(白名单放行) | 估:两边 MD / AD 边界最多差 1 天。出生时刻越晚,日期串落到不同日的概率越高(约等于出生小时 / 24)。星盘页和报告可能对不上一天 | `build_dasha_timeline` 增加 datetime 入参,`domain_calculation_service` 传真实出生时刻;年数表改读 `dasha_common` | 用 golden 盘对比 `/api/chart` 与 `cmd_dasha` 的 MD / AD 边界,逐项相等;校正 11 个场景要重新冻结(会变) | 改变用户可见日期,属于数值变更,要按「引擎改值跑全量」执行 | +| 16 | `scripts/` 根目录 | 从运行入口(api_server、engine、mcp_server、前端 / deploy 引用的脚本、quality_gate、pre_work_check)静态不可达的脚本有 226 个,约 33.9k 行;其中 208 个只被 tests 引用。`dasha_calculator.py` 已无任何运行时引用。python-hygiene 只加了「数量只降不升」的护栏 | 实测:459 个顶层脚本里 226 个不可达(字符串匹配宽松,这是下限) | 先把台账 / closure / matrix / inventory 类(约 84 个文件名命中)连同只测它们的测试,一起挪到 `tools/ledger/` 或删除;一单只处理一个前缀 | 快速门与全量 pytest 失败清单不变;`scripts/` 根目录 `.py` 数下降 | 有些被 workflow 或人工命令调用,要先核对 `.gitea` 和 docs | + +### 后端超长文件前 10(行数,实测) + +| 文件 | 行数 | +| --- | --- | +| `scripts/jyotish_engine.py` | 18,679 | +| `scripts/classical_astrology.py` | 13,065 | +| `scripts/jyotish_api_server.py` | 10,949(冻结) | +| `mcp_server.py` | 5,220 | +| `frontend/src/lib/personal-report-generation.ts` | 3,740 | +| `frontend/src/lib/rectification-agentic/v9/method-followup.ts` | 3,574 | +| `scripts/pl9_reader_export.py` | 3,390 | +| `scripts/muhurta.py` | 3,373 | +| `scripts/vedastro_service_adapter.py` | 3,214 | +| `scripts/report_render/parity_chapters.py` | 2,767 | + +其后依次是 `answer-choice.ts` 2,641、`rectification-v9-tools.ts` 2,638、`pl9_full_data_export.py` 2,498。 + +### 超长函数前 10(实测) + +| 函数 | 位置 | 行数 | +| --- | --- | --- | +| `render_pl9_markdown` | `jyotish_engine.py:3665` | 6,506 | +| `render_pl9_parity_markdown` | `pl9_reader_export.py:637` | 2,754 | +| `cmd_full_reading` | `jyotish_engine.py:15368` | 1,653 | +| `build_pl9_style_export_packet` | `jyotish_engine.py:17023` | 934 | +| `_render_pl9_ai_density_raw_data_markdown_en` | `pl9_full_data_export.py:1431` | 850 | +| `_auxiliary_dasha_section` | `jyotish_engine.py:7608` | 761 | +| `_sanitize_pl9_ai_density_markdown` | `pl9_full_data_export.py:97` | 451 | +| `main` | `jyotish_engine.py:18239` | 437 | +| `_derive_event_judgement` | `mcp_server.py:2317` | 423 | +| `_release_adjudication` | `classical_astrology.py:801` | 419 | + +另外:`execute_consultation_workflow` 400 行(`api_server:2131`);`_sanitize_pl9_ai_density_markdown` 一个函数在 profile 里占 8.6 s,主要是 7.1 s 的 `re.sub`,第 2 条的开关打开后会走快捷路径。 + +## 10-13 体检项核实结果(看代码与实测,不看状态板) + +| 项 | 结论 | 证据 | +| --- | --- | --- | +| 报告 worker `defaultSleep` 漏摘监听 | **已修** | `personal-report-worker-core.ts:197`,到点就 `removeEventListener` | +| `guided_collect_windows` 按候选对重复算 | **已修** | `event_probes.py:1871` 起按(轨道, 序号)去重;`_context_time` 从 278 万次降到 2.4 万次(子代理实测) | +| 功能吉凶 / Narayana 每候选×事件×日重复算 | **已修(循环内)** | `derive_functional_benefic_malefic` 从 6,519 次降到 121 次;`_active_narayana` 改走子运表;探针路径还剩 348 次 `get_current_narayana_dasha`,约 0.03 s | +| 打分响应 2 MB 含前端不读字段 | **已修** | 矩阵只在 `include_internal` 时返回,实测 0.84 MB。另外特征快照里 `kp_cusps` 约 298 KB 没人读,属卫生问题 | +| 长报告 18.5 s 不缓存 | **部分** | full_data markdown 按 `GITHUB_SHA` 缓存,命中实测 0.08 s;但冷生成仍 15–23 s,每次部署全部失效;提速开关默认关(第 2、3 条) | +| `/api/chart` 非 skip 每次起 2 个子进程、不受闸门管 | **部分,生产上已回退** | 前端请求全部带 skip(有合同测试);闸门开关 `JYOTISH_GATE_CHART_OVERVIEW` 默认关;「装不上 PyJHora 就不起子进程」在 #93 装进生产后失效(第 4 条) | +| chart 缓存清理扫错目录 | **已修** | `api_scratch_retention.py` 登记了 `api_chart_cache` 等 9 个目录;但 `consult_full_data_cache` / `report_reference_cache` 文件仍是 0644(第 8 条) | +| `render_pl9_markdown` 单函数 6917 行 | **部分** | 第一阶段搬出 34 个嵌套函数,现在 6,506 行、114 个嵌套函数,第二阶段没做 | +| api_server 伪造 handler(#70) | **已修(scripts 内)** | `scripts/` 和 `mcp_server.py` 里已没有 `JyotishAPIHandler.__new__`,改用 `JyotishComputeService` 和 `offline_compute_mixins.py`;tests 里还有 10 处 `__new__` / `object.__new__` | +| dasha 两套实现(#69) | **部分** | 年数表已收进 `dasha_common`(`dasha_analyzer` 和 `tajika` 在白名单里);但起算口径仍是两套(第 15 条);`dasha_calculator.py` 无运行时调用方,`dasha_calculator_enhanced` 仍被 api_server 用作「五级大运」 | +| 138 个只被测试用的台账脚本 | **未修** | 现在静态不可达 226 个(33.9k 行),208 个只被测试引用;只加了数量护栏(第 16 条) | +| 旧校正链约 2.6k 行 | **已修** | 旧 agentic 链(`rectification-tools.ts`、`session.ts`、`/api/birth-rectification` 等,合计 3,191 行)已删;`birth-time-journey*` / `birth-time-scoring*` 约 4.8k 行仍有首页引导在用,不是死代码 | + +## 环境缺口 + +- 没有模型 key:第 1、6、7 条的秒数取自 10-04 ~ 10-07 的记录,要用 staging `/admin/usage` 分段核实。 +- 没有 PyJHora 和 VedAstro SDK:第 4、5、10 条的生产耗时是估计,本机只量到「子进程秒退」时的下限。 +- `.env` 没读:第 1、12 条的开关在生产上是否另行设置,需要产品在服务器核对。 + +--- + +# 前端体检(加载慢 + 屎山)· 2026-10-28 + +基线:`.worktrees/audit-20261028` = origin/staging `0f0fea325`。只读,没有改仓库。构建用 Node 22 跑 `next build`,再跑 `next experimental-analyze -o`,按模块归因。`/` 仍是 `○ Static`。 + +**口径。** 首屏 gzip 的量法和 DESIGN.md 一样:取 `.next/server/app/index.html` 引用的 39 个 js/css 块,每个 `gzip -9` 后求和。 +- 实测 **702,985 B**。#111 接受的基线是 694,941,现在 +1.16%,还在 ±2% 内。 +- 其中 39,392 B 是 `polyfill-nomodule`(`