diff --git a/docs/tasks/PROGRESS-api-server-backdoor-close-20260916.md b/docs/tasks/PROGRESS-api-server-backdoor-close-20260916.md index dc195278..04702492 100644 --- a/docs/tasks/PROGRESS-api-server-backdoor-close-20260916.md +++ b/docs/tasks/PROGRESS-api-server-backdoor-close-20260916.md @@ -1,105 +1,250 @@ # PROGRESS · 关掉 `JyotishAPIHandler.__new__` 后门 - 日期:2026-09-16 -- 分支:`codex/api-server-backdoor-close-20260916` -- 基线:`origin/staging` @ `37e6c519`(任务书写的 `4f643aa0` 已被超车) -- 状态:**§5.1 完成,§5.2 起停手待决**——闭包重算结果推翻了任务书「8 个方法 / 314 行」的规模前提 +- 分支:`codex/api-server-backdoor-close-20260916`(rebase 到 `origin/staging` @ `5094fd26`) +- 任务书:`docs/tasks/TASK-api-server-backdoor-close-20260916.md`(第二版,B / mixin 方案) +- 结论:**spike 闸门红 → 按 §6.3 退回 A**。scripts 侧 `__new__` **4 → 2**,未归零。 --- -## 1. §5.1 传递闭包重算(强制第一步) +## 1. §5.1 闭包复核(在 `dc8cae31` 代码上重算) -方法:`ast` 解析 `scripts/jyotish_api_server.py`,从四处 `JyotishAPIHandler.__new__` 实际借用的入口出发,沿 `self._x()` **以及模块级函数的 `handler._x()`** 追传递闭包,并对每个成员扫 `self.headers` / `wfile` / `rfile` / `path` / `client_address` 等 HTTP 上下文属性。 +`ast` 解析,从四处伪造实际借用的入口出发,沿 `self._x()` **以及模块级函数的 `handler._x()`** 追传递闭包。 -### 1.1 入口(四处伪造实际借用的方法) - -| 位置 | 借用 | -| --- | --- | -| `scripts/consultation_workflow_service.py:20` | 模块级 `execute_consultation_workflow(handler, ...)` | -| `scripts/consultation_workflow_service.py:27` | `_high_rigor_vedastro_official_summary`、`_compute_vedastro_gateway_archives`、`_interpretation_source_runtime_coverage` | -| `scripts/capture_report_blocked_repairs_golden.py:59` → `:234` | `_compute_consultation_workflow` | -| `scripts/local_accuracy_report.py:141` | `_compute_synastry` | - -### 1.2 实测结果 - -| | 任务书 §1 | **实测** | -| --- | ---: | ---: | -| 需要搬的类方法 | 8 | **115** | -| 合计行数 | 314 | **4,104** | -| 其中碰 HTTP 上下文 | 0 | **0** | -| 另外途经的模块级函数(已在类外,不需搬) | — | 65 个 / 2,028 行 | -| 占全类(regex 225 方法 / 11,291 行)比例 | 4 % / 4 % | **51 % / 36 %** | - -**前提成立的那一半**:闭包里 **0 个**方法碰 HTTP 上下文。任务书 §5.1 的停手条件(有方法碰 HTTP)未触发。 - -**前提不成立的那一半**:规模是任务书估算的 **13 倍**。 - -### 1.3 按入口拆分——闭包分裂得很干净 - -| 入口 | 闭包方法数 | 行数 | 可搬? | -| --- | ---: | ---: | --- | -| `_compute_vedastro_gateway_archives` | 1 | 4 | ✅ | -| `_interpretation_source_runtime_coverage` | 1 | 41 | ✅ | -| `_high_rigor_vedastro_official_summary` | 1 | 220 | ✅ | -| `_compute_synastry` | 4 | 35 | ✅ | -| `_compute_consultation_workflow` | **114** | **4,086** | ❌ | -| 模块级 `execute_consultation_workflow` | 113 | 4,080 | ❌ | - -前四项去重合计 **7 个方法 / 300 行**——这正是任务书「8 个方法 / 314 行」的来源。 - -### 1.4 任务书数字是怎么偏的(已定位) - -`execute_consultation_workflow` 是**模块级函数**,不是类方法。一次只跟 `self._x()` 的闭包走到它就停了,于是 `_compute_consultation_workflow`(自身 6 行)被当成叶子。但它签名是 `execute_consultation_workflow(handler, *, body, surface)`,函数体内对传进来的 `handler` 调了 **113 个**类方法。跟进这一层,闭包从 300 行炸到 4,104 行。 - -`consultation_workflow_service.py:20` 与 `capture_report_blocked_repairs_golden.py:59` 这两处伪造都落在这条链上。 - -### 1.5 闭包里的大方法(与任务书 §4.3 直接冲突) - -| 方法 | 行数 | +| | 实测 | | --- | ---: | -| `_build_chart_prompt_pack` | 289 | -| `_compute_chart_sync` | 280 | -| `_high_rigor_vedastro_official_summary` | 220 | -| `_derived_career_evidence` | 144 | -| `_compute_thematic_report` | 122 | -| `_derive_thematic_evidence` | 111 | -| `_compute_prashna` | 101 | +| 闭包类方法 | **115** | +| 合计行数 | **4,104** | +| 碰 HTTP 上下文(`headers`/`wfile`/`rfile`/`path`/`client_address`) | **0** | +| 闭包途经的模块级函数 | 65 个 / 2,028 行 | +| 移动集还需要的模块级类 | 2(`BadRequest`、`JobQueueFull`) | +| 移动集还需要的模块级赋值 | 26 | -`_build_chart_prompt_pack`(289)与 `_compute_chart_sync`(280)是 ≥150 行的业务方法,属于任务书 §4.3 明令**不得搬**的阶段 2 范围,却落在 §5.2 要求必须搬的闭包里。**§5.2 ∧ §4.3 在真实代码上不可同时满足。** +与第二版任务书 §1 的数字一致,前提「0 个碰 HTTP」成立。 -(`_high_rigor_vedastro_official_summary` 虽然 220 行,但它是后门入口本身、且含在任务书自己的 314 行估算内,不算 §4.3 的「顺手搬」。) +**循环 import 不是障碍**:移动集的自由名里**不含 `JyotishAPIHandler`**,方向是单向的 `jyotish_api_server → mixin`。任务书 §5.2 点名的这条风险,实测可解。 + +闭包按入口分裂: + +| 入口 | 方法 | 行数 | +| --- | ---: | ---: | +| `_compute_vedastro_gateway_archives` | 1 | 4 | +| `_interpretation_source_runtime_coverage` | 1 | 41 | +| `_high_rigor_vedastro_official_summary` | 1 | 220 | +| `_compute_synastry` | 4 | 35 | +| `_compute_consultation_workflow` | **114** | **4,086** | --- -## 2. 另一处口径偏差:`__new__` 基线不是 4 +## 2. §5.2 spike 闸门:**红** -`tests/test_api_server_growth_contract.py` 现行基线 `JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 33`,注释写明「scripts/ production forgeries = 4, tests/ = 29」。 +机械切分(源码行切片,方法体逐字搬运):115 方法 → `ConsultationComputeMixin`, +`class JyotishAPIHandler(BaseHTTPRequestHandler, ConsultationComputeMixin)`, +连同 65 个模块函数 / 2 个类 / 26 个赋值一并移入新模块。两文件均可解析、可 import, +MRO 正常(`_compute_synastry` / `_compute_consultation_workflow` 都能解析到)。 -任务书 §4.1 要求 `grep -rn "JyotishAPIHandler.__new__" --include=*.py .`(排除 `skills/*/versions/**`)**零命中**。该 grep 覆盖 `tests/`,其中 29 处分布在 20 个测试文件里,**包括 `tests/test_api_server_security.py:34`**——而 §4.2 规定该文件不许改。 +`tests/test_api_server_security.py` **一个字不改**直接跑: -因此「零命中」只能理解为 **scripts/ 生产侧零命中**(4 → 0),tests/ 的 29 处保持不增长。这与 §4.1 括号里的「基线 4 → 0」一致。 +``` +1 failed, 128 passed in 43.17s +FAILED tests/test_api_server_security.py::test_chart_async_job_executes_in_background +``` + +### 2.1 红在哪、为什么 + +```python +monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', fake_write) # 测试第 3768 行附近 +... +assert writes[0][0] == 'api_chart_response' # IndexError: list index out of range +``` + +`_enqueue_chart_job` 随 mixin 搬到新模块后,它对 `_write_async_job_record` 的查找走 +**新模块的 globals**;而测试把补丁打在 `jyotish_api_server` 的 globals 上。两个绑定不再是同一个, +补丁静默失效,`writes` 始终为空。 + +**这不是搬坏了代码**,已实证:把同一个 fake 改打到 mixin 模块上,行为与搬运前完全一致—— + +``` +endpoint: chart_async mode: async_submitted status: queued +writes captured: 3 first scope: api_chart_response # 正是测试断言的值 +``` + +### 2.2 为什么不能绕过 + +security 测试用 `jyotish_api_server.X` 打模块级补丁的名字共 6 个,其中 3 个落在移动集里 +(`_attach_vedastro_main_entry_overview`、`_load_local_module`、`_write_async_job_record`)。 + +任何 B 形态的切分,只要「被打补丁的函数」与「调用它的方法」一起离开 `jyotish_api_server` 的 globals, +这条断言就会红。提到第三个共享模块也一样(`from third import X` 在两边各建一个绑定)。 +唯一能保住绿的做法是给 `jyotish_api_server` 换一个自定义 module 类、劫持 `__setattr__` 往 mixin 模块转发—— +那是新增机制、不是纯搬运,属于任务书 §6.3 明令的「硬做 B」。 + +按 §6.3:**退回 A,不硬做 B。** spike 产物已丢弃,`scripts/` 回滚干净后重做。 --- -## 3. 可行性预检(为 §5.2 备) +## 3. 实际交付(A 方案) -两组小闭包搬出去后对 `jyotish_api_server` 的模块级依赖,均可解析、无反向 import 死结: +新建 `scripts/offline_compute_mixins.py`(412 行): -| 组 | 方法 | 需一并处理的模块级名字 | -| --- | --- | --- | -| vedastro 证据 | `_high_rigor_vedastro_official_summary`、`_compute_vedastro_gateway_archives`、`_interpretation_source_runtime_coverage` | `_build_vedastro_official_full_snapshot_payload_from_chart`(L2942)、`_free_tier_queue_enabled_env`(L2564)、`_preferred_strict_contract`(L3005) | -| 合盘 | `_compute_synastry`、`_get_float`、`_check_range`、`_normalize_degree` | `BadRequest`(L3177) | +| 内容 | 成员 | +| --- | --- | +| `BadRequest` | 从 api server 搬出(api server 改为 import 回来,205 处引用不变) | +| 3 个模块函数 | `_build_vedastro_official_full_snapshot_payload_from_chart`、`_free_tier_queue_enabled_env`、`_preferred_strict_contract` | +| `RequestParamMixin` | `_get_float`、`_check_range`、`_normalize_degree` | +| `VedastroEvidenceMixin` | `_high_rigor_vedastro_official_summary`、`_compute_vedastro_gateway_archives`、`_interpretation_source_runtime_coverage` | +| `SynastryMixin(RequestParamMixin)` | `_compute_synastry` | -这些依赖全在 `jyotish_api_server.py` 内部定义,需随组搬走或提到共享模块,否则新模块就得反向 import(违反 §4.4)。 +`class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryMixin)`, +HTTP 侧通过继承拿回全部 7 个方法(`self._get_float` 等 74 处调用点一行未改)。 + +两个调用方改成直接实例化 mixin,不再伪造 handler: + +- `scripts/consultation_workflow_service.py:27` `build_runtime_evidence_helpers` → `VedastroEvidenceMixin()` +- `scripts/local_accuracy_report.py:141` → `SynastryMixin()._compute_synastry(...)` + +**仍在用 `__new__` 的 2 处**(都挂在咨询工作流链上,属于 spike 红掉的那一半): +`scripts/consultation_workflow_service.py:20`、`scripts/capture_report_blocked_repairs_golden.py:59`。 + +### 3.1 改前 / 改后 + +| 指标 | 改前 | 改后 | +| --- | ---: | ---: | +| `JyotishAPIHandler` 类方法数(`^ (?:async )?def`) | 225 | **218** | +| `scripts/jyotish_api_server.py` 行数 | 11,291 | **10,924** | +| 新模块行数 | — | 412 | +| `__new__` scripts 侧 | 4 | **2** | +| `__new__` tests 侧 | 29 | 29(未动) | +| `__new__` 合计 | 33 | **31** | + +### 3.2 §4.2「方法体一字不改」的证明 + +`git show HEAD:scripts/jyotish_api_server.py` 与新模块各自 `ast` 取定义源码切片,逐个 SHA-256 比对, +**11 个搬走的定义全部字节级相同**: + +``` +IDENTICAL M:_get_float cf428670791b cf428670791b (10 lines) +IDENTICAL M:_check_range 5cb38a5550ba 5cb38a5550ba (5 lines) +IDENTICAL M:_normalize_degree b2ac5ec5533a b2ac5ec5533a (2 lines) +IDENTICAL M:_compute_synastry ad868c3b8e1f ad868c3b8e1f (18 lines) +IDENTICAL M:_high_rigor_vedastro_official_summary 42219ca0bef6 42219ca0bef6 (220 lines) +IDENTICAL M:_compute_vedastro_gateway_archives faa9643b7287 faa9643b7287 (4 lines) +IDENTICAL M:_interpretation_source_runtime_coverage 333b5c13515c 333b5c13515c (41 lines) +IDENTICAL T:_build_vedastro_official_full_snapshot_payload_from_chart d1cafe318865 d1cafe318865 (61 lines) +IDENTICAL T:_free_tier_queue_enabled_env 63a3fd275267 63a3fd275267 (7 lines) +IDENTICAL T:_preferred_strict_contract e251697dd292 e251697dd292 (6 lines) +IDENTICAL T:BadRequest 99667903a999 99667903a999 (2 lines) +``` + +搬运用按行区间删除实现,`jyotish_api_server.py` 的 diff 形态是 +**11 行插入(import 块 + 类头一行)/ 378 行删除**,没有重排、没有空行规范化。 --- -## 4. 环境 +## 4. §5.5 合同测试 -`python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` → `exit=0`,六项检查全绿(python_runtime / fragment_scan / external_engine_adapters / remote_visibility=verified / focused_tests)。 +`tests/test_api_server_growth_contract.py`: + +- 行数 baseline `11291` → `10924`(+300 粗护栏不变) +- 类方法数 baseline `225` → `218` +- `__new__` 合计 baseline `33` → `31` +- **新增** `JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 2` 与 `test_scripts_handler_forgeries_must_not_grow`, + 单独锁 scripts 生产侧,注释写明目标仍是 0 及剩下 2 处为什么没关 +- **新增** `test_offline_mixins_must_not_import_the_http_monolith`(§4.3 的方向断言,`ast` 查 import) +- 原有三条断言(`AGENTS.md` 含 `must not grow` / `thinly registered`、该测试在 `CORE_PYTEST_TARGETS` 与快速门里)未改,仍绿 + +`7 passed`(原 5 条 + 新增 2 条)。 + +### 4.1 两次反向验证 + +**① 在 `scripts/` 加回一处 `JyotishAPIHandler.__new__`** → 必须红: + +``` +FAILED tests/test_api_server_growth_contract.py::test_jyotish_api_handler_new_count_must_not_grow +FAILED tests/test_api_server_growth_contract.py::test_scripts_handler_forgeries_must_not_grow +2 failed, 5 passed +``` + +**② 给 `JyotishAPIHandler` 加一个类方法**(218 → 219)→ 必须红: + +``` +FAILED tests/test_api_server_growth_contract.py::test_jyotish_api_handler_method_count_must_not_grow +1 failed, 6 passed +``` + +两次探针均已还原,工作树无残留。 --- -## 5. 待决 +## 5. 新增端到端守卫 -见回报。§5.2 / §5.3 / §5.4 未动,`scripts/` 与 `tests/` 零改动。 +`build_runtime_evidence_helpers` 原本**零测试覆盖**(只有 `mcp_server.py:4751` 在调)。 +新增 `tests/test_offline_compute_mixins.py`(4 条)覆盖 §5.4 验收: + +1. `SynastryMixin()._compute_synastry` 与 `ashtakoot.calculate_ashtakoot` 逐字段一致(`local_accuracy_report` 路径) +2. `build_runtime_evidence_helpers` 不伪造 handler 也能返回三个键(MCP 路径,端到端) +3. `ast` 断言该函数体内已无 `JyotishAPIHandler` +4. `JyotishAPIHandler` 仍持有全部 7 个方法、继承链正确(HTTP 侧零变化) + +同时把该文件加进 `scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`——该列表自带注释 +「不在这个清单里的守卫在 CI 里永远不跑」,本单的后门守卫必须进门禁。 + +--- + +## 6. §7 验收 + +``` +pytest tests/test_api_server_security.py tests/test_api_server_growth_contract.py \ + tests/test_consultation_consumer_context.py tests/test_vedastro_gateway.py \ + tests/test_vedastro_snapshot_cache.py +→ 190 passed +``` + +`tests/test_api_server_security.py` 单独跑:**129 passed,文件一字未改**(`git diff --stat tests/` 对该文件为空)。 + +快速门(退出码直接取,不隔管道): + +``` +.venv/bin/python scripts/run_quality_gate.py --profile quick → EXIT=127 +pytest 段:798 passed, 1 skipped, 0 failed +失败步骤:step: npm test → sh: 1: tsx: not found +``` + +pytest 段 0 failed,且比 `dc8cae31` 基线(792 passed / 1 skipped)**多 6 条**, +正好是本轮新增的 4 条(`test_offline_compute_mixins.py`)+ 2 条(合同测试)。 + +`tsx: not found` 是**本工作树没装 `frontend/node_modules`**(主检出 `/workspace/Jyotisha/frontend/node_modules` 有), +不是仓库缺口,也与本单纯 Python 改动无关,故未新开 BLK 条目。 + +开工预检 `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` → `exit=0`,六项全绿。 + +### 6.1 门禁外的定向抽查(`BadRequest` 搬家的下游) + +`BadRequest` 从 `jyotish_api_server` 搬进新模块后由 api server import 回来, +仓内两个直接 `from jyotish_api_server import BadRequest` 的测试照常解析同一个类对象: + +- `tests/test_declared_window_chart.py` —— 在快速门的 798 条里,通过 +- `tests/test_active_rectification_api.py` —— **不在快速门**,定向跑:`1 failed / 50 passed` + +那一条失败 `test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` +**是基线即红,与本单无关**。在 `5094fd26` 的干净工作树上单独复跑同一条, +失败停在同一行(`tests/test_active_rectification_api.py:666`)、同一断言: + +``` +{'start_time': '04:16'} != {'start_time': '05:07'} +``` + +属校正打分的期望漂移,不涉及本轮搬走的任何名字,未修(§4.6 不得顺手修不在本单里的问题)。 + +--- + +## 7. 没做的 / 留给下一轮 + +- **scripts 侧 `__new__` 未归零**,剩 2 处:`consultation_workflow_service.py:20`、 + `capture_report_blocked_repairs_golden.py:59`。两者都要整条咨询工作流闭包(114 方法 / 4,086 行)搬走, + 而搬走就会触发 §2.1 的 monkeypatch 落点问题。**下一轮要先解决的是那 3 个被模块级打补丁的名字怎么安置,不是体量。** + 一个可行方向:先把 `_write_async_job_record` / `_load_local_module` / + `_attach_vedastro_main_entry_overview` 连同各自调用方一起留在 api server, + 或把异步任务记录层单独抽成一个两边都 import 的模块并同步改测试落点(需产品批准动 security 测试)。 +- 阶段 3(`do_POST` 路由表)按 §9 不做。 +- 本单不产生 Bug 记录,不进 `CHANGELOG.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8ce02ac4..5a4c49e8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -232,7 +232,7 @@ | `TASK-rectification-title-repair-migration-20260915.md` | `PROGRESS-rectification-title-repair-migration-20260916.md` | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待验收(`d6c359b2`,分支 `codex/rectification-title-repair-migration-20260916`):迁移 `20260916020000_rectification_session_title_repair.sql`,SQL 由 `sed` 从脚本切出、合同测试比对逐字一致;幂等两层(迁移器记账 + where 改完即不再命中);顺带给 `db-migrate.mjs` 加 NOTICE 转发,否则 node-postgres 丢掉 NOTICE、产品在日志里一个数字都看不到。tsc 0 错、lint 0 error、`npm test` 3334/fail 31 对基线 3329/fail 31(+5 全绿)。**无 Docker/无本地 PG**:`test:db` 没跑、`db:migrate:check` 缺 `SCHEMA_DATABASE_URL`,记为 BLK-002 | `codex/rectification-title-repair-migration-20260916` | | `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` | | `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` | -| `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **已取代:阶段 1 由 `TASK-api-server-backdoor-close-20260916` 完成(实测只需 8 方法 / 314 行);阶段 2/3 产品拍板不立单,由新门禁长期推进。以下为原文** · 重构单(串行在 qizheng 单之后;2026-09-15 又加两条前置:C1 外网缓存单先做、freeze-metric-change 先落地)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是 3 个文件 **4 处**靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸——实测佐证:**225 个类方法里只有 12 处真的碰 HTTP 上下文**。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → **阶段 4 已改写**:收尾不再是「行数 baseline + 余量 300→50」(那只是把问题推到三个月后),改成主门 `__new__` 计数必须为 0 + 类方法数不得增长,行数退为粗护栏保持 300 余量。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,334 → 约 9,230 行。BUG 段 710+ | 已取代 | 见 `TASK-api-server-backdoor-close-20260916.md` | +| `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **已取代:阶段 1 由 `TASK-api-server-backdoor-close-20260916` 承接(该单第一版估的「8 方法 / 314 行」已被实测证伪,真实闭包 115 方法 / 4,104 行;2026-09-16 收尾只关掉 4 处伪造中的 2 处,另 2 处待后续);阶段 2/3 产品拍板不立单,由新门禁长期推进。以下为原文** · 重构单(串行在 qizheng 单之后;2026-09-15 又加两条前置:C1 外网缓存单先做、freeze-metric-change 先落地)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是 3 个文件 **4 处**靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸——实测佐证:**225 个类方法里只有 12 处真的碰 HTTP 上下文**。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → **阶段 4 已改写**:收尾不再是「行数 baseline + 余量 300→50」(那只是把问题推到三个月后),改成主门 `__new__` 计数必须为 0 + 类方法数不得增长,行数退为粗护栏保持 300 余量。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,334 → 约 9,230 行。BUG 段 710+ | 已取代 | 见 `TASK-api-server-backdoor-close-20260916.md` | | `TASK-chart-vedastro-decouple-20260915.md` | `PROGRESS-chart-vedastro-decouple-20260915.md` | **P0**:星盘页首屏那一发 `/api/chart` 没传 `skip_vedastro_main_entry_overview`,实测冷算 0.40–0.66 秒里约 0.36 秒是 VedAstro 空转(本机连 endpoint 都没配);生产 env 开着 network + fanout,等于首屏同步等 24 个外部请求 + 3 次领域扫描,而 `chart-view-mapper.ts` / `chart-view-contract.ts` 根本不读这份证据。星历页同端点传了标志,两页策略相反。BUG-718,**复发自 BUG-161**(前台请求不得同步串联可选外部证据)。串行在 chart-page-blocking-open 之后 | 待验收 | `codex/chart-vedastro-decouple-20260915` | | `TASK-vedastro-runtime-ops-20260915.md` | `PROGRESS-vedastro-runtime-ops-20260915.md` | 运行期真相单(与上单并行,文件不重叠;**不得改 `jyotish_api_server.py`**):官方 `vedastro==1.23.25` 其实是 REST 客户端(46 KB,全打 `api.vedastro.org`),且 import 时请求 pypi 并 `pip install --upgrade` 自升级——本机实测 pin 装完一 import 就变 1.23.26,`requirements.txt` 的锁在运行期是假的(BUG-719);无 key 时免费层排队是同步 sleep + 全局锁,24 个请求 ≈ 4.8 分钟堵住前台线程(BUG-720,定级依赖生产 key 是否配置)。生产 env 核对清单在 `docs/testing/vedastro-runtime-20260915.md`,**只能由产品负责人执行**。台账 ERR-107 / ERR-108 | 待验收 | `codex/vedastro-runtime-ops-20260915` | | `TASK-rectification-engine-memoization-20260915.md` | `PROGRESS-rectification-engine-memoization-20260915.md` | **性能单(纯 Python,独占引擎三文件,可并行)**:一次重算 45% 的 CPU 是重复算同一份 Shadbala——`build_candidate_static_context` 每个候选分钟已算过一次却只留哈希、丢掉结果,`_candidate_row` 在「候选 × 事件 × 采样日期」最内层再算 36 遍(实测 2196 次 vs 应 61 次)。过境盘只依赖事件日期却按候选算 2196 次(应 36);鉴别探针一次请求算两遍;`_cached_rows` 是死代码。本机等价实验 3358 → 1604 ms(**快 53%**),`candidate_scores` 与 `decision_receipt` 逐字相同(唯一差异是计时字段)。**只做记忆化,不改算法**;year 精度采满 12 个月**产品 2026-09-15 决定不改、研究单也不立**。BUG 段 721 | 已验收(带修复单) | `53a37ce9`(BUG-721)。实现等价性由改前/改后同机对比独立证实;等价 golden 跨机不稳另出修复单 | @@ -247,7 +247,7 @@ | `TASK-rectification-engine-memoization-fix-20260915.md` | `PROGRESS-rectification-engine-memoization-fix-20260915.md` | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload,`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 已验收 | `2b7b4565`(BUG-733)。同进程差分立为主证据,golden 降为离散严格+浮点容差;本机 14 条全绿 | | `TASK-consultation-residual-hotspots-20260916.md` | `PROGRESS-consultation-residual-hotspots-20260916.md` | **性能单(与拆解单文件不重叠,可并行)**:BUG-727 的快照缓存已验收生效(逐轮 trace 确认每轮 hit、响应体 52 万→40 万字符),但耗时没降——重测 6 次调用 5.30 s 里**网络仍占 70 %**(poll 2.42 + ssl read 0.76 + 握手 0.56),本地占星计算只剩 **2.4 %**。剩下那一次外网是 `gateway_status → probe_official_rest_health → probe_calculate_health`:**它不是 ping,是把一份虚构排盘 POST 给 `api.vedastro.org`**,而且 `consume_rate_token()` 会**烧掉对方一个业务限流令牌**,还不复用连接(6 次调用 3 次 TLS 握手,单次 0.186 s),结果零 TTL(BUG-734)。另 `yoga_engine._eval_custom` 对静态规则表的 192 条表达式**每请求重新编译 102 次**(多语句的还要先抛一次 SyntaxError 再 parse+改写 AST+compile),占 9.4 %(BUG-735)。修法:探测加 TTL(成功 60 s / 失败 10 s,配置变即失效,**诊断端点必须 force_refresh 保持实时**)+ 共享 opener;表达式缓存 code object。**硬红线:绝不缓存 yoga 求值结果**(会跨用户串盘)。等价证明用 BUG-733 的同进程差分,不再写跨机 golden。BUG 段 734–735 | 待验收 | `codex/consultation-residual-hotspots-20260916`:6.3 探测 TTL 与 6.1 编译缓存均完成,**6.2 共享 opener 按让步顺序砍掉**——实测 `urllib.request` 无连接池,模块级共享 opener 在 6 次请求下仍开 6 条 TCP 连接,按任务书写法只能骗过「同一个 opener 实例」的断言而省不掉任何握手,另立单。同口径实测:6 轮前台请求探测 6→1 次、握手 5→1 次、对方限流令牌 6→1 个(**改前第 6 轮探测自己被限流、报出假 `official_blocked`**);yoga 每次检测源码编译 386→第二次起 0,单次 27.4→3.3 ms。等价用 192 条表达式 ×3 盘的同进程差分。`force_refresh` 默认取 `True`(与任务书写的 `False` 相反):诊断端点调的是裸 `gateway_status()` 且该文件不得改,默认实时才不会让运维看到旧结论 | | `TASK-home-state-lowering-batch2-20260916.md` | — | **状态下沉第二批(纯前端,与另两单可并行)**:第一批 `f8e607c2` 已验收(useState 66→52、useRef 41→39、散装 `rectification*` 归零、全量套件两侧完全相同)。本单照同一套做法搬剩下三簇:`session*` **10** 个(`useSessionManagement` 仍要解构约 40 个参数)、`profile*` **6** 个、`synastry*` **4** 个(无 hook,散在 `Home()`),目标 useState 52 → **≤36**。另修第一批留下的尾巴:`createRectificationShellSetters` 在 render 体里无记忆化 → 每帧新身份 → 多一条 `exhaustive-deps` warning(119→**120**),**正解是稳住 setter 身份、不是塞进 deps**(塞进去会每帧重拉入口摘要),本单要把 warning 降回 119。零行为变化;先分类再动手。不占 BUG 号 | 待领取 | — | -| `TASK-api-server-backdoor-close-20260916.md` | — | **取代 decomposition 单的阶段 1** · **第二版(2026-09-16 改写)**:第一版的「8 方法 / 314 行」**错了一个数量级**——闭包只跟了 `self._x()`,漏掉 `_compute_consultation_workflow` 那 6 行委托转给模块级 `execute_consultation_workflow(self, ...)` 的一跳。执行方重算实测 **115 方法 / 4,104 行(占全类 51% 方法),但碰 HTTP 上下文的仍是 0 个**。产品在 A/B/C 中**选定 B(mixin 抽取)**:方法体原样搬进 `ConsultationComputeMixin`,`JyotishAPIHandler(BaseHTTPRequestHandler, Mixin)` 靠 MRO 解析,离线调用方直接实例化 mixin——四个伪造点全关。**产品同时授权:§4 那条「不得搬 ≥150 行业务方法」按本意解释(本意是防逐行改写的 C 案),mixin 放行。** 必须先过 **spike 闸门**:搬完后 `test_api_server_security.py` 一字不改直接跑,绿则继续、红则退回 A(只搬 7 方法/300 行、关 2 个伪造点)。真风险是循环 import——闭包引用同文件 44 个模块级函数 + 21 个常量。`__new__` 归零只针对 scripts 侧 4→0(tests 侧 29 处含不许改的 security 测试,保持不增长)。阶段 3 仍不做。不占 BUG 号 | 待领取 | — | +| `TASK-api-server-backdoor-close-20260916.md` | — | **取代 decomposition 单的阶段 1** · **第二版(2026-09-16 改写)**:第一版的「8 方法 / 314 行」**错了一个数量级**——闭包只跟了 `self._x()`,漏掉 `_compute_consultation_workflow` 那 6 行委托转给模块级 `execute_consultation_workflow(self, ...)` 的一跳。执行方重算实测 **115 方法 / 4,104 行(占全类 51% 方法),但碰 HTTP 上下文的仍是 0 个**。产品在 A/B/C 中**选定 B(mixin 抽取)**:方法体原样搬进 `ConsultationComputeMixin`,`JyotishAPIHandler(BaseHTTPRequestHandler, Mixin)` 靠 MRO 解析,离线调用方直接实例化 mixin——四个伪造点全关。**产品同时授权:§4 那条「不得搬 ≥150 行业务方法」按本意解释(本意是防逐行改写的 C 案),mixin 放行。** 必须先过 **spike 闸门**:搬完后 `test_api_server_security.py` 一字不改直接跑,绿则继续、红则退回 A(只搬 7 方法/300 行、关 2 个伪造点)。真风险是循环 import——闭包引用同文件 44 个模块级函数 + 21 个常量。`__new__` 归零只针对 scripts 侧 4→0(tests 侧 29 处含不许改的 security 测试,保持不增长)。阶段 3 仍不做。不占 BUG 号 | **已执行(spike 红 → 退回 A)** | spike 实测:整体 mixin 化后 `test_api_server_security.py` **1 failed / 128 passed**——`test_chart_async_job_executes_in_background` 挂在 `monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', ...)`:调用方法随 mixin 搬走后从新模块 globals 解析,补丁落在旧模块上不生效(已证明把同一 fake 打到 mixin 模块即恢复原行为,非搬坏)。循环 import 不是障碍(移动集不引用 `JyotishAPIHandler`)。按 §6.3 退回 A:`scripts/offline_compute_mixins.py` 收 7 方法 / 300 行 + 3 助手 + `BadRequest`,关掉 `consultation_workflow_service:27` 与 `local_accuracy_report:141` 两处伪造,scripts 侧 `__new__` **4 → 2**;类方法 225 → 218、行数 11,291 → 10,924;security 测试一字未改 **129 passed**。剩余 2 处伪造都在咨询工作流链上,需另案解决 monkeypatch 落点问题 | | `RECONCILE-20260916.md` | — | **对账清单(不是任务书)**:状态板与现实脱节(09-15~16 那 10 份早已合入却仍写「待领取/待验收」,本轮已修正),所以另外 7 份 09-10~14 的单**不能拿 README 当证据**——它们没有 `PROGRESS-*.md`,但引用的 BUG 号都是 `resolved`。要么被别的单顺带修了没留记录,要么压根没做。每行回一个「做了/没做」即可。另附三条我已查到确定没做的证据、两条状态未闭环的、以及 BLK-001 仍红(2026-09-16 在 `4f643aa0` 复跑确认) | 待产品负责人 / 执行方回填 | — | ## 命名与归档 diff --git a/scripts/consultation_workflow_service.py b/scripts/consultation_workflow_service.py index 88558203..faf9b4a3 100644 --- a/scripts/consultation_workflow_service.py +++ b/scripts/consultation_workflow_service.py @@ -22,11 +22,11 @@ def execute_consultation_workflow(body: dict[str, Any], *, surface: str = "api") def build_runtime_evidence_helpers(chart: dict[str, Any]) -> dict[str, Any]: - from jyotish_api_server import JyotishAPIHandler + from offline_compute_mixins import VedastroEvidenceMixin - handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + helper = VedastroEvidenceMixin() return { - "vedastro_official": handler._high_rigor_vedastro_official_summary(chart), - "vedastro_archive_manifest": handler._compute_vedastro_gateway_archives(), - "interpretation_coverage": handler._interpretation_source_runtime_coverage(chart), + "vedastro_official": helper._high_rigor_vedastro_official_summary(chart), + "vedastro_archive_manifest": helper._compute_vedastro_gateway_archives(), + "interpretation_coverage": helper._interpretation_source_runtime_coverage(chart), } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index f446031b..83d91907 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -136,6 +136,15 @@ from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, ayan from raman_support_observations import build_raman_support_observations load_local_env(REPO_ROOT) + +from offline_compute_mixins import ( # noqa: E402 (after sys.path bootstrap) + BadRequest, + SynastryMixin, + VedastroEvidenceMixin, + _build_vedastro_official_full_snapshot_payload_from_chart, + _free_tier_queue_enabled_env, + _preferred_strict_contract, +) _LOCAL_MODULE_CACHE = {} _API_CHART_CACHE_SCOPE = 'api_chart_response' _HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow' @@ -2561,13 +2570,6 @@ def _api_chart_cache_ttl_seconds() -> float: return max(ttl, 0.0) -def _free_tier_queue_enabled_env() -> bool: - raw_values = [ - str(os.environ.get("VEDASTRO_FREE_TIER_QUEUE", "")).strip().lower(), - str(os.environ.get("VEDASTRO_FREE_TIER_QUEUE_ENABLED", "")).strip().lower(), - str(os.environ.get("VEDASTRO_ENABLE_FREE_TIER_QUEUE", "")).strip().lower(), - ] - return any(value in {"1", "true", "yes", "on"} for value in raw_values) def _vedastro_runtime_fingerprint() -> dict: @@ -2939,75 +2941,8 @@ def _build_vedastro_overview_payload_from_chart(chart): } -def _build_vedastro_official_full_snapshot_payload_from_chart(chart): - modules = chart.get('modules') if isinstance(chart, dict) else {} - snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} - strict_workflow_contracts = snapshot.get('strict_workflow_contracts') if isinstance(snapshot, dict) else {} - if not isinstance(strict_workflow_contracts, dict): - strict_workflow_contracts = {} - if not isinstance(snapshot, dict) or not snapshot: - return { - 'status': 'blocked', - 'available': False, - 'operation': 'official_full_snapshot', - 'primary_source': 'vedastro_official', - 'strict_workflow_primary_route': None, - 'strict_workflow_routes_available': [], - 'strict_workflow_contracts': {}, - 'boundary_note': 'VedAstro official full snapshot is not attached.', - } - manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} - requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] - sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} - metadata = snapshot.get('source_metadata') if isinstance(snapshot.get('source_metadata'), dict) else {} - official_bundle = metadata.get('official_python_bundle') if isinstance(metadata.get('official_python_bundle'), dict) else {} - full_catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata.get('official_full_capability_catalog'), dict) else {} - coverage = official_bundle.get('coverage') if isinstance(official_bundle.get('coverage'), dict) else {} - official_chart = snapshot.get('official_chart') if isinstance(snapshot.get('official_chart'), dict) else {} - dynamic_selection = full_catalog.get('dynamic_selection') if isinstance(full_catalog.get('dynamic_selection'), dict) else {} - report_references = { - theme: selection.get('report_reference') - for theme, selection in dynamic_selection.items() - if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) - } - return { - 'status': snapshot.get('status') or 'blocked', - 'available': bool(snapshot.get('available')), - 'operation': snapshot.get('operation') or 'official_full_snapshot', - 'primary_source': snapshot.get('primary_source') or 'vedastro_official', - 'official_python_path': metadata.get('official_python_path'), - 'official_bundle_status': official_bundle.get('status'), - 'official_primary_sections_ok': coverage.get('filled_sections') or [], - 'official_chart_available': bool(official_chart.get('planets')) and bool(official_chart.get('ascendant')), - 'official_full_capability_catalog_status': full_catalog.get('status'), - 'official_full_capability_catalog_summary': full_catalog.get('summary') or {}, - 'official_full_capability_catalog_coverage': full_catalog.get('coverage') or {}, - 'official_full_capability_domain_routing': full_catalog.get('domain_routing') or {}, - 'official_full_capability_dynamic_selection': dynamic_selection, - 'official_report_references': report_references, - 'strict_workflow_primary_route': snapshot.get('strict_workflow_primary_route'), - 'strict_workflow_routes_available': snapshot.get('strict_workflow_routes_available') or list(strict_workflow_contracts.keys()), - 'strict_workflow_contracts': strict_workflow_contracts, - 'section_statuses': snapshot.get('section_statuses') or {}, - 'snapshot_section_keys': sorted(sections.keys()), - 'request_section_count': len(requests), - 'request_sections': [item.get('section') for item in requests if isinstance(item, dict)], - 'method_catalog': manifest.get('method_catalog') or {}, - 'user_visibility': snapshot.get('user_visibility') or 'backend_raw_evidence_not_direct_user_report', - 'source_metadata': snapshot.get('source_metadata') or {}, - 'boundary_note': ( - snapshot.get('reason') - or 'VedAstro official full snapshot is the primary raw evidence layer; user reports consume selected slices only.' - ), - } -def _preferred_strict_contract(strict_workflow_contracts, primary_route=None): - if not isinstance(strict_workflow_contracts, dict) or not strict_workflow_contracts: - return None, {} - route = primary_route if primary_route in strict_workflow_contracts else next(iter(strict_workflow_contracts.keys())) - contract = strict_workflow_contracts.get(route) - return route, contract if isinstance(contract, dict) else {} def _strict_adjudication_bundle_from_contract(contract, *, interpretation_axes=None, monthly_humanized=None): @@ -3174,8 +3109,6 @@ CITY_DB = { } -class BadRequest(ValueError): - """Client-side request validation failed.""" class Forbidden(PermissionError): @@ -3198,7 +3131,7 @@ class RateLimited(RuntimeError): """Client exceeded the local fixed-window request budget.""" -class JyotishAPIHandler(BaseHTTPRequestHandler): +class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryMixin): server_version = 'JyotishAPI/6.9.14' def _json(self, data, status=200, extra_headers=None): @@ -3741,30 +3674,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): except ValueError as exc: raise BadRequest(str(exc)) from exc - def _get_float(self, body, key, default, min_value=None, max_value=None): - value = body.get(key, default) - try: - number = float(value) - except (TypeError, ValueError) as e: - raise BadRequest(f'{key} must be a number') from e - if not math.isfinite(number): - raise BadRequest(f'{key} must be finite') - self._check_range(key, number, min_value, max_value) - return number - def _check_range(self, key, number, min_value, max_value): - if min_value is not None and number < min_value: - raise BadRequest(f'{key} must be >= {min_value}') - if max_value is not None and number > max_value: - raise BadRequest(f'{key} must be <= {max_value}') def _validate_planets(self, planets): if not isinstance(planets, dict): raise BadRequest('planets must be an object') return planets - def _normalize_degree(self, body, key, default): - return self._get_float(body, key, default, 0, 360) % 360 def _get_birth_second(self, body, default=0.0): return self._get_float(body, 'second', body.get('birth_second', default), 0, 59) @@ -4460,10 +4376,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return gateway_status() - def _compute_vedastro_gateway_archives(self): - from scripts.vedastro_gateway import list_official_raw_response_archives - - return list_official_raw_response_archives() def _compute_vedastro_gateway_job(self, job_id): from scripts.vedastro_gateway import get_gateway_job @@ -4859,268 +4771,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): } return module.build_report(payload) - def _high_rigor_vedastro_official_summary(self, chart): - prompt_pack = chart.get('ai_prompt_pack') if isinstance(chart, dict) else {} - evidence_snapshot = prompt_pack.get('evidence_snapshot') if isinstance(prompt_pack, dict) else {} - prompt_official = evidence_snapshot.get('vedastro_official_snapshot') if isinstance(evidence_snapshot, dict) else {} - if not isinstance(prompt_official, dict): - prompt_official = {} - prompt_full_snapshot = evidence_snapshot.get('vedastro_official_full_snapshot') if isinstance(evidence_snapshot, dict) else {} - if not isinstance(prompt_full_snapshot, dict): - prompt_full_snapshot = {} - modules = chart.get('modules') if isinstance(chart, dict) else {} - if not isinstance(modules, dict): - modules = {} - range_scan = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} - if not isinstance(range_scan, dict): - range_scan = {} - full_snapshot_payload = _build_vedastro_official_full_snapshot_payload_from_chart(chart) - official_snapshot = range_scan.get('official_full_snapshot') if isinstance(range_scan, dict) else {} - if not isinstance(official_snapshot, dict): - official_snapshot = {} - if not official_snapshot and isinstance(modules.get('vedastro_official_full_snapshot'), dict): - official_snapshot = modules.get('vedastro_official_full_snapshot') or {} - metadata = official_snapshot.get('source_metadata') if isinstance(official_snapshot, dict) else {} - catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata, dict) else {} - if not isinstance(catalog, dict): - catalog = {} - range_metadata = range_scan.get('source_metadata') if isinstance(range_scan, dict) else {} - if not isinstance(range_metadata, dict): - range_metadata = {} - strict_workflow_contracts = ( - prompt_full_snapshot.get('strict_workflow_contracts') - or full_snapshot_payload.get('strict_workflow_contracts') - or {} - ) - if not isinstance(strict_workflow_contracts, dict): - strict_workflow_contracts = {} - strict_workflow_primary_route = ( - prompt_full_snapshot.get('strict_workflow_primary_route') - or full_snapshot_payload.get('strict_workflow_primary_route') - ) - strict_workflow_routes_available = ( - prompt_full_snapshot.get('strict_workflow_routes_available') - or full_snapshot_payload.get('strict_workflow_routes_available') - or list(strict_workflow_contracts.keys()) - ) - if not isinstance(strict_workflow_routes_available, list): - strict_workflow_routes_available = list(strict_workflow_contracts.keys()) - _selected_route, primary_contract = _preferred_strict_contract( - strict_workflow_contracts, - strict_workflow_primary_route, - ) - dynamic_selection = ( - prompt_official.get('official_full_capability_dynamic_selection') - or catalog.get('dynamic_selection') - or prompt_full_snapshot.get('official_full_capability_dynamic_selection') - or full_snapshot_payload.get('official_full_capability_dynamic_selection') - or range_metadata.get('official_full_capability_dynamic_selection') - or {} - ) - report_references = ( - prompt_official.get('official_report_references') - or prompt_full_snapshot.get('official_report_references') - or full_snapshot_payload.get('official_report_references') - or range_metadata.get('official_report_references') - or { - theme: selection.get('report_reference') - for theme, selection in dynamic_selection.items() - if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) - } - ) - status = ( - prompt_official.get('status') - or official_snapshot.get('status') - or range_scan.get('status') - or 'blocked' - ) - chart_core_status = 'blocked' - official_primary_evidence = ( - primary_contract.get('official_primary_evidence') - or prompt_official.get('official_primary_evidence') - or {} - ) - if not isinstance(official_primary_evidence, dict): - official_primary_evidence = {} - chart_core = official_primary_evidence.get('chart_core') - if isinstance(chart_core, dict) and chart_core.get('status'): - chart_core_status = chart_core.get('status') - elif full_snapshot_payload.get('available'): - chart_core_status = 'ok' - event_radar_status = 'blocked' - if ( - prompt_official.get('blocked_items') - or prompt_official.get('fallback_used') - or prompt_official.get('conflicts') - ): - event_radar_status = 'partial' - elif range_scan.get('status') == 'ok': - event_radar_status = 'ok' - elif range_scan.get('status'): - event_radar_status = 'partial' - runtime_truth = { - 'status': status, - 'catalog_boundary': 'catalog_recognized_not_full_runtime_execution', - 'primary_route': strict_workflow_primary_route or _selected_route, - 'routes_available': strict_workflow_routes_available, - 'official_execution_layers': { - 'chart_core': chart_core_status, - 'event_radar': event_radar_status, - 'catalog_status': ( - prompt_official.get('official_full_capability_catalog_status') - or catalog.get('status') - or range_metadata.get('official_full_capability_catalog_status') - or official_snapshot.get('status') - or 'blocked' - ), - }, - 'fallback_active': bool( - primary_contract.get('fallback_used') - or prompt_official.get('fallback_used') - ), - 'blocked_items': ( - primary_contract.get('blocked_items') - or prompt_official.get('blocked_items') - or [] - ), - 'conflicts': ( - primary_contract.get('conflicts') - or prompt_official.get('conflicts') - or [] - ), - 'free_tier_strategy': { - 'using_free_tier': not bool(os.environ.get('VEDASTRO_API_KEY', '').strip()), - 'queue_enabled': _free_tier_queue_enabled_env(), - 'cache_hit': bool( - (((official_snapshot.get('source_metadata') or {}).get('semantic_cache') or {}).get('cache_hit')) - if isinstance(official_snapshot, dict) - else False - ), - 'guard_status': ( - 'degraded_or_partial' - if status in {'partial', 'blocked', 'official_snapshot_budget_exhausted'} - or bool(prompt_official.get('blocked_items')) - else 'within_free_tier_strategy' - ), - }, - } - raw_response = ( - official_snapshot.get('raw_response') - or official_snapshot.get('official_raw_response') - or official_snapshot.get('raw_payload') - or official_snapshot.get('raw') - or prompt_full_snapshot.get('raw_response') - or prompt_full_snapshot.get('official_raw_response') - or prompt_full_snapshot.get('raw_payload') - or prompt_full_snapshot.get('raw') - ) - return { - 'status': status, - 'range_scan_status': range_scan.get('status') if isinstance(range_scan, dict) else None, - 'event_count': int(range_scan.get('event_count', 0) or 0) if isinstance(range_scan, dict) else 0, - 'official_full_capability_catalog_status': ( - prompt_official.get('official_full_capability_catalog_status') - or catalog.get('status') - or range_metadata.get('official_full_capability_catalog_status') - ), - 'official_full_capability_catalog_summary': ( - prompt_official.get('official_full_capability_catalog_summary') - or prompt_full_snapshot.get('official_full_capability_catalog_summary') - or full_snapshot_payload.get('official_full_capability_catalog_summary') - or catalog.get('summary') - or range_metadata.get('official_full_capability_catalog_summary') - or {} - ), - 'official_full_capability_domain_routing': ( - prompt_official.get('official_full_capability_domain_routing') - or prompt_full_snapshot.get('official_full_capability_domain_routing') - or full_snapshot_payload.get('official_full_capability_domain_routing') - or catalog.get('domain_routing') - or range_metadata.get('official_full_capability_domain_routing') - or {} - ), - 'official_full_capability_dynamic_selection': dynamic_selection, - 'official_report_references': report_references, - 'strict_workflow_primary_route': strict_workflow_primary_route, - 'strict_workflow_routes_available': strict_workflow_routes_available, - 'strict_workflow_contracts': strict_workflow_contracts, - 'official_primary_evidence': ( - primary_contract.get('official_primary_evidence') - or prompt_official.get('official_primary_evidence') - or {} - ), - 'local_supplemental_evidence': ( - primary_contract.get('local_supplemental_evidence') - or prompt_official.get('local_supplemental_evidence') - or {} - ), - 'fallback_used': ( - primary_contract.get('fallback_used') - or prompt_official.get('fallback_used') - or [] - ), - 'blocked_items': ( - primary_contract.get('blocked_items') - or prompt_official.get('blocked_items') - or [] - ), - 'conflicts': ( - primary_contract.get('conflicts') - or prompt_official.get('conflicts') - or [] - ), - 'technique_audit_summary': primary_contract.get('technique_audit_summary') or {}, - 'adjudication_stages': primary_contract.get('adjudication_stages') or {}, - 'multi_reference_reading_summary': primary_contract.get('multi_reference_reading_summary') or {}, - 'verdict': primary_contract.get('verdict'), - 'dominant_label': primary_contract.get('dominant_label'), - 'main_conflicts': primary_contract.get('main_conflicts') or primary_contract.get('conflicts') or [], - 'runtime_truth': runtime_truth, - 'raw_response': raw_response, - 'boundary': 'VedAstro official snapshot and capability catalog are consumed as primary evidence metadata; execution breadth depends on configured network and sample limits.', - } - def _interpretation_source_runtime_coverage(self, chart): - modules = chart.get('modules') if isinstance(chart, dict) else {} - if not isinstance(modules, dict): - modules = {} - prompt_pack = chart.get('ai_prompt_pack') if isinstance(chart, dict) else {} - evidence_snapshot = prompt_pack.get('evidence_snapshot') if isinstance(prompt_pack, dict) else {} - interpretation_pack = evidence_snapshot.get('interpretation_source_pack') if isinstance(evidence_snapshot.get('interpretation_source_pack'), dict) else {} - candidates = { - 'dasha_timing_layer_used', - 'varga_strength_layer_used', - 'annual_special_layer_context', - 'modifier_obstacle_layer_used', - } - proven_markers = [] - guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else [] - for topic in guided_topics: - if not isinstance(topic, dict): - continue - strict_gate = topic.get('strict_audit_gate') - if not isinstance(strict_gate, dict): - continue - secondary = strict_gate.get('secondary_context') - if not isinstance(secondary, list): - continue - for item in secondary: - if isinstance(item, str) and item in candidates and item not in proven_markers: - proven_markers.append(item) - return { - 'source_pack_status': interpretation_pack.get('status') or 'used', - 'proven_runtime_markers': proven_markers, - 'runtime_visibility_status': 'partial' if proven_markers else 'blocked', - 'not_fully_closed': [ - 'references/open_source_sources/jyotishganit', - 'references/open_source_sources/jaimini-tropical', - 'references/open_source_sources/VedicAstro', - 'references/open_source_sources/rishi-ai-mcp', - 'references/open_source_sources/vedic-astro-skills', - 'references/open_source_sources/dashaflow', - ], - 'boundary': 'Inventory/grading exists, but full runtime invocation is only proven for surfaced strict-workflow markers, not every local source asset.', - } def _high_rigor_next_questions(self, rectification, historical_backtest): questions = [] @@ -7608,24 +7259,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, } - def _compute_synastry(self, body): - from ashtakoot import calculate_ashtakoot - - result = calculate_ashtakoot( - self._normalize_degree(body, 'male_moon', 0), - self._normalize_degree(body, 'female_moon', 0), - ) - # Backward-compatible aliases for older frontend/report consumers. - result['is_approved'] = result.get('is_match_approved', False) - result['assessment'] = ( - '优秀' if result.get('total_score', 0) >= 28 else - '良好' if result.get('total_score', 0) >= 21 else - '一般' if result.get('total_score', 0) >= 18 else - '不推荐' - ) - result['male'] = result.get('male_details', {}) - result['female'] = result.get('female_details', {}) - return result def _compute_dasha_system(self, body): dasha_key = body.get('dasha', body.get('name', 'vimshottari')) diff --git a/scripts/local_accuracy_report.py b/scripts/local_accuracy_report.py index 4e6dec5e..1c5b9c1d 100755 --- a/scripts/local_accuracy_report.py +++ b/scripts/local_accuracy_report.py @@ -135,11 +135,10 @@ def load_oracle_boundary() -> dict[str, Any]: def load_ashtakoot_engine() -> dict[str, Any]: sys.path.insert(0, str(ROOT / "scripts")) from ashtakoot import calculate_ashtakoot # type: ignore - from jyotish_api_server import JyotishAPIHandler # type: ignore + from offline_compute_mixins import SynastryMixin # type: ignore direct = calculate_ashtakoot(0, 60) - handler = JyotishAPIHandler.__new__(JyotishAPIHandler) - api = handler._compute_synastry({"male_moon": 0, "female_moon": 60}) + api = SynastryMixin()._compute_synastry({"male_moon": 0, "female_moon": 60}) return { "full_engine_parity": api.get("total_score") == direct.get("total_score") and api.get("male_details") == direct.get("male_details") diff --git a/scripts/offline_compute_mixins.py b/scripts/offline_compute_mixins.py new file mode 100644 index 00000000..3f7d5b43 --- /dev/null +++ b/scripts/offline_compute_mixins.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""Compute-only mixins lifted verbatim out of ``JyotishAPIHandler``. + +These methods never touch HTTP context (``headers`` / ``wfile`` / ``rfile`` / +``path`` / ``client_address``), so offline callers (MCP, reporting scripts) can +instantiate the mixins directly instead of forging a ``JyotishAPIHandler`` +instance that never ran its constructor. + +Hard constraint: this module must never import ``jyotish_api_server`` -- that +would merely move the backdoor. See tests/test_api_server_growth_contract.py. +""" + +from __future__ import annotations + +import math +import os + +class BadRequest(ValueError): + """Client-side request validation failed.""" + +def _build_vedastro_official_full_snapshot_payload_from_chart(chart): + modules = chart.get('modules') if isinstance(chart, dict) else {} + snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} + strict_workflow_contracts = snapshot.get('strict_workflow_contracts') if isinstance(snapshot, dict) else {} + if not isinstance(strict_workflow_contracts, dict): + strict_workflow_contracts = {} + if not isinstance(snapshot, dict) or not snapshot: + return { + 'status': 'blocked', + 'available': False, + 'operation': 'official_full_snapshot', + 'primary_source': 'vedastro_official', + 'strict_workflow_primary_route': None, + 'strict_workflow_routes_available': [], + 'strict_workflow_contracts': {}, + 'boundary_note': 'VedAstro official full snapshot is not attached.', + } + manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} + requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] + sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} + metadata = snapshot.get('source_metadata') if isinstance(snapshot.get('source_metadata'), dict) else {} + official_bundle = metadata.get('official_python_bundle') if isinstance(metadata.get('official_python_bundle'), dict) else {} + full_catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata.get('official_full_capability_catalog'), dict) else {} + coverage = official_bundle.get('coverage') if isinstance(official_bundle.get('coverage'), dict) else {} + official_chart = snapshot.get('official_chart') if isinstance(snapshot.get('official_chart'), dict) else {} + dynamic_selection = full_catalog.get('dynamic_selection') if isinstance(full_catalog.get('dynamic_selection'), dict) else {} + report_references = { + theme: selection.get('report_reference') + for theme, selection in dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) + } + return { + 'status': snapshot.get('status') or 'blocked', + 'available': bool(snapshot.get('available')), + 'operation': snapshot.get('operation') or 'official_full_snapshot', + 'primary_source': snapshot.get('primary_source') or 'vedastro_official', + 'official_python_path': metadata.get('official_python_path'), + 'official_bundle_status': official_bundle.get('status'), + 'official_primary_sections_ok': coverage.get('filled_sections') or [], + 'official_chart_available': bool(official_chart.get('planets')) and bool(official_chart.get('ascendant')), + 'official_full_capability_catalog_status': full_catalog.get('status'), + 'official_full_capability_catalog_summary': full_catalog.get('summary') or {}, + 'official_full_capability_catalog_coverage': full_catalog.get('coverage') or {}, + 'official_full_capability_domain_routing': full_catalog.get('domain_routing') or {}, + 'official_full_capability_dynamic_selection': dynamic_selection, + 'official_report_references': report_references, + 'strict_workflow_primary_route': snapshot.get('strict_workflow_primary_route'), + 'strict_workflow_routes_available': snapshot.get('strict_workflow_routes_available') or list(strict_workflow_contracts.keys()), + 'strict_workflow_contracts': strict_workflow_contracts, + 'section_statuses': snapshot.get('section_statuses') or {}, + 'snapshot_section_keys': sorted(sections.keys()), + 'request_section_count': len(requests), + 'request_sections': [item.get('section') for item in requests if isinstance(item, dict)], + 'method_catalog': manifest.get('method_catalog') or {}, + 'user_visibility': snapshot.get('user_visibility') or 'backend_raw_evidence_not_direct_user_report', + 'source_metadata': snapshot.get('source_metadata') or {}, + 'boundary_note': ( + snapshot.get('reason') + or 'VedAstro official full snapshot is the primary raw evidence layer; user reports consume selected slices only.' + ), + } + +def _free_tier_queue_enabled_env() -> bool: + raw_values = [ + str(os.environ.get("VEDASTRO_FREE_TIER_QUEUE", "")).strip().lower(), + str(os.environ.get("VEDASTRO_FREE_TIER_QUEUE_ENABLED", "")).strip().lower(), + str(os.environ.get("VEDASTRO_ENABLE_FREE_TIER_QUEUE", "")).strip().lower(), + ] + return any(value in {"1", "true", "yes", "on"} for value in raw_values) + +def _preferred_strict_contract(strict_workflow_contracts, primary_route=None): + if not isinstance(strict_workflow_contracts, dict) or not strict_workflow_contracts: + return None, {} + route = primary_route if primary_route in strict_workflow_contracts else next(iter(strict_workflow_contracts.keys())) + contract = strict_workflow_contracts.get(route) + return route, contract if isinstance(contract, dict) else {} + +class RequestParamMixin: + """Request-parameter coercion helpers (raise :class:`BadRequest`).""" + + def _get_float(self, body, key, default, min_value=None, max_value=None): + value = body.get(key, default) + try: + number = float(value) + except (TypeError, ValueError) as e: + raise BadRequest(f'{key} must be a number') from e + if not math.isfinite(number): + raise BadRequest(f'{key} must be finite') + self._check_range(key, number, min_value, max_value) + return number + + def _check_range(self, key, number, min_value, max_value): + if min_value is not None and number < min_value: + raise BadRequest(f'{key} must be >= {min_value}') + if max_value is not None and number > max_value: + raise BadRequest(f'{key} must be <= {max_value}') + + def _normalize_degree(self, body, key, default): + return self._get_float(body, key, default, 0, 360) % 360 + +class VedastroEvidenceMixin: + """VedAstro runtime-evidence summaries used by the consultation boundary.""" + + def _high_rigor_vedastro_official_summary(self, chart): + prompt_pack = chart.get('ai_prompt_pack') if isinstance(chart, dict) else {} + evidence_snapshot = prompt_pack.get('evidence_snapshot') if isinstance(prompt_pack, dict) else {} + prompt_official = evidence_snapshot.get('vedastro_official_snapshot') if isinstance(evidence_snapshot, dict) else {} + if not isinstance(prompt_official, dict): + prompt_official = {} + prompt_full_snapshot = evidence_snapshot.get('vedastro_official_full_snapshot') if isinstance(evidence_snapshot, dict) else {} + if not isinstance(prompt_full_snapshot, dict): + prompt_full_snapshot = {} + modules = chart.get('modules') if isinstance(chart, dict) else {} + if not isinstance(modules, dict): + modules = {} + range_scan = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} + if not isinstance(range_scan, dict): + range_scan = {} + full_snapshot_payload = _build_vedastro_official_full_snapshot_payload_from_chart(chart) + official_snapshot = range_scan.get('official_full_snapshot') if isinstance(range_scan, dict) else {} + if not isinstance(official_snapshot, dict): + official_snapshot = {} + if not official_snapshot and isinstance(modules.get('vedastro_official_full_snapshot'), dict): + official_snapshot = modules.get('vedastro_official_full_snapshot') or {} + metadata = official_snapshot.get('source_metadata') if isinstance(official_snapshot, dict) else {} + catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata, dict) else {} + if not isinstance(catalog, dict): + catalog = {} + range_metadata = range_scan.get('source_metadata') if isinstance(range_scan, dict) else {} + if not isinstance(range_metadata, dict): + range_metadata = {} + strict_workflow_contracts = ( + prompt_full_snapshot.get('strict_workflow_contracts') + or full_snapshot_payload.get('strict_workflow_contracts') + or {} + ) + if not isinstance(strict_workflow_contracts, dict): + strict_workflow_contracts = {} + strict_workflow_primary_route = ( + prompt_full_snapshot.get('strict_workflow_primary_route') + or full_snapshot_payload.get('strict_workflow_primary_route') + ) + strict_workflow_routes_available = ( + prompt_full_snapshot.get('strict_workflow_routes_available') + or full_snapshot_payload.get('strict_workflow_routes_available') + or list(strict_workflow_contracts.keys()) + ) + if not isinstance(strict_workflow_routes_available, list): + strict_workflow_routes_available = list(strict_workflow_contracts.keys()) + _selected_route, primary_contract = _preferred_strict_contract( + strict_workflow_contracts, + strict_workflow_primary_route, + ) + dynamic_selection = ( + prompt_official.get('official_full_capability_dynamic_selection') + or catalog.get('dynamic_selection') + or prompt_full_snapshot.get('official_full_capability_dynamic_selection') + or full_snapshot_payload.get('official_full_capability_dynamic_selection') + or range_metadata.get('official_full_capability_dynamic_selection') + or {} + ) + report_references = ( + prompt_official.get('official_report_references') + or prompt_full_snapshot.get('official_report_references') + or full_snapshot_payload.get('official_report_references') + or range_metadata.get('official_report_references') + or { + theme: selection.get('report_reference') + for theme, selection in dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) + } + ) + status = ( + prompt_official.get('status') + or official_snapshot.get('status') + or range_scan.get('status') + or 'blocked' + ) + chart_core_status = 'blocked' + official_primary_evidence = ( + primary_contract.get('official_primary_evidence') + or prompt_official.get('official_primary_evidence') + or {} + ) + if not isinstance(official_primary_evidence, dict): + official_primary_evidence = {} + chart_core = official_primary_evidence.get('chart_core') + if isinstance(chart_core, dict) and chart_core.get('status'): + chart_core_status = chart_core.get('status') + elif full_snapshot_payload.get('available'): + chart_core_status = 'ok' + event_radar_status = 'blocked' + if ( + prompt_official.get('blocked_items') + or prompt_official.get('fallback_used') + or prompt_official.get('conflicts') + ): + event_radar_status = 'partial' + elif range_scan.get('status') == 'ok': + event_radar_status = 'ok' + elif range_scan.get('status'): + event_radar_status = 'partial' + runtime_truth = { + 'status': status, + 'catalog_boundary': 'catalog_recognized_not_full_runtime_execution', + 'primary_route': strict_workflow_primary_route or _selected_route, + 'routes_available': strict_workflow_routes_available, + 'official_execution_layers': { + 'chart_core': chart_core_status, + 'event_radar': event_radar_status, + 'catalog_status': ( + prompt_official.get('official_full_capability_catalog_status') + or catalog.get('status') + or range_metadata.get('official_full_capability_catalog_status') + or official_snapshot.get('status') + or 'blocked' + ), + }, + 'fallback_active': bool( + primary_contract.get('fallback_used') + or prompt_official.get('fallback_used') + ), + 'blocked_items': ( + primary_contract.get('blocked_items') + or prompt_official.get('blocked_items') + or [] + ), + 'conflicts': ( + primary_contract.get('conflicts') + or prompt_official.get('conflicts') + or [] + ), + 'free_tier_strategy': { + 'using_free_tier': not bool(os.environ.get('VEDASTRO_API_KEY', '').strip()), + 'queue_enabled': _free_tier_queue_enabled_env(), + 'cache_hit': bool( + (((official_snapshot.get('source_metadata') or {}).get('semantic_cache') or {}).get('cache_hit')) + if isinstance(official_snapshot, dict) + else False + ), + 'guard_status': ( + 'degraded_or_partial' + if status in {'partial', 'blocked', 'official_snapshot_budget_exhausted'} + or bool(prompt_official.get('blocked_items')) + else 'within_free_tier_strategy' + ), + }, + } + raw_response = ( + official_snapshot.get('raw_response') + or official_snapshot.get('official_raw_response') + or official_snapshot.get('raw_payload') + or official_snapshot.get('raw') + or prompt_full_snapshot.get('raw_response') + or prompt_full_snapshot.get('official_raw_response') + or prompt_full_snapshot.get('raw_payload') + or prompt_full_snapshot.get('raw') + ) + return { + 'status': status, + 'range_scan_status': range_scan.get('status') if isinstance(range_scan, dict) else None, + 'event_count': int(range_scan.get('event_count', 0) or 0) if isinstance(range_scan, dict) else 0, + 'official_full_capability_catalog_status': ( + prompt_official.get('official_full_capability_catalog_status') + or catalog.get('status') + or range_metadata.get('official_full_capability_catalog_status') + ), + 'official_full_capability_catalog_summary': ( + prompt_official.get('official_full_capability_catalog_summary') + or prompt_full_snapshot.get('official_full_capability_catalog_summary') + or full_snapshot_payload.get('official_full_capability_catalog_summary') + or catalog.get('summary') + or range_metadata.get('official_full_capability_catalog_summary') + or {} + ), + 'official_full_capability_domain_routing': ( + prompt_official.get('official_full_capability_domain_routing') + or prompt_full_snapshot.get('official_full_capability_domain_routing') + or full_snapshot_payload.get('official_full_capability_domain_routing') + or catalog.get('domain_routing') + or range_metadata.get('official_full_capability_domain_routing') + or {} + ), + 'official_full_capability_dynamic_selection': dynamic_selection, + 'official_report_references': report_references, + 'strict_workflow_primary_route': strict_workflow_primary_route, + 'strict_workflow_routes_available': strict_workflow_routes_available, + 'strict_workflow_contracts': strict_workflow_contracts, + 'official_primary_evidence': ( + primary_contract.get('official_primary_evidence') + or prompt_official.get('official_primary_evidence') + or {} + ), + 'local_supplemental_evidence': ( + primary_contract.get('local_supplemental_evidence') + or prompt_official.get('local_supplemental_evidence') + or {} + ), + 'fallback_used': ( + primary_contract.get('fallback_used') + or prompt_official.get('fallback_used') + or [] + ), + 'blocked_items': ( + primary_contract.get('blocked_items') + or prompt_official.get('blocked_items') + or [] + ), + 'conflicts': ( + primary_contract.get('conflicts') + or prompt_official.get('conflicts') + or [] + ), + 'technique_audit_summary': primary_contract.get('technique_audit_summary') or {}, + 'adjudication_stages': primary_contract.get('adjudication_stages') or {}, + 'multi_reference_reading_summary': primary_contract.get('multi_reference_reading_summary') or {}, + 'verdict': primary_contract.get('verdict'), + 'dominant_label': primary_contract.get('dominant_label'), + 'main_conflicts': primary_contract.get('main_conflicts') or primary_contract.get('conflicts') or [], + 'runtime_truth': runtime_truth, + 'raw_response': raw_response, + 'boundary': 'VedAstro official snapshot and capability catalog are consumed as primary evidence metadata; execution breadth depends on configured network and sample limits.', + } + + def _compute_vedastro_gateway_archives(self): + from scripts.vedastro_gateway import list_official_raw_response_archives + + return list_official_raw_response_archives() + + def _interpretation_source_runtime_coverage(self, chart): + modules = chart.get('modules') if isinstance(chart, dict) else {} + if not isinstance(modules, dict): + modules = {} + prompt_pack = chart.get('ai_prompt_pack') if isinstance(chart, dict) else {} + evidence_snapshot = prompt_pack.get('evidence_snapshot') if isinstance(prompt_pack, dict) else {} + interpretation_pack = evidence_snapshot.get('interpretation_source_pack') if isinstance(evidence_snapshot.get('interpretation_source_pack'), dict) else {} + candidates = { + 'dasha_timing_layer_used', + 'varga_strength_layer_used', + 'annual_special_layer_context', + 'modifier_obstacle_layer_used', + } + proven_markers = [] + guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else [] + for topic in guided_topics: + if not isinstance(topic, dict): + continue + strict_gate = topic.get('strict_audit_gate') + if not isinstance(strict_gate, dict): + continue + secondary = strict_gate.get('secondary_context') + if not isinstance(secondary, list): + continue + for item in secondary: + if isinstance(item, str) and item in candidates and item not in proven_markers: + proven_markers.append(item) + return { + 'source_pack_status': interpretation_pack.get('status') or 'used', + 'proven_runtime_markers': proven_markers, + 'runtime_visibility_status': 'partial' if proven_markers else 'blocked', + 'not_fully_closed': [ + 'references/open_source_sources/jyotishganit', + 'references/open_source_sources/jaimini-tropical', + 'references/open_source_sources/VedicAstro', + 'references/open_source_sources/rishi-ai-mcp', + 'references/open_source_sources/vedic-astro-skills', + 'references/open_source_sources/dashaflow', + ], + 'boundary': 'Inventory/grading exists, but full runtime invocation is only proven for surfaced strict-workflow markers, not every local source asset.', + } + +class SynastryMixin(RequestParamMixin): + """Ashtakoot synastry scoring.""" + + def _compute_synastry(self, body): + from ashtakoot import calculate_ashtakoot + + result = calculate_ashtakoot( + self._normalize_degree(body, 'male_moon', 0), + self._normalize_degree(body, 'female_moon', 0), + ) + # Backward-compatible aliases for older frontend/report consumers. + result['is_approved'] = result.get('is_match_approved', False) + result['assessment'] = ( + '优秀' if result.get('total_score', 0) >= 28 else + '良好' if result.get('total_score', 0) >= 21 else + '一般' if result.get('total_score', 0) >= 18 else + '不推荐' + ) + result['male'] = result.get('male_details', {}) + result['female'] = result.get('female_details', {}) + return result diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 877a27cb..cda99888 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -75,6 +75,9 @@ CORE_PYTEST_TARGETS = [ "tests/test_birth_time_journey_contract.py", # Freeze scripts/jyotish_api_server.py growth; new features must be modules. "tests/test_api_server_growth_contract.py", + # Pins the two closed __new__ forgeries: MCP/report scripts must keep reaching the + # compute mixins without constructing an HTTP handler (TASK-api-server-backdoor-close). + "tests/test_offline_compute_mixins.py", # Foreground VedAstro snapshot cache + join cancel (BUG-727 / BUG-728). "tests/test_vedastro_snapshot_cache.py", # Native seven-governors adapter and the three read-only chart endpoints. diff --git a/tests/test_api_server_growth_contract.py b/tests/test_api_server_growth_contract.py index 18bba5de..4209f1c9 100644 --- a/tests/test_api_server_growth_contract.py +++ b/tests/test_api_server_growth_contract.py @@ -3,10 +3,15 @@ New endpoints and features must live in new modules and be thinly registered from the main file. Line count is only a coarse guardrail; the live gates are JyotishAPIHandler method count and JyotishAPIHandler.__new__ forgery sites. + +Baselines re-measured 2026-09-16 after TASK-api-server-backdoor-close-20260916 +moved seven compute-only methods into scripts/offline_compute_mixins.py and +closed two of the four production forgeries. """ from __future__ import annotations +import ast import re from collections import Counter from pathlib import Path @@ -18,16 +23,30 @@ AGENTS = ROOT / "AGENTS.md" # Live `wc -l scripts/jyotish_api_server.py` equivalent: # Path.read_bytes().count(b"\n"), measured 2026-09-16 on origin/staging @ 51a65d92. # New features must not consume this budget; open a module instead. -JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11291 +JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 10924 JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300 # Whole-file indent match `^ (?:async )?def \w+`, same count as -# TASK-freeze-metric-change-20260915 §1. Measured 2026-09-16 @ 51a65d92. -JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 225 +# TASK-freeze-metric-change-20260915 §1. Re-measured 2026-09-16 after +# TASK-api-server-backdoor-close-20260916 lifted 7 compute-only methods into +# scripts/offline_compute_mixins.py (225 -> 218). +JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 218 # `JyotishAPIHandler.__new__` in scripts/ and tests/ `*.py`, excluding this file. -# Measured 2026-09-16 @ 51a65d92: scripts/ production forgeries = 4, tests/ = 29. -JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 33 +# Re-measured 2026-09-16 after TASK-api-server-backdoor-close-20260916: +# scripts/ production forgeries 4 -> 2, tests/ unchanged at 29. +JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 31 + +# Production forgeries only. The two survivors both sit on the consultation +# workflow chain (`consultation_workflow_service.execute_consultation_workflow` +# and `capture_report_blocked_repairs_golden`); closing them needs the 4,086-line +# mixin extraction that the 2026-09-16 spike showed breaks cross-module +# monkeypatching in tests/test_api_server_security.py. Target stays 0. +JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 2 + +# Compute-only mixins lifted out of the handler must never depend back on the +# HTTP monolith, or the backdoor simply moves house. +OFFLINE_MIXINS = ROOT / "scripts" / "offline_compute_mixins.py" HANDLER_METHOD_RE = re.compile(r"^ (?:async )?def \w+", re.MULTILINE) NEW_MARKER = "JyotishAPIHandler.__new__" @@ -37,10 +56,10 @@ def _handler_method_count(source: str) -> int: return len(HANDLER_METHOD_RE.findall(source)) -def _new_hits() -> Counter[str]: +def _new_hits(*folders: Path) -> Counter[str]: hits: Counter[str] = Counter() skip = Path(__file__).resolve() - for folder in (ROOT / "scripts", ROOT / "tests"): + for folder in folders or (ROOT / "scripts", ROOT / "tests"): for path in sorted(folder.rglob("*.py")): if path.resolve() == skip: continue @@ -70,6 +89,32 @@ def test_jyotish_api_handler_new_count_must_not_grow() -> None: ) +def test_scripts_handler_forgeries_must_not_grow() -> None: + hits = _new_hits(ROOT / "scripts") + total = sum(hits.values()) + listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items())) + assert total <= JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE, ( + f"{NEW_MARKER} appears {total} times under scripts/; cap is " + f"{JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE} and the target is 0. " + f"Offline callers must instantiate a compute mixin instead. Hits: {listed}" + ) + + +def test_offline_mixins_must_not_import_the_http_monolith() -> None: + tree = ast.parse(OFFLINE_MIXINS.read_text(encoding="utf-8")) + imported: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + imported.append(node.module or "") + offenders = [name for name in imported if "jyotish_api_server" in name] + assert not offenders, ( + f"{OFFLINE_MIXINS.as_posix()} imports {offenders}; the compute mixins must " + "not depend on the HTTP monolith or the __new__ backdoor just moves house." + ) + + def test_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None: line_count = API_SERVER.read_bytes().count(b"\n") assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, ( diff --git a/tests/test_offline_compute_mixins.py b/tests/test_offline_compute_mixins.py new file mode 100644 index 00000000..d6447527 --- /dev/null +++ b/tests/test_offline_compute_mixins.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Offline compute mixins: the handler-free path used by MCP and report scripts. + +TASK-api-server-backdoor-close-20260916. These assertions pin the two production +call sites that no longer forge a ``JyotishAPIHandler`` without its constructor, +and pin that the HTTP handler keeps the moved methods through inheritance. +""" + +from __future__ import annotations + +import ast +import os +import sys + +SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts') +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from ashtakoot import calculate_ashtakoot # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 +from offline_compute_mixins import ( # noqa: E402 + RequestParamMixin, + SynastryMixin, + VedastroEvidenceMixin, +) + +_CHART = { + 'success': True, + 'ascendant': {'sign': 'Aries', 'sign_idx': 0, 'degree': 12.0, 'lon': 12.0}, + 'planets': { + 'Sun': {'sign': 'Leo', 'lon': 130.0}, + 'Moon': {'sign': 'Cancer', 'lon': 100.0}, + }, +} + + +def test_synastry_mixin_needs_no_http_handler() -> None: + """local_accuracy_report.py's path: score a pair without a handler.""" + api = SynastryMixin()._compute_synastry({'male_moon': 0, 'female_moon': 60}) + direct = calculate_ashtakoot(0, 60) + assert api['total_score'] == direct['total_score'] + assert api['male_details'] == direct['male_details'] + assert api['female_details'] == direct['female_details'] + + +def test_runtime_evidence_helpers_run_without_forging_a_handler() -> None: + """mcp_server.py:4751 -> consultation_workflow_service.build_runtime_evidence_helpers.""" + from consultation_workflow_service import build_runtime_evidence_helpers + + helpers = build_runtime_evidence_helpers(_CHART) + assert set(helpers) == { + 'vedastro_official', + 'vedastro_archive_manifest', + 'interpretation_coverage', + } + assert isinstance(helpers['vedastro_official'], dict) + assert isinstance(helpers['vedastro_archive_manifest'], dict) + assert isinstance(helpers['interpretation_coverage'], dict) + + +def test_consultation_workflow_service_no_longer_forges_for_evidence() -> None: + source = os.path.join(SCRIPTS, 'consultation_workflow_service.py') + tree = ast.parse(open(source, encoding='utf-8').read()) + fn = next( + n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == 'build_runtime_evidence_helpers' + ) + body = ast.dump(fn) + assert 'JyotishAPIHandler' not in body + assert 'VedastroEvidenceMixin' in body + + +def test_http_handler_still_inherits_the_moved_methods() -> None: + """HTTP-visible behaviour must be unchanged: same methods, now via MRO.""" + for name in ( + '_get_float', + '_check_range', + '_normalize_degree', + '_compute_synastry', + '_high_rigor_vedastro_official_summary', + '_compute_vedastro_gateway_archives', + '_interpretation_source_runtime_coverage', + ): + assert hasattr(JyotishAPIHandler, name), name + assert issubclass(JyotishAPIHandler, VedastroEvidenceMixin) + assert issubclass(JyotishAPIHandler, SynastryMixin) + assert issubclass(SynastryMixin, RequestParamMixin)