diff --git a/docs/engine/annual-package-v1.md b/docs/engine/annual-package-v1.md index c05d9c7c..14694276 100644 --- a/docs/engine/annual-package-v1.md +++ b/docs/engine/annual-package-v1.md @@ -8,7 +8,7 @@ - `"detail": "slim"`(#154 遮罩用):只回报告范围、每张年盘的有效期、生日分界和块状态,几 KB;不回运限行、行运和生产器数据。默认 `"full"`。 - 年运报告的事实与结论卡:`POST /api/annual/report_facts`(同样的请求体,加必填的 `birth_time_confirmed`),见 `annual-report-v1.md`。 - 不带 `mode` 的 `/api/annual` 仍返回原来的基础太阳返照报告(`report`),形状不变;只多传了 `node_mode`(默认 mean,结果不变)。 -- 代码:`scripts/annual_package.py`(`normalize_request`、`build_annual_package`),API 处理在 `scripts/jyotish_api_server.py::_compute_annual_package`。前端合同:`frontend/src/lib/annual-package-contract.ts`(zod + 映射,无界面)。 +- 代码:`scripts/annual_service/package.py`(`normalize_request`、`build_annual_package`);请求解析与入口在 `scripts/annual_service/api.py`(`compute_annual_package`、`compute_annual_report_facts`,输入错误 `AnnualServiceInputError` 映射为 400),`scripts/jyotish_api_server.py` 只做薄注册(`_annual_service`,与 `/api/chart_points` 同一做法)。前端合同:`frontend/src/lib/annual-package-contract.ts`(zod + 映射,无界面)。 ### 请求字段 diff --git a/docs/engine/annual-report-v1.md b/docs/engine/annual-report-v1.md index 335a0ff5..bd03608a 100644 --- a/docs/engine/annual-report-v1.md +++ b/docs/engine/annual-report-v1.md @@ -7,7 +7,7 @@ 1. **入口**:「我的报告」页多一张「年运」卡片,直接进 `/reports/annual`(放在 `/reports` 下面,侧栏仍亮「我的报告」)。没有单独的入口页。 2. **遮罩**:页面先请求 `GET /api/annual/overview?subject=&kind=`(Next 路由 → Python `/api/annual/package`,`detail: "slim"`,几 KB)。返回人物、范围、三条注意事项、出生时间是否确认、价格,以及这一年是不是已经写过。写过的(或正在写的)直接打开,不出遮罩,也不再扣点。 3. **开始**:`POST /api/reports {reportType: "annual_full", subject, rangeKind, situation}`。服务端预留 `annual.full` 的点数,建 `personal_reports` 一行(`report_type = annual_full`,范围键放在 `requested_themes`,例如 `annual:birthday_year:2026`),把范围和现状存成 `personal_report_sections` 的 `annual-report-input`,入报告队列。 -4. **算**:worker 调 Python `POST /api/annual/report_facts`(`scripts/annual_report_facts.py`)。这是写手唯一能用的材料:本命底子、年内各段及走势、转折点、候选窗口、各领域信号、分歧、技法执行表。 +4. **算**:worker 调 Python `POST /api/annual/report_facts`(`scripts/annual_service/report_facts.py`)。这是写手唯一能用的材料:本命底子、年内各段及走势、转折点、候选窗口、各领域信号、分歧、技法执行表。 5. **写**:按顺序一节一节写(`src/lib/annual-report-generate.ts`,写手 `src/mastra/annual-report.ts`,没有工具、记忆和技能)。每写完一节就把快照存进 `annual-report-snapshot`,页面每 2.5 秒读一次,写好的那一节出现,下一节显示「正在写这一节……」。 6. **检查**:每一节过程序检查(`gateSection`):格式、确定性用语、死亡灾祸疾病诊断、正文里出现具体日期、回看写成「你已经经历了」、重复标题、段 / 窗口 / 行的 id 对不上。不过的那一节带着原因重写一次,再不过整份停下。 7. **结束**:写完结算 `annual.full`(`complete_usage`,事件键 `annual.full`),状态改为 `ready`、`schema_version = annual_report.v1`。中途失败按报告队列原来的规则重试;最终失败释放预留,不扣点(遮罩上「写到一半断掉不扣」)。 @@ -46,7 +46,7 @@ ## 默认口径(待占星师确认) -以下都集中在一处,改一行即可:Python `scripts/annual_report_facts.py` 的 `CONFIG` / `HOUSE_DOMAINS`,前端 `src/lib/annual-report-sections.ts`(分段与写法)。 +以下都集中在一处,改一行即可:Python `scripts/annual_service/report_facts.py` 的 `CONFIG` / `HOUSE_DOMAINS`,前端 `src/lib/annual-report-sections.ts`(分段与写法)。 1. **Narayana 骨架**:用产品时间轴现行的旧算法(`narayana_skeleton_profile = "legacy"`);Sanjay Rath 版只并列参考,不算第二份支持(`narayana_rath_counts_as_support = False`)。 2. **Tithi Pravesha**:保持生产器自己的地点规则(出生地、出生固定偏移),只作独立年度视角,不并入 Tajika 年,不进候选窗口(`tithi_pravesha_in_windows = False`)。 diff --git a/frontend/src/lib/annual-package-contract.ts b/frontend/src/lib/annual-package-contract.ts index 6d080ac5..80037f2c 100644 --- a/frontend/src/lib/annual-package-contract.ts +++ b/frontend/src/lib/annual-package-contract.ts @@ -1,7 +1,7 @@ import { z } from "zod"; // Annual data package v1 (POST /api/annual with mode "package", or -// /api/annual/package). Producer: scripts/annual_package.py. Design note: +// /api/annual/package). Producer: scripts/annual_service/package.py. Design note: // docs/engine/annual-package-v1.md. Only the fields the report and the entry overlay // read are typed; producer data stays passthrough so evidence is not dropped. diff --git a/frontend/src/lib/annual-report-sections.ts b/frontend/src/lib/annual-report-sections.ts index c3f8022e..d334bb6c 100644 --- a/frontend/src/lib/annual-report-sections.ts +++ b/frontend/src/lib/annual-report-sections.ts @@ -1,7 +1,7 @@ /** * Annual report plan, section evidence, prompts and the reader gate (#153). * - * The facts come from POST /api/annual/report_facts (scripts/annual_report_facts.py): + * The facts come from POST /api/annual/report_facts (scripts/annual_service/report_facts.py): * the server-side cards are the only conclusions. Each section's evidence is * a plain Chinese projection of those cards; the model words them and cannot * add a lean, a date or a stronger certainty. Times, window ranges and their diff --git a/frontend/tests/fixtures/annual-package-v1.json b/frontend/tests/fixtures/annual-package-v1.json index 9c26a6cd..117f0664 100644 --- a/frontend/tests/fixtures/annual-package-v1.json +++ b/frontend/tests/fixtures/annual-package-v1.json @@ -82,7 +82,7 @@ "basis": "return_location_zone" }, "status": "computed", - "producer": "annual_package._report_range", + "producer": "annual_service.package._report_range", "start": { "utc": "2025-12-31T16:00:00Z", "local": "2026-01-01T00:00:00+08:00", @@ -3871,7 +3871,7 @@ { "path": "report_range", "status": "computed", - "producer": "annual_package._report_range", + "producer": "annual_service.package._report_range", "reason": null }, { diff --git a/frontend/tests/fixtures/annual-report-facts.json b/frontend/tests/fixtures/annual-report-facts.json index 58575bdc..fb9a1d63 100644 --- a/frontend/tests/fixtures/annual-report-facts.json +++ b/frontend/tests/fixtures/annual-report-facts.json @@ -5,7 +5,7 @@ "facts": { "schema": "jyotish.annual_report_facts.v1", "annual_package_version": 1, - "package_sha256": "8549ec5127839104aa1e55e56a441598f95f407e4747953c542bf9e735b72268", + "package_sha256": "0565f483b6f2288485722136325726ef3a346485d80218d2038f6ffe06a65753", "parameters": { "birth": { "local_datetime": "1990-06-15T12:00:00", @@ -50,7 +50,7 @@ "basis": "return_location_zone" }, "status": "computed", - "producer": "annual_package._report_range", + "producer": "annual_service.package._report_range", "start": { "utc": "2026-06-15T09:29:26Z", "local": "2026-06-15T17:29:26+08:00", @@ -651,7 +651,7 @@ }, "cards": { "status": "computed", - "producer": "annual_report_facts.build_cards", + "producer": "annual_service.report_facts.build_cards", "config": { "narayana_skeleton_profile": "legacy", "narayana_rath_counts_as_support": false, diff --git a/scripts/annual_service/__init__.py b/scripts/annual_service/__init__.py new file mode 100644 index 00000000..2583255c --- /dev/null +++ b/scripts/annual_service/__init__.py @@ -0,0 +1,8 @@ +"""Annual data package and annual report facts (Gitea #152 / #153 / #154). + +- ``package``: the versioned annual data package (``build_annual_package``). +- ``report_facts``: the annual report's claim cards (``build_annual_report_facts``). +- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for + ``/api/annual`` (mode package), ``/api/annual/package`` and + ``/api/annual/report_facts``. +""" diff --git a/scripts/annual_service/api.py b/scripts/annual_service/api.py new file mode 100644 index 00000000..9e8e451c --- /dev/null +++ b/scripts/annual_service/api.py @@ -0,0 +1,151 @@ +"""HTTP-facing entry points for the annual package and report facts. + +``scripts/jyotish_api_server.py`` registers these thinly (like +``chart_view_points.api``): it loads this module, calls a function with the +JSON body and maps ``AnnualServiceInputError`` to HTTP 400. Request parsing +lives here, not on the handler class. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +try: # scripts/ on sys.path (API server, tests) + from annual_service import package as _package + from annual_service import report_facts as _facts +except ImportError: # pragma: no cover - repository-root import + from scripts.annual_service import package as _package + from scripts.annual_service import report_facts as _facts + + +class AnnualServiceInputError(ValueError): + """Bad request input; the API maps it to HTTP 400.""" + + +def _number(body: dict[str, Any], key: str, low: float, high: float, *, default: float | None = None, + aliases: tuple[str, ...] = ()) -> float: + value = body.get(key) + for alias in aliases: + if value is None: + value = body.get(alias) + if value is None or value == "": + if default is None: + raise AnnualServiceInputError(f"{key} is required") + return float(default) + if isinstance(value, bool): + raise AnnualServiceInputError(f"{key} must be a number") + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise AnnualServiceInputError(f"{key} must be a number") from exc + if not low <= number <= high: + raise AnnualServiceInputError(f"{key} must be between {low:g} and {high:g}") + return number + + +def _ayanamsa(body: dict[str, Any]) -> str: + try: + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name + except ImportError: # pragma: no cover + from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name + raw = body.get("ayanamsa") or body.get("ayanamsa_name") or body.get("ayanamsa_policy") + if raw in (None, ""): + return DEFAULT_AYANAMSA_NAME + try: + return normalize_ayanamsa_name(raw) + except UnsupportedAyanamsaError as exc: + raise AnnualServiceInputError(str(exc)) from exc + + +def _node_mode(body: dict[str, Any]) -> str: + raw = body.get("node_mode", body.get("nodeMode")) + if raw in (None, ""): + return "mean" + if not isinstance(raw, str) or raw.strip().lower() not in {"mean", "true"}: + raise AnnualServiceInputError("node_mode must be mean or true") + return raw.strip().lower() + + +def _timezone_id(body: dict[str, Any]) -> str | None: + for key in ("timezone_id", "timezone"): + value = body.get(key) + if isinstance(value, str) and "/" in value: + try: + ZoneInfo(value.strip()) + except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request + raise AnnualServiceInputError(f"{key} is not a known IANA timezone") from exc + return value.strip() + return None + + +def _birth(body: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict): + raise AnnualServiceInputError("JSON body must be an object") + year = int(_number(body, "year", 1800, 2400)) + month = int(_number(body, "month", 1, 12)) + day = int(_number(body, "day", 1, 31)) + hour = int(_number(body, "hour", 0, 23)) + minute = int(_number(body, "minute", 0, 59)) + second = int(_number(body, "second", 0, 59, default=0, aliases=("birth_second",))) + try: + local = datetime(year, month, day, hour, minute, second) + except ValueError as exc: + raise AnnualServiceInputError("Invalid birth date") from exc + lat = _number(body, "lat", -90, 90, aliases=("birth_lat",)) + lon = _number(body, "lon", -180, 180, aliases=("birth_lon",)) + if body.get("tz") not in (None, ""): + tz = _number(body, "tz", -14, 14) + else: + try: + from domain_calculation_service import infer_timezone_offset + except ImportError: # pragma: no cover + from scripts.domain_calculation_service import infer_timezone_offset + try: + tz = float(infer_timezone_offset(lat=lat, lon=lon, local_datetime=local)) + except ValueError as exc: + raise AnnualServiceInputError(str(exc)) from exc + return {"year": year, "month": month, "day": day, "hour": hour, "minute": minute, "second": second, + "lat": lat, "lon": lon, "tz": tz, "timezone_id": _timezone_id(body)} + + +def _request(body: dict[str, Any]) -> dict[str, Any]: + birth = _birth(body) + try: + return _package.normalize_request( + birth=birth, + ayanamsa=_ayanamsa(body), + node_mode=_node_mode(body), + report_range=body.get("report_range"), + observation_date=body.get("observation_date") or body.get("today"), + return_location=body.get("return_location"), + ) + except _package.AnnualPackageInputError as exc: + raise AnnualServiceInputError(str(exc)) from exc + + +def compute_annual_package(body: dict[str, Any]) -> dict[str, Any]: + """`/api/annual` with mode=package, and `/api/annual/package`.""" + request = _request(body) + detail = body.get("detail") or "full" + if detail not in ("full", "slim"): + raise AnnualServiceInputError("detail must be full or slim") + package = _package.build_annual_package(request, detail=detail) + return { + "success": True, + "endpoint": "annual", + "mode": "package", + "annual_package_version": package["annual_package_version"], + "package": package, + } + + +def compute_annual_report_facts(body: dict[str, Any]) -> dict[str, Any]: + """`/api/annual/report_facts`: the annual report writer's only input (#153).""" + request = _request(body) + confirmed = body.get("birth_time_confirmed") + if not isinstance(confirmed, bool): + raise AnnualServiceInputError("birth_time_confirmed must be true or false") + facts = _facts.build_annual_report_facts(request, birth_time_confirmed=confirmed) + return {"success": True, "endpoint": "annual_report_facts", "facts": facts} diff --git a/scripts/annual_package.py b/scripts/annual_service/package.py similarity index 99% rename from scripts/annual_package.py rename to scripts/annual_service/package.py index 4878c597..1318f59b 100644 --- a/scripts/annual_package.py +++ b/scripts/annual_service/package.py @@ -617,7 +617,7 @@ def _report_range(ctx: _Context) -> tuple[dict[str, Any], list[int]]: lookback = _local_midnight(_add_months(obs, -LOOKBACK_MONTHS), zone) observed = _local_midnight(obs, zone) out.update(block( - "computed" if covered is None else "blocked", "annual_package._report_range", + "computed" if covered is None else "blocked", "annual_service.package._report_range", reason=covered, start=instant(start, zone), end=instant(end, zone), diff --git a/scripts/annual_report_facts.py b/scripts/annual_service/report_facts.py similarity index 98% rename from scripts/annual_report_facts.py rename to scripts/annual_service/report_facts.py index 2e53d302..74dda2b4 100644 --- a/scripts/annual_report_facts.py +++ b/scripts/annual_service/report_facts.py @@ -29,10 +29,10 @@ import hashlib import json from typing import Any -try: - import annual_package as ap -except ImportError: # pragma: no cover - from scripts import annual_package as ap +try: # scripts/ on sys.path (API server, tests) + from annual_service import package as ap +except ImportError: # pragma: no cover - repository-root import + from scripts.annual_service import package as ap SCHEMA = "jyotish.annual_report_facts.v1" @@ -262,7 +262,7 @@ def _annual_house(chart_block: dict, planet: str | None) -> int | None: def build_cards(package: dict, natal: dict) -> dict[str, Any]: zone_jd = _jd(package["report_range"].get("start")) if package["report_range"].get("status") != "computed" or zone_jd is None: - return ap.block("blocked", "annual_report_facts.build_cards", reason="report_range_blocked") + return ap.block("blocked", "annual_service.report_facts.build_cards", reason="report_range_blocked") range_lo = _jd(package["report_range"]["start"]) range_hi = _jd(package["report_range"]["end"]) obs_jd = _jd(package["report_range"]["observation"]["instant"]) @@ -357,7 +357,7 @@ def build_cards(package: dict, natal: dict) -> dict[str, Any]: domains = _domain_cards(natal, charts, phases, windows, events) conflicts = _conflicts(package, phases, natal) return ap.block( - "computed", "annual_report_facts.build_cards", + "computed", "annual_service.report_facts.build_cards", config={key: list(value) if isinstance(value, tuple) else value for key, value in CONFIG.items()}, house_domains={key: list(value) for key, value in HOUSE_DOMAINS.items()}, phases=phases, turning_points=[{k: v for k, v in e.items() if k != "jd"} for e in events], @@ -635,10 +635,10 @@ def build_annual_report_facts(request: dict[str, Any], *, birth_time_confirmed: ctx = ap._Context(request) natal = ap._guard("jyotish_engine._compute_chart_from_args", lambda: _natal_base(ctx, birth_time_confirmed)) if natal.get("status") != "computed": - cards = ap.block("blocked", "annual_report_facts.build_cards", reason="natal_base_blocked") + cards = ap.block("blocked", "annual_service.report_facts.build_cards", reason="natal_base_blocked") audit: list[dict[str, str]] = [] else: - cards = ap._guard("annual_report_facts.build_cards", lambda: build_cards(package, natal)) + cards = ap._guard("annual_service.report_facts.build_cards", lambda: build_cards(package, natal)) audit = _technique_audit(package, natal) public_natal = {key: value for key, value in natal.items() if not key.startswith("_")} digest = hashlib.sha256(json.dumps(package, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest() diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 769f9ffd..d169517e 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -2554,6 +2554,15 @@ def _pooled(handler, method, body): return heavy_pool.run(method, body, inline) +def _annual_service(function_name, body): + """Thin registration of scripts/annual_service/api.py (#152 / #153).""" + annual_api = _load_local_module('annual_service.api') + try: + return getattr(annual_api, function_name)(body) + except annual_api.AnnualServiceInputError as exc: + raise BadRequest(str(exc)) from exc + + def _load_local_module(module_name): cached = _LOCAL_MODULE_CACHE.get(module_name) if cached: @@ -7734,7 +7743,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): def _compute_annual(self, body): if isinstance(body, dict) and body.get('mode') == 'package': - return self._compute_annual_package(body) + return _annual_service('compute_annual_package', body) target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400) birth_dt = self._parse_birth_datetime(body) lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) @@ -7766,79 +7775,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): ) return {'success': True, 'endpoint': 'annual', 'report': report} - def _compute_annual_package(self, body): - """Annual data package v1 (#152 ruling 2026-11-01): `mode: "package"` or /api/annual/package. - - The default `/api/annual` response (basic solar-return report) keeps its shape. - """ - birth_dt, lat, lon, tz, timezone_id = self._annual_birth(body) - package_mod, request = self._annual_request(body, birth_dt, lat, lon, tz, timezone_id) - detail = body.get('detail') or 'full' - if detail not in ('full', 'slim'): - raise BadRequest('detail must be full or slim') - package = package_mod.build_annual_package(request, detail=detail) - return { - 'success': True, - 'endpoint': 'annual', - 'mode': 'package', - 'annual_package_version': package['annual_package_version'], - 'package': package, - } - - def _annual_request(self, body, birth_dt, lat, lon, tz, timezone_id): - package_mod = _load_local_module('annual_package') - try: - request = package_mod.normalize_request( - birth={ - 'year': birth_dt.year, 'month': birth_dt.month, 'day': birth_dt.day, - 'hour': birth_dt.hour, 'minute': birth_dt.minute, 'second': birth_dt.second, - 'lat': lat, 'lon': lon, 'tz': tz, 'timezone_id': timezone_id, - }, - ayanamsa=_request_ayanamsa(body), - node_mode=_request_node_mode(body), - report_range=body.get('report_range'), - observation_date=body.get('observation_date') or body.get('today'), - return_location=body.get('return_location'), - ) - except package_mod.AnnualPackageInputError as exc: - raise BadRequest(str(exc)) from exc - return package_mod, request - - def _annual_birth(self, body): - if not isinstance(body, dict): - raise BadRequest('JSON body must be an object') - birth_dt = self._parse_birth_datetime(body) - lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) - lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) - tz = self._parse_timezone( - body, lat, lon, birth_dt.year, birth_dt.month, birth_dt.day, - birth_dt.hour, birth_dt.minute, birth_dt.second, - ) - timezone_id = None - for key in ('timezone_id', 'timezone'): - value = body.get(key) - if isinstance(value, str) and '/' in value: - try: - from zoneinfo import ZoneInfo - ZoneInfo(value.strip()) - except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request - raise BadRequest(f'{key} is not a known IANA timezone') from exc - timezone_id = value.strip() - break - return birth_dt, lat, lon, tz, timezone_id - - def _compute_annual_report_facts(self, body): - """Annual report facts and claim cards (#153); the report writer's only input.""" - birth_dt, lat, lon, tz, timezone_id = self._annual_birth(body) - _package_mod, request = self._annual_request(body, birth_dt, lat, lon, tz, timezone_id) - confirmed = body.get('birth_time_confirmed') - if not isinstance(confirmed, bool): - raise BadRequest('birth_time_confirmed must be true or false') - facts = _load_local_module('annual_report_facts').build_annual_report_facts( - request, birth_time_confirmed=confirmed, - ) - return {'success': True, 'endpoint': 'annual_report_facts', 'facts': facts} - def _compute_tajika(self, body): result = self._compute_annual(body) return { @@ -10721,11 +10657,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService): result = self._compute_annual(body) self._json(result) elif path == '/api/annual/package': - result = self._compute_annual_package(body) - self._json(result) + self._json(_annual_service('compute_annual_package', body)) elif path == '/api/annual/report_facts': - result = self._compute_annual_report_facts(body) - self._json(result) + self._json(_annual_service('compute_annual_report_facts', body)) elif path == '/api/tajika': result = self._compute_tajika(body) self._json(result) diff --git a/tests/test_annual_package.py b/tests/test_annual_package.py index 27f12e40..4024d5c1 100644 --- a/tests/test_annual_package.py +++ b/tests/test_annual_package.py @@ -11,6 +11,7 @@ import json import os import sys from datetime import UTC, datetime, timedelta +from pathlib import Path import pytest @@ -18,8 +19,10 @@ SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") if SCRIPTS not in sys.path: sys.path.insert(0, SCRIPTS) -import annual_package # noqa: E402 -from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 +from annual_service import api as annual_api # noqa: E402 +from annual_service import package as annual_package # noqa: E402 + +BadRequest = annual_api.AnnualServiceInputError VOCAB = {"computed", "blocked", "not_applicable"} YEAR_CHART_BLOCKS = { @@ -28,8 +31,9 @@ YEAR_CHART_BLOCKS = { } -def _handler() -> JyotishAPIHandler: - return JyotishAPIHandler.__new__(JyotishAPIHandler) +def _compute(body: dict) -> dict: + """POST /api/annual mode=package and /api/annual/package both call this function.""" + return annual_api.compute_annual_package(body) def _body(**overrides): @@ -47,7 +51,7 @@ def _body(**overrides): @functools.cache def _cached(key: str) -> dict: - return _handler()._compute_annual(json.loads(key)) + return _compute(json.loads(key)) def _package(**overrides) -> dict: @@ -192,7 +196,7 @@ def test_return_location_keeps_the_field_and_offers_birth_place_only() -> None: assert location["mode"] == "birth_place" assert location["options_v1"] == ["birth_place"] with pytest.raises(BadRequest): - _handler()._compute_annual(_body(return_location={"mode": "current_location"})) + _compute(_body(return_location={"mode": "current_location"})) # ── year chart validity ──────────────────────────────────────────────────── @@ -268,7 +272,7 @@ def test_bad_range_inputs_are_bad_requests() -> None: for bad in ({"kind": "lunar_year"}, {"kind": "calendar_year", "year": "abc"}, {"kind": "calendar_year", "start_date": "2026-01-01"}, "2026"): with pytest.raises(BadRequest): - _handler()._compute_annual(_body(report_range=bad)) + _compute(_body(report_range=bad)) # ── each period system keeps its own year ────────────────────────────────── @@ -352,7 +356,7 @@ def test_slow_transits_events_are_exact_and_inside_the_range() -> None: # ── blocked blocks do not fail the response ──────────────────────────────── def _fresh(**overrides) -> dict: - return _handler()._compute_annual(_body(**overrides)) + return _compute(_body(**overrides)) def test_blocked_patyayini_and_rath_do_not_fail_the_response(monkeypatch) -> None: @@ -396,17 +400,16 @@ def test_blocked_slow_transits_do_not_fail_the_response(monkeypatch) -> None: assert result["package"]["year_charts"][0]["status"] == "computed" -# ── old /api/annual response unchanged ───────────────────────────────────── +# ── thin registration; the default /api/annual shape is covered by test_api_server_security ── -def test_default_annual_response_keeps_its_shape() -> None: - body = _body() - body.pop("mode") - result = _handler()._compute_annual({**body, "target_year": 2026}) - assert result["success"] is True - assert result["endpoint"] == "annual" - assert "package" not in result - assert result["report"]["target_year"] == 2026 - assert "muntha" in result["report"] +def test_route_is_registered_thinly() -> None: + source = (Path(SCRIPTS) / "jyotish_api_server.py").read_text(encoding="utf-8") + assert "elif path == '/api/annual/package':" in source + assert "elif path == '/api/annual/report_facts':" in source + assert "_load_local_module('annual_service.api')" in source + assert "_annual_service('compute_annual_package', body)" in source + assert "def _compute_annual_package" not in source + assert "def _compute_annual_report_facts" not in source def test_solar_return_node_mode_default_stays_mean() -> None: diff --git a/tests/test_annual_report_facts.py b/tests/test_annual_report_facts.py index c03c2f9b..8be8bdd8 100644 --- a/tests/test_annual_report_facts.py +++ b/tests/test_annual_report_facts.py @@ -13,12 +13,10 @@ SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") if SCRIPTS not in sys.path: sys.path.insert(0, SCRIPTS) -import annual_report_facts # noqa: E402 -from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 +from annual_service import api as annual_api # noqa: E402 +from annual_service import report_facts as annual_report_facts # noqa: E402 - -def _handler() -> JyotishAPIHandler: - return JyotishAPIHandler.__new__(JyotishAPIHandler) +BadRequest = annual_api.AnnualServiceInputError def _body(**overrides): @@ -35,7 +33,7 @@ def _body(**overrides): @functools.cache def _cached(key: str) -> dict: - return _handler()._compute_annual_report_facts(json.loads(key))["facts"] + return annual_api.compute_annual_report_facts(json.loads(key))["facts"] def _facts(**overrides) -> dict: @@ -141,13 +139,13 @@ def test_birth_time_flag_is_required() -> None: body = _body() body.pop("birth_time_confirmed") with pytest.raises(BadRequest): - _handler()._compute_annual_report_facts(body) + annual_api.compute_annual_report_facts(body) def test_slim_package_is_small_and_keeps_validity() -> None: body = {key: value for key, value in _body(report_range={"kind": "calendar_year", "year": 2026}).items() if key != "birth_time_confirmed"} - result = _handler()._compute_annual({**body, "mode": "package", "detail": "slim"}) + result = annual_api.compute_annual_package({**body, "detail": "slim"}) package = result["package"] assert package["detail"] == "slim" assert set(package) == {"schema", "annual_package_version", "detail", "parameters", "report_range", @@ -157,4 +155,4 @@ def test_slim_package_is_small_and_keeps_validity() -> None: assert package["report_range"]["crosses_birthday"] is True assert len(json.dumps(package)) < 12_000 with pytest.raises(BadRequest): - _handler()._compute_annual({**body, "mode": "package", "detail": "tiny"}) + annual_api.compute_annual_package({**body, "detail": "tiny"})