把星盘接口的计算和 HTTP 拆成两个类,离线入口不再伪造请求对象。

方法体未改。定向测试 383 通过;1 条校正回归在基线上同样失败,断言未改。快速门和全量 pytest 未跑。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-16 10:24:42 +08:00
co-authored by Grok (agent loop)
parent fbf04f0b5f
commit c7350bcafb
25 changed files with 3104 additions and 658 deletions
@@ -0,0 +1,88 @@
# PROGRESS · API 计算与 HTTP 拆类(2026-10-16)
- 分支:`codex/api-server-service-extract-20261013`
- 任务书里的基线 `2994588ad4d858dead4835943e5883c5aa2b03f8` 已过期。本分支从当前 `origin/staging` `fbf04f0b5f31cd8421bc4546840b1c833aaed810` 新建,没有上游。该提交已包含 #69 与 #68。
- 没有新 BUG 编号。结构改造,不写 `docs/BUG_HISTORY.md`,不写 `CHANGELOG.md`。
## 开工重测
| 指标 | 任务书写作时 | 开工实测 |
| --- | --- | --- |
| `scripts/jyotish_api_server.py` 换行数 | 10970 | **10906** |
| 四空格 `def`(合同口径) | 218 | **217**(BUG-1341 已下调,本单保持) |
| `JyotishAPIHandler` 类方法 | 215 | **214** |
| `JyotishAPIHandler.__new__` 合计 | 31 | **31**(`scripts/` 2 + `tests/` 29,不含合同文件) |
| 行数基线 / 上限 | 10924 / 11224 | 基线不动。搬后换行数 **10920**,低于基线 |
HTTP 侧按「直接碰 HTTP 成员或覆写 `BaseHTTPRequestHandler`,再向上收到调用闭包」重算,仍是 15 个方法,外加 `server_version`:
- 直接:`_json`、`_html`、`_send_cors_headers`、`_enforce_request_security`、`_job_access_token`、`do_OPTIONS`、`do_GET`、`do_POST`、`_read_json_body`
- 调用者:`_error_json`、`_require_dynamic_rectification_token`、`_get_high_rigor_job`、`_get_chart_job`、`_compute_dynamic_rectification_opportunities`、`_compute_dynamic_rectification_score`
类里没有 `self.xxx =` 赋值。
## 做了什么
- 同一文件里把原类改成 `JyotishComputeService`(199 个计算方法),新增 `JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService)`,放入上面 15 个方法和 `server_version`。`ThreadingHTTPServer` 仍用 `JyotishAPIHandler`。
- 方法体与基线逐个 `ast.dump` 相等:旧类 214 个方法,新文件 214 个方法,不一致 **0**。换类的成员是这 15 个方法加 `server_version`,共 16 个。
- `scripts/consultation_workflow_service.py` 和 `scripts/capture_report_blocked_repairs_golden.py` 改为 `JyotishComputeService()`。`scripts/` 里 `JyotishAPIHandler.__new__` 为 **0**。
- 只用计算方法的 17 个测试文件同样改了构造。`tests/test_active_rectification_api.py` 里 3 处补丁从 HTTP 类改到计算类,否则补丁打不中实例。断言没改。
- 增长合同:总伪造 31 → **5**,生产伪造 2 → **0**,方法数 217 不变,行数基线 10924 不动。新增两条守卫:计算类不是 HTTP 处理器子类(并钉住 MRO 与 `server_version`);计算类方法体不碰 HTTP 成员、也不调用上述 15 个方法。
- 搬前 golden 连跑两次哈希一致,落在 `tests/golden/api_server_service_extract_v1.json`(只存 sha256 和键路径类型)。采集脚本:`scripts/research/api_service_extract_golden.py`。搬后对照由 `tests/test_api_service_extract_golden.py` 重算,通过。
构造语句三栏(17 个测试文件和 2 个生产文件,每处相同):
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| `JyotishAPIHandler.__new__(JyotishAPIHandler)` | `JyotishComputeService()` | 计算类可以正常实例化,不必再绕过 HTTP 构造 |
`tests/test_active_rectification_api.py` 另有三处:
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| 补丁打在 `JyotishAPIHandler` | 补丁打在 `JyotishComputeService` | 实例已是计算类,补丁必须打在实例的类上。断言未改 |
未改的 HTTP 侧伪造(5 处,合同新基线就是这 5 处):`test_api_server_security.py`、`test_api_async_job_contract.py`、`test_runtime_security_p0.py`、`test_dynamic_rectification_scoring.py`、`test_professional_report_reference_api.py`。其中安全测试文件 `git diff` 为空。
## 命令和结果
| 检查 | 命令 | 结果数字 |
| --- | --- | --- |
| 搬前 golden 连跑两次 | 基线上采集 MCP / HTTP / 报告裁剪 / 证据助手 | 8 个哈希两次相同 |
| 搬后 golden | `pytest tests/test_api_service_extract_golden.py`(含在下面那条里) | 通过,与搬前哈希一致 |
| 任务书点名测试 + 改过的测试 + 两个调用了采集入口的测试 | `pytest` 上述文件 `-q --tb=line` | **383 通过,1 失败**,约 1046 秒 |
| 方法体对比 | 基线文件与工作树 `ast.dump` | 214 对 214,不一致 0 |
| 反向:计算类临时写 `self.headers` | `test_compute_service_methods_do_not_touch_http` | 失败后已撤回 |
| 反向:`scripts/` 临时加一处伪造标记 | `test_scripts_handler_forgeries_must_not_grow` | 1 > 0,失败后已撤回 |
| 安全测试文件 | `git diff -- tests/test_api_server_security.py` | 空 |
| 生产伪造 | 在 `scripts/**/*.py` 搜伪造标记 | **0** |
| 换行数 | 文件内 `\n` 个数 | 10906 → **10920**(上限 11224) |
| 隐私标记 | `pytest tests/test_repo_privacy_markers.py -q` | 退出码 0 |
搬前哈希(去掉两个墙钟字段后的规范 JSON):
| 案例 | sha256 | 字节 |
| --- | --- | ---: |
| mcp.career | `ad38a539cf9036143333acd538567b7eb94528d537c46ef326905ebc47527a05` | 543868 |
| http.career | `6a6ca421d942e2577e587647324ef9521ea14841a74a6850c11f629df4d9ba96` | 420555 |
| mcp.wealth | `9aaacf64d08f17bca2d3f6b020b1dbb1ec07eaee7727b04bba90430fcbf594d3` | 542629 |
| http.wealth | `f22368bbde38b76dc44e2995c34f7809ca96442405c1f13e39f53c4a2dd90a62` | 419316 |
| mcp.relationship | `be70fd603b1de52026ad40f0d6582a27cb8b148018474fcd7d8c4134509b40ae` | 537785 |
| http.relationship | `4405ae821aef81ec03660994d9f8f4104ae3388f339407bfe2721d05d662059b` | 414472 |
| repairs | `41d48a453fbd5d616de083fe07bb8d318c3f60ad2e127764237ef6ae50cc6175` | 74700 |
| evidence | `27975ba40a9ee89fc19543a18cb2ee60fb2477b8a6e8f14004ad411adfc408a2` | 2092 |
MCP 与 HTTP 彼此不相等:MCP 走 `surface=skill_mcp`,HTTP 方法默认 `api_web`,并且 HTTP 方法还会多挂一层咨询全文。这是原来的两条路径,不是这次拆类造成的。每条路径搬前搬后各自一致。
## 那 1 条失败
`tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` 期望时间窗 `05:07–05:08`,实际得到 `07:00–07:02`。把该测试文件还原到基线后再跑,失败内容相同。不是拆类引入的,也不是「用到了 HTTP」。按任务书,这种红不回退该文件。断言没改。同文件其余测试在本轮通过。
## 偏离
- 任务书交付物写了「删两处死代码」。硬红线写明零引用函数只记进度、本单不删。`_strict_adjudication_bundle_from_contract` 与 `_high_rigor_job_path` 仍只在定义处出现,没有删。
- 后续「把咨询闭包搬出文件、改依赖方向」没做。那一步要改安全测试的补丁落点,任务书明确不做。
- `AGENTS.md` §6 没有写死伪造数字 31 或 2,所以没改措辞。
- `skills/jyotish-vedic-astrology/scripts/` 里的同名副本没改。任务书点名的是 `scripts/`。合同计数也不含技能副本。
- 另有若干 `object.__new__(JyotishAPIHandler)`,不在合同计数里,调用的是计算方法,经继承仍可用。本单没改。
- 没跑:Python 快速门、全量 pytest、前端 `npm test`、`test:db`。没动数据库,也没对生产或 staging 做压测或登录。
@@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from consultation_plan_contract import PLAN_VERSION, _ROUTE_CONTRACTS # noqa: E402
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_api_server import JyotishComputeService # noqa: E402
BIRTH = {
"year": 1993,
@@ -55,8 +55,8 @@ CONSUMER_KEEP = (
CHART_KEEP = ("success", "ascendant", "planets", "houses", "dasha")
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def _body(theme: str) -> dict:
+2 -2
View File
@@ -15,9 +15,9 @@ for path in (ROOT, SCRIPTS_DIR):
def execute_consultation_workflow(body: dict[str, Any], *, surface: str = "api") -> dict[str, Any]:
from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow as _execute
from jyotish_api_server import JyotishComputeService, execute_consultation_workflow as _execute
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
return _execute(handler, body=body, surface=surface)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
"""Canonical hashes for the compute/HTTP class split.
Fictional smoke chart only. Clock fields are removed before hashing.
Payload values are not written; the golden file stores sha256 and key-path types.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts"
GOLDEN_PATH = ROOT / "tests" / "golden" / "api_server_service_extract_v1.json"
CACHE_DIR = ROOT / "scratch" / "local" / "api_chart_cache"
CLOCK_KEYS = {"cache_created_at", "cache_expires_at"}
THEMES = ("career", "wealth", "relationship")
BASELINE_SHA = "fbf04f0b5f31cd8421bc4546840b1c833aaed810"
# Synthetic positions reused from tests/test_offline_compute_mixins.py.
# Not a birth record.
EVIDENCE_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 prepare_env() -> None:
os.environ["VEDASTRO_ENABLE_NETWORK"] = "0"
os.environ.setdefault("JYOTISH_SKIP_LOCAL_ENV", "1")
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
for path in (str(ROOT), str(SCRIPTS)):
if path not in sys.path:
sys.path.insert(0, path)
def clear_chart_cache() -> None:
if CACHE_DIR.exists():
shutil.rmtree(CACHE_DIR)
def theme_body(theme: str) -> dict[str, Any]:
"""Fictional smoke chart from the task brief. Coordinates only, no person."""
return {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"theme": [theme],
"defer_optional_external_evidence": True,
"today": "2026-10-13",
}
def strip_clock(value: Any) -> Any:
if isinstance(value, dict):
return {
key: strip_clock(item)
for key, item in value.items()
if key not in CLOCK_KEYS
}
if isinstance(value, list):
return [strip_clock(item) for item in value]
return value
def json_type(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return type(value).__name__
def flatten_keypaths(value: Any, prefix: str = "", *, depth: int = 0, max_depth: int = 2) -> dict[str, str]:
out: dict[str, str] = {}
label = prefix or "$"
out[label] = json_type(value)
if depth >= max_depth:
return out
if isinstance(value, dict):
for key in sorted(item for item in value if isinstance(item, str)):
child = f"{prefix}.{key}" if prefix else key
out.update(flatten_keypaths(value[key], child, depth=depth + 1, max_depth=max_depth))
elif isinstance(value, list) and value:
child = f"{prefix}[]" if prefix else "[]"
out.update(flatten_keypaths(value[0], child, depth=depth + 1, max_depth=max_depth))
return out
def canonical_text(value: Any) -> str:
return json.dumps(strip_clock(value), sort_keys=True, ensure_ascii=False)
def digest(value: Any) -> dict[str, Any]:
text = canonical_text(value)
return {
"sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
"canonicalBytes": len(text.encode("utf-8")),
"keypaths": flatten_keypaths(strip_clock(value)),
}
def capture_mcp(theme: str) -> Any:
prepare_env()
clear_chart_cache()
from consultation_workflow_service import execute_consultation_workflow
return execute_consultation_workflow(theme_body(theme), surface="skill_mcp")
def capture_http(theme: str) -> Any:
"""HTTP handler method, on the compute class after the split."""
prepare_env()
clear_chart_cache()
from jyotish_api_server import JyotishComputeService
return JyotishComputeService()._compute_consultation_workflow(theme_body(theme))
def capture_repairs() -> dict[str, Any]:
prepare_env()
clear_chart_cache()
from capture_report_blocked_repairs_golden import (
BIRTH,
MODULE_KEEP,
THEMES as REPAIR_THEMES,
VARGA_KEEP,
_body,
_handler,
_trim_workflow,
)
payload: dict[str, Any] = {
"source": "scripts/jyotish_api_server.py _compute_consultation_workflow",
"smoke_birth": {**BIRTH, "note": "fictional capture identity; not a real person"},
"trim": {
"keep_varga_keys": list(VARGA_KEEP),
"keep_modules": list(MODULE_KEEP),
"drop": [
"ai_prompt_pack",
"vedastro",
"varga_spectrum",
"guided_topics",
"western_spectrum",
"technique_audit_table",
],
},
"themes": {},
}
for theme in REPAIR_THEMES:
clear_chart_cache()
raw = _handler()._compute_consultation_workflow(_body(theme))
payload["themes"][theme] = _trim_workflow(raw)
return payload
def capture_evidence() -> Any:
prepare_env()
from consultation_workflow_service import build_runtime_evidence_helpers
return build_runtime_evidence_helpers(EVIDENCE_CHART)
def capture_cases(*, include_http: bool = True, include_repairs: bool = True) -> dict[str, Any]:
cases: dict[str, Any] = {}
for theme in THEMES:
cases[f"mcp.{theme}"] = capture_mcp(theme)
if include_http:
cases[f"http.{theme}"] = capture_http(theme)
if include_repairs:
cases["repairs"] = capture_repairs()
cases["evidence"] = capture_evidence()
return cases
def summarize(cases: dict[str, Any]) -> dict[str, Any]:
return {name: digest(value) for name, value in cases.items()}
def provenance() -> dict[str, Any]:
return {
"fictional": True,
"command": "python scripts/research/api_service_extract_golden.py --check",
"baselineSha": BASELINE_SHA,
"env": {
"VEDASTRO_ENABLE_NETWORK": "0",
"JYOTISH_SKIP_LOCAL_ENV": "1",
},
"clockFieldsRemoved": sorted(CLOCK_KEYS),
"mcpSurface": "skill_mcp",
"note": "fictional smoke chart from the task brief; payload values are not stored",
}
def document_from_summary(summary: dict[str, Any]) -> dict[str, Any]:
return {
"kind": "sha256_and_keypath_types",
"fixtureProvenance": provenance(),
"cases": summary,
}
def write_golden(summary: dict[str, Any], path: Path = GOLDEN_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(document_from_summary(summary), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
path.write_text(text, encoding="utf-8")
def load_golden(path: Path = GOLDEN_PATH) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def check_golden(path: Path = GOLDEN_PATH) -> list[str]:
expected = load_golden(path)["cases"]
actual = summarize(capture_cases())
mismatches: list[str] = []
if set(expected) != set(actual):
mismatches.append(f"case names {sorted(expected)} != {sorted(actual)}")
for name in sorted(set(expected) & set(actual)):
if expected[name]["sha256"] != actual[name]["sha256"]:
mismatches.append(
f"{name} sha256 {expected[name]['sha256']} != {actual[name]['sha256']} "
f"bytes {expected[name]['canonicalBytes']} != {actual[name]['canonicalBytes']}"
)
elif expected[name]["keypaths"] != actual[name]["keypaths"]:
mismatches.append(f"{name} keypaths differ")
return mismatches
def main(argv: list[str]) -> int:
prepare_env()
if "--check" in argv:
mismatches = check_golden()
if mismatches:
print("MISMATCH")
for item in mismatches:
print(item)
return 1
print("MATCH", len(load_golden()["cases"]))
return 0
summary = summarize(capture_cases())
for name, row in summary.items():
print(f"{name} {row['sha256']} {row['canonicalBytes']}")
if "--write" in argv:
write_golden(summary)
print("WROTE", GOLDEN_PATH)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -14,11 +14,11 @@ if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import jyotish_api_server as api_server # noqa: E402
from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402
from jyotish_api_server import BadRequest, JyotishComputeService # noqa: E402
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def test_active_rectification_questions_api_builds_choice_workflow() -> None:
@@ -515,7 +515,7 @@ def test_high_rigor_event_rectification_requires_real_vedastro_candidate_discrim
},
)
monkeypatch.setattr(
JyotishAPIHandler,
JyotishComputeService,
"_compute_vedastro_gateway_run",
lambda *_args, **_kwargs: {
"status": "ok",
@@ -633,7 +633,7 @@ def test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow(mon
},
)
monkeypatch.setattr(
JyotishAPIHandler,
JyotishComputeService,
"_compute_vedastro_gateway_run",
lambda *_args, **_kwargs: {
"status": "ok",
@@ -825,7 +825,7 @@ def test_search_events_difference_cannot_override_identical_minute_snapshots(mon
},
)
monkeypatch.setattr(
JyotishAPIHandler,
JyotishComputeService,
"_compute_vedastro_gateway_run",
lambda *_args, **_kwargs: {
"status": "ok",
+2 -2
View File
@@ -77,14 +77,14 @@ def test_v4_wrapper_uses_raman_spec_and_never_confirms_a_minute(monkeypatch) ->
def test_v4_api_validates_event_kind_and_date_boundaries(monkeypatch) -> None:
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
captured = {}
monkeypatch.setattr(
"scripts.rectification.api_service.score_candidates",
lambda request: captured.setdefault("request", request) or {},
)
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
body = {
"birth_date": "1997-08-08",
"start_time": "05:00",
+104 -16
View File
@@ -2,11 +2,12 @@
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.
handler 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.
Baselines re-measured 2026-10-16 after TASK-api-server-service-extract-20261013
moved HTTP methods onto JyotishAPIHandler and left calculation on
JyotishComputeService. Production forgeries went from 2 to 0. The two classes
stay in this file so existing module-attribute patches still land.
"""
from __future__ import annotations
@@ -23,6 +24,9 @@ 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.
# 原值:10924
# 新值:10924
# 原因:行数基线按任务书保持不动。拆类只加了类声明,实测行数仍不超过这条基线。
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 10924
JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
@@ -30,22 +34,21 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# 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).
# 原值:218(删占位回退之前)
# 原值:217
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 原因:BUG-1341 已把基线降到 217。本单只把 HTTP 方法换到子类,不增删方法。
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217
# `JyotishAPIHandler.__new__` in scripts/ and tests/ `*.py`, excluding this file.
# 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
# 原值:31
# 新值:5
# 原因:生产 2 处和只用计算方法的测试改为 JyotishComputeService()。留下的 5 处要碰 HTTP,本单不改。
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 5
# 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
# Production forgeries only. Offline callers instantiate JyotishComputeService.
# 原值:2
# 新值:0
# 原因:consultation_workflow_service 与 capture_report_blocked_repairs_golden 改为正常实例化计算类。
JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 0
# Compute-only mixins lifted out of the handler must never depend back on the
# HTTP monolith, or the backdoor simply moves house.
@@ -141,3 +144,88 @@ def test_quality_gate_runs_api_server_growth_contract() -> None:
assert "tests/test_api_server_growth_contract.py" in CORE_PYTEST_TARGETS
quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")
assert '"tests/test_api_server_growth_contract.py"' in quality_gate
_HTTP_ATTRS = frozenset({
"headers",
"wfile",
"rfile",
"client_address",
"send_response",
"send_header",
"end_headers",
"path",
"command",
})
_HTTP_METHOD_NAMES = frozenset({
"_json",
"_html",
"_send_cors_headers",
"_read_json_body",
"_enforce_request_security",
"_job_access_token",
"do_GET",
"do_POST",
"do_OPTIONS",
"_error_json",
"_get_chart_job",
"_get_high_rigor_job",
"_require_dynamic_rectification_token",
"_compute_dynamic_rectification_score",
"_compute_dynamic_rectification_opportunities",
})
def test_compute_service_is_not_an_http_handler() -> None:
"""JyotishComputeService can be constructed. HTTP stays on the subclass."""
from http.server import BaseHTTPRequestHandler
from scripts.jyotish_api_server import JyotishAPIHandler, JyotishComputeService
assert not issubclass(JyotishComputeService, BaseHTTPRequestHandler)
assert issubclass(JyotishAPIHandler, JyotishComputeService)
assert issubclass(JyotishAPIHandler, BaseHTTPRequestHandler)
mro_names = [item.__name__ for item in JyotishAPIHandler.__mro__]
assert mro_names.index("BaseHTTPRequestHandler") < mro_names.index("JyotishComputeService")
assert JyotishAPIHandler.server_version == "JyotishAPI/6.9.14"
assert "server_version" not in JyotishComputeService.__dict__
for name in _HTTP_METHOD_NAMES:
assert name in JyotishAPIHandler.__dict__, name
assert name not in JyotishComputeService.__dict__, name
def _http_touches(fn: ast.AST) -> list[str]:
hits: list[str] = []
for node in ast.walk(fn):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:{node.attr}")
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "getattr"
and len(node.args) >= 2
and isinstance(node.args[0], ast.Name)
and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)
and node.args[1].value in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:getattr:{node.args[1].value}")
return hits
def test_compute_service_methods_do_not_touch_http() -> None:
tree = ast.parse(API_SERVER.read_text(encoding="utf-8"))
compute = next(
node for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "JyotishComputeService"
)
offenders: list[str] = []
for node in compute.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
offenders.extend(f"{node.name}:{hit}" for hit in _http_touches(node))
assert offenders == []
+17
View File
@@ -0,0 +1,17 @@
"""Recompute the fictional smoke chart and compare canonical hashes.
The stored golden keeps sha256 and key-path types only.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
def test_api_service_extract_golden_matches_baseline() -> None:
path = Path(__file__).resolve().parents[1] / "scripts" / "research" / "api_service_extract_golden.py"
spec = importlib.util.spec_from_file_location("api_service_extract_golden", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.check_golden() == []
+2 -2
View File
@@ -13,7 +13,7 @@ if str(SCRIPTS) not in sys.path:
import domain_calculation_service as calculation_service # noqa: E402
import jyotish_api_server # noqa: E402
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_api_server import JyotishComputeService # noqa: E402
from jyotish_engine import _compute_chart_from_args # noqa: E402
BIRTH = {
@@ -121,7 +121,7 @@ def test_chart_hash_matches_domain_cli_and_rest(
cli, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args(
SimpleNamespace(**BIRTH, node_mode="true")
)
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(
rest = JyotishComputeService()._compute_chart_sync(
{**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"}
)
@@ -11,7 +11,7 @@ if str(SCRIPTS) not in sys.path:
import domain_calculation_service as calculation_service # noqa: E402
import jyotish_api_server # noqa: E402
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_api_server import JyotishComputeService # noqa: E402
BIRTH = {
"year": 1990,
@@ -100,7 +100,7 @@ def test_api_chart_uses_same_domain_contract_and_preserves_shape(
) -> None:
import domain_calculation_service
import jyotish_api_server
from jyotish_api_server import JyotishAPIHandler
from jyotish_api_server import JyotishComputeService
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
@@ -111,7 +111,7 @@ def test_api_chart_uses_same_domain_contract_and_preserves_shape(
)
expected = domain_calculation_service.compute_chart(BIRTH)
result = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(
result = JyotishComputeService()._compute_chart_sync(
{**BIRTH, "transit_date": "2026-07-11"}
)
@@ -141,7 +141,7 @@ def test_api_chart_response_uses_domain_contract_hash(monkeypatch: pytest.Monkey
)
expected = calculation_service.compute_chart({**BIRTH, "node_mode": "true"})
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(
rest = JyotishComputeService()._compute_chart_sync(
{**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"}
)
@@ -160,7 +160,7 @@ def test_api_visible_chart_values_come_from_domain_service(monkeypatch: pytest.M
request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"}
expected = calculation_service.compute_chart(request)
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request)
rest = JyotishComputeService()._compute_chart_sync(request)
assert rest["ascendant"]["lon"] == pytest.approx(expected["ascendant"]["lon"], abs=1e-8)
for planet in ("Sun", "Moon", "Rahu", "Ketu"):
@@ -187,7 +187,7 @@ def test_api_sade_sati_uses_domain_true_saturn_transit(monkeypatch: pytest.Monke
ayanamsa=BIRTH["ayanamsa"],
)
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request)
rest = JyotishComputeService()._compute_chart_sync(request)
assert rest["sade_sati"]["transit_saturn_lon"] == pytest.approx(
expected["transit_saturn_lon"], abs=1e-8
@@ -210,7 +210,7 @@ def test_api_dasha_boundary_comes_from_domain_service(monkeypatch: pytest.Monkey
moon_lon=chart["planets"]["Moon"]["lon"],
)
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request)
rest = JyotishComputeService()._compute_chart_sync(request)
assert rest["dasha"]["current_md"] == expected["birth_balance"]["lord"]
assert rest["dasha"]["remaining_years"] == pytest.approx(
+3 -3
View File
@@ -12,7 +12,7 @@ if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from birth_time_rectifier import get_effective_accuracy, get_enabled_vargas # noqa: E402
from jyotish_api_server import JyotishAPIHandler, _build_consumer_context # noqa: E402
from jyotish_api_server import JyotishComputeService, _build_consumer_context # noqa: E402
CANDIDATE_WINDOW_COPY = re.compile(
r'candidate windows|not_auto_rectified|按时间范围|建议先做事件反验',
@@ -21,8 +21,8 @@ CANDIDATE_WINDOW_COPY = re.compile(
PRECISION_ANNOTATION_COPY = re.compile(r'填报时间|标注精度|precision', re.I)
def _handler() -> JyotishAPIHandler:
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
handler = JyotishComputeService()
handler._compute_vedastro_gateway_run = lambda body: { # type: ignore[method-assign]
'status': 'official_blocked',
'official_closure_reason': 'accuracy_contract_probe',
+3 -3
View File
@@ -17,7 +17,7 @@ from jyotish_api_server import ( # noqa: E402
_PRECISE_BIRTH_TIME_ACCURACY,
_ROUTE_DOMAIN_CONTEXT,
_ROUTE_REQUIRED_LAYERS,
JyotishAPIHandler,
JyotishComputeService,
_attach_local_consultation_layers,
_build_consumer_context,
_compact_vedastro_cross_check,
@@ -26,8 +26,8 @@ from jyotish_api_server import ( # noqa: E402
from unified_consultation_orchestrator import UnifiedConsultationOrchestrator # noqa: E402
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
_BIRTH = {'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0}
+6 -6
View File
@@ -267,9 +267,9 @@ def _consultation_body(**overrides):
def test_declared_route_executes_even_when_question_text_names_another_domain():
"""One question, several domains: text routing can only ever agree with one of them."""
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.jyotish_api_server import JyotishComputeService
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
question = "我的事业和财运接下来会怎么走,两者之间该怎么取舍"
wealth = handler._compute_consultation_workflow(_consultation_body(
@@ -287,9 +287,9 @@ def test_declared_route_executes_even_when_question_text_names_another_domain():
def test_declared_route_cannot_smuggle_a_route_off_the_server_allowlist():
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
with pytest.raises(BadRequest, match="unsupported consultation workflow route"):
handler._compute_consultation_workflow(_consultation_body(
@@ -308,9 +308,9 @@ def test_declared_route_cannot_smuggle_a_route_off_the_server_allowlist():
def test_api_dry_run_exposes_validated_plan_contract_and_rejects_tampering():
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
body = {
**timing_body(),
"dry_run": True,
@@ -16,7 +16,7 @@ if SCRIPTS not in sys.path:
import flexible_birth_time_profile as profile_module # noqa: E402
from jyotish_engine import _birth_datetime_from_args # noqa: E402
from scripts.consultation_domain_registry import CANONICAL_DOMAINS # noqa: E402
from scripts.jyotish_api_server import JyotishAPIHandler, _load_local_module # noqa: E402
from scripts.jyotish_api_server import JyotishComputeService, _load_local_module # noqa: E402
_SMOKE_BIRTH = {
@@ -31,11 +31,11 @@ _SMOKE_BIRTH = {
}
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def _stub_consultation_runtime(monkeypatch, handler: JyotishAPIHandler) -> None:
def _stub_consultation_runtime(monkeypatch, handler: JyotishComputeService) -> None:
chart = {
"success": True,
"birth_info": {"date": "1993-06-15", "time": "10:30", "tz": 8},
+4 -4
View File
@@ -7,7 +7,7 @@ import pytest
from scripts.consultation_domain_registry import CANONICAL_DOMAINS
from scripts.consultation_plan_contract import plan_route_contract
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
from scripts.report_orchestrator import (
INDEPENDENT_REPORT_THEMES,
ThemeName,
@@ -18,11 +18,11 @@ from scripts.report_orchestrator import (
_NATIVE_THEMATIC_DOMAINS = {"career", "marriage", "wealth", "health"}
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def _stub_consultation_runtime(monkeypatch, handler: JyotishAPIHandler, *, include_strict_contracts: bool = True) -> None:
def _stub_consultation_runtime(monkeypatch, handler: JyotishComputeService, *, include_strict_contracts: bool = True) -> None:
chart = {
"success": True,
"birth_info": {"date": "1997-08-08", "time": "05:00", "tz": 8},
+2 -2
View File
@@ -79,9 +79,9 @@ def test_window_packet_probes_always_pass_hour_and_minute(monkeypatch: pytest.Mo
def test_api_handler_rejects_hour_on_declared_window() -> None:
from jyotish_api_server import BadRequest, JyotishAPIHandler
from jyotish_api_server import BadRequest, JyotishComputeService
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
with pytest.raises(BadRequest, match="single birth minute"):
handler._compute_declared_window_chart({
**BIRTH_PLACE,
+2 -2
View File
@@ -192,7 +192,7 @@ def test_api_turns_invalid_birth_time_accuracy_into_bad_request(monkeypatch) ->
return {"modules": {}}
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
handler = api.JyotishComputeService()
with pytest.raises(
api.BadRequest,
@@ -218,7 +218,7 @@ def test_api_turns_invalid_report_window_into_bad_request(monkeypatch) -> None:
raise ValueError("birth_time_window_must_not_cross_midnight")
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
handler = api.JyotishComputeService()
with pytest.raises(api.BadRequest, match="birth_time_window_must_not_cross_midnight"):
handler._compute_full_reading_for_thematic({
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.jyotish_api_server import JyotishComputeService
from scripts.jyotish_engine import _build_ai_prompt_pack, _build_health_narrative_payload
@@ -44,7 +44,7 @@ def test_health_narrative_uses_only_health_vargas_lords_dasha_and_related_shadba
def test_api_prompt_pack_keeps_compact_health_narrative():
report = _report()
chart = {**report["chart"], "modules": report["modules"], "birth": {}, "dasha": {}}
prompt = JyotishAPIHandler.__new__(JyotishAPIHandler)._build_chart_prompt_pack(chart)
prompt = JyotishComputeService()._build_chart_prompt_pack(chart)
seed = prompt["evidence_snapshot"]["health_narrative"]
assert seed["status"] == "parameter_sensitive"
assert "D6/D8/D30" in seed["headline"]
+3 -3
View File
@@ -6,11 +6,11 @@ SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_api_server import JyotishComputeService # noqa: E402
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def _planets():
+2 -2
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
ROOT = Path(__file__).resolve().parents[1]
@@ -39,7 +39,7 @@ def test_cli_prashna_blocks_legacy_approximation_modes():
def test_api_prashna_rejects_client_planets_and_computes_context():
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
body = {
"question_text": "Test question", "question_timestamp": "2026-07-12T12:00:00+08:00",
"lat": 39.9042, "lon": 116.4074, "timezone": 8,
@@ -9,7 +9,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_api_server import JyotishComputeService # noqa: E402
from relationship_analysis import analyze_relationship # noqa: E402
from relationship_event_class_evidence import build_event_class_evidence # noqa: E402
@@ -74,7 +74,7 @@ def test_consultation_shaped_1990_chart_feeds_nonempty_event_class_hits() -> Non
)
)
chart = golden["chart"]
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
handler = JyotishComputeService()
dasha = (chart.get("modules") or {}).get("dasha") or {}
dasha_info = handler._thematic_dasha_info(chart, dasha)
assert dasha_info["maha_dasha"] == "Saturn"
+3 -3
View File
@@ -2,11 +2,11 @@
from __future__ import annotations
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.jyotish_api_server import JyotishComputeService
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def test_prefers_dasha_sub_periods_current_over_birth_md() -> None:
+4 -4
View File
@@ -11,7 +11,7 @@ import pytest
from scripts import jyotish_api_server
from scripts import vedastro_foreground as foreground
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.jyotish_api_server import JyotishComputeService
from scripts.vedastro_snapshot_cache import (
lookup_snapshot,
snapshot_cache_dir,
@@ -21,8 +21,8 @@ from scripts.vedastro_snapshot_cache import (
)
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _handler() -> JyotishComputeService:
return JyotishComputeService()
def _verified_gateway(*, request_id: str, reference_date: str) -> dict:
@@ -56,7 +56,7 @@ def _fake_chart() -> dict:
}
def _stub_local(monkeypatch, handler: JyotishAPIHandler, gateway) -> None:
def _stub_local(monkeypatch, handler: JyotishComputeService, gateway) -> None:
monkeypatch.setattr(handler, "_compute_chart", lambda body: _fake_chart())
monkeypatch.setattr(
handler,