From eb5e9adcf93c77801a480dce270aba63461fe143 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:29:30 +0800 Subject: [PATCH 1/2] fix(shadbala): move /api/shadbala into shadbala_service; birth data no longer 500 (BUG-1463) _compute_shadbala called swe.julday with no swe in scope, so any request with year/month/day/lat/lon raised NameError. The endpoint now lives in scripts/shadbala_service/api.py, which imports Swiss Ephemeris itself; the handler only forwards. _shadbala_rule_variants and _compute_shadbala_advanced_layer moved with it (handler methods 217 -> 215, baseline lowered). New real-HTTP regression test with ruff F821 on the server, added to the quick gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- docs/BUG_HISTORY.md | 18 +-- scripts/jyotish_api_server.py | 100 +-------------- scripts/run_quality_gate.py | 2 + scripts/shadbala_service/__init__.py | 5 + scripts/shadbala_service/api.py | 154 +++++++++++++++++++++++ tests/test_api_server_growth_contract.py | 6 +- tests/test_shadbala_service_api.py | 151 ++++++++++++++++++++++ 7 files changed, 332 insertions(+), 104 deletions(-) create mode 100644 scripts/shadbala_service/__init__.py create mode 100644 scripts/shadbala_service/api.py create mode 100644 tests/test_shadbala_service_api.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ac9a9307..a96ad30d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19935,19 +19935,19 @@ ## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500 -- 状态:investigating +- 状态:resolved - 首次发现:2026-10-27(做 issue #114 时调接口发现) -- 最近更新:2026-10-27 -- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。 +- 最近更新:2026-11-01 +- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。进程内的两处调用(咨询的 `_attach_local_consultation_layers`、专题的 `_derive_thematic_evidence`)只在图里还没有 `modules.shadbala` 时才走这里,以前会被各自的 `except` 记成 `unavailable` / `warning`,不报 500。 - 用户现象:接口返回 `ERR_INTERNAL`。 - 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。 -- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。 -- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。 -- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。 -- 防复发:待修复单补接口测试。 -- 相关记录:issue #114。 +- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。整份文件只有这一处(`ruff --select F821` 实测只报 7564 行)。只带星体经度的旧测试走不到这条分支,所以一直没拦住。 +- 修复:新建 `scripts/shadbala_service/api.py`,模块顶部 `import swisseph as swe`,把 `_compute_shadbala` 的出生时刻分支、`_shadbala_rule_variants`、`_compute_shadbala_advanced_layer` 原样移过去。主文件的 `_compute_shadbala` 只转发,并把不支持的 ayanamsa 转成 400。主文件四空格方法 217 → 215,`tests/test_api_server_growth_contract.py` 基线同步降到 215。 +- 验证:`tests/test_shadbala_service_api.py` 在本机端口起真实 `JyotishAPIHandler`,POST 金盘出生资料(1990-06-15 12:00 北京,raman,虚构)→ 200;`method_variants` 为精确 Kala / Chesta;七星 Dig Bala 等于同一时刻 `build_shadbala_context` 的宫中点算出的值。只带星体时仍等于 `chart-insight-golden.json` 的 `chart_modules_shadbala`。坏 ayanamsa、缺日月 → 400。把主文件换回修复前版本,同一测试得到 500 和 `NameError: name 'swe' is not defined`。 +- 防复发:上面的接口测试已加进 `scripts/run_quality_gate.py` 的 quick 清单;同一文件里还对 `jyotish_api_server.py`、`offline_compute_mixins.py`、`scripts/shadbala_service/` 跑 `ruff --select F821`,再有用了没导入的名字就失败。 +- 相关记录:issue #114;Gitea #119 第 46 项。 - 复发自:无 -- 修复版本:分支 `codex/rectification-d60-two-way-20261028`。 +- 修复版本:分支 `codex/outage-shadbala-119`。 ## BUG-1464 | 存过别人的账户进首页即「页面出错了」 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index d169517e..6816fed4 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7545,41 +7545,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): } def _compute_shadbala(self, body): - planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body) - if not {'Sun', 'Moon'} <= set(planet_lons): - raise BadRequest('planets must include Sun and Moon longitude data') - birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) - birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) - birth_second = self._get_birth_second(body) - birth_hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second) - shadbala_module = _load_local_module('shadbala') - context = None - if all(key in body for key in ('year', 'month', 'day', 'lat', 'lon')): - year = self._get_int(body, 'year', 1990, 1800, 2400) - month = self._get_int(body, 'month', 6, 1, 12) - day = self._get_int(body, 'day', 15, 1, 31) - lat = self._get_float(body, 'lat', 0, -90, 90) - lon = self._get_float(body, 'lon', 0, -180, 180) - tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) - jd = swe.julday(year, month, day, birth_hour_decimal - tz) - context = shadbala_module.build_shadbala_context(jd, lat, lon, _request_ayanamsa(body), tz) - result = shadbala_module.calc_shadbala( - planets, - SIGNS[asc_sign_idx], - birth_hour_decimal, - planet_lons['Sun'], - planet_lons['Moon'], - birth_minute, - context=context, - ) - advanced = self._compute_shadbala_advanced_layer(body, planets, result) - return { - 'success': True, - 'endpoint': 'shadbala', - 'rule_variants': self._shadbala_rule_variants(bool(advanced)), - 'advanced_layer': advanced, - 'result': result, - } + """Thin registration of scripts/shadbala_service/api.py (BUG-1463).""" + shadbala_api = _load_local_module('shadbala_service.api') + try: + return shadbala_api.compute_shadbala(self, body) + except shadbala_api.ShadbalaInputError as exc: + raise BadRequest(str(exc)) from exc def _compute_yogas_api(self, body): planets, _, asc_sign_idx = self._normalized_planets_from_body(body) @@ -7624,16 +7595,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): 'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。', } - def _shadbala_rule_variants(self, advanced_enabled): - return { - 'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'], - 'available': [ - {'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'}, - {'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'}, - ], - 'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。', - } - def _yoga_rule_variants(self, curse_enabled): return { 'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []), @@ -7673,55 +7634,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): legacy[planet] = row return legacy - def _compute_shadbala_advanced_layer(self, body, planets, base_result): - try: - advanced_mod = _load_local_module('shadbala_advanced') - birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) - birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) - birth_second = self._get_birth_second(body) - year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) - month = self._get_int(body, 'month', 1, 1, 12) - day = self._get_int(body, 'day', 1, 1, 31) - 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, year, month, day, birth_hour, birth_minute, birth_second) - hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second) - solar_lon = planets.get('Sun', {}).get('lon', 0) - base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {} - comparison_planets = {} - kala_additions = {} - drik_sputa = {} - for planet, pdata in planets.items(): - if planet not in base_planets: - continue - comparison_planets[planet] = { - **pdata, - 'degree': pdata.get('lon', pdata.get('degree', 0)), - 'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0), - } - for planet in comparison_planets: - kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala( - planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz - ), 2) - drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2) - yuddha = advanced_mod.calc_yuddha_bala(comparison_planets) - active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0} - top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3] - return { - 'source': 'scripts/shadbala_advanced.py', - 'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层', - 'kala_vmdh': kala_additions, - 'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala], - 'yuddha_bala': yuddha, - 'active_yuddha': active_yuddha, - 'sputa_drik_bala': drik_sputa, - 'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。', - } - except Exception as exc: - import logging - logging.warning(f"[api_server] shadbala advanced layer failed: {exc}") - return {} - def _compute_aspects(self, body): planets, planet_lons = self._planet_lons_from_body(body) if len(planet_lons) < 2: diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 61c3630b..dc4d7e3c 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -137,6 +137,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_runtime_jhora_import_scan.py", # English edition han leak, 12-chart slice (issue #89 / BUG-1413). The 200-chart file stays off this list. "tests/test_report_english_sweep_smoke.py", + # /api/shadbala with the birth moment over real HTTP, plus ruff F821 on the API server (BUG-1463). + "tests/test_shadbala_service_api.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/scripts/shadbala_service/__init__.py b/scripts/shadbala_service/__init__.py new file mode 100644 index 00000000..209daf0e --- /dev/null +++ b/scripts/shadbala_service/__init__.py @@ -0,0 +1,5 @@ +"""Shadbala endpoint service (BUG-1463). + +- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for + ``/api/shadbala`` and the in-process callers of ``_compute_shadbala``. +""" diff --git a/scripts/shadbala_service/api.py b/scripts/shadbala_service/api.py new file mode 100644 index 00000000..df196627 --- /dev/null +++ b/scripts/shadbala_service/api.py @@ -0,0 +1,154 @@ +"""`/api/shadbala`: the six-fold strength core plus the advanced evidence layer. + +Moved out of ``JyotishAPIHandler`` for BUG-1463. The handler method used +``swe.julday`` with no ``swe`` in scope, so every request carrying +year/month/day/lat/lon failed with ``NameError`` (HTTP 500). This module +imports Swiss Ephemeris itself; the handler only forwards the body. + +The handler is passed in for its existing body parsers (``_get_float``, +``_parse_timezone`` ...). They raise the server's ``BadRequest`` and that +propagates unchanged. This module never imports the HTTP server. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import swisseph as swe + +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name + +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +BIRTH_KEYS = ("year", "month", "day", "lat", "lon") + +logger = logging.getLogger(__name__) + + +class ShadbalaInputError(ValueError): + """The request names an ayanamsa the engine does not support.""" + + +def _ayanamsa(body: dict[str, Any]) -> str: + """Same reading as the server's ``_request_ayanamsa(body)``.""" + 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 ShadbalaInputError(str(exc)) from exc + + +def birth_context(handler: Any, body: dict[str, Any], birth_hour: float, birth_minute: float, birth_second: float) -> dict[str, Any] | None: + """Precise Swiss Ephemeris context when the body carries the full birth moment.""" + if not all(key in body for key in BIRTH_KEYS): + return None + import shadbala + + year = handler._get_int(body, "year", 1990, 1800, 2400) + month = handler._get_int(body, "month", 6, 1, 12) + day = handler._get_int(body, "day", 15, 1, 31) + lat = handler._get_float(body, "lat", 0, -90, 90) + lon = handler._get_float(body, "lon", 0, -180, 180) + tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) + hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + jd_ut = swe.julday(year, month, day, hour_decimal - tz) + return shadbala.build_shadbala_context(jd_ut, lat, lon, _ayanamsa(body), tz) + + +def rule_variants(advanced_enabled: bool) -> dict[str, Any]: + return { + "selected": ["core_sixfold", "advanced_evidence"] if advanced_enabled else ["core_sixfold"], + "available": [ + {"key": "core_sixfold", "label": "六重力量主算法", "status": "active", "source": "scripts/shadbala.py"}, + {"key": "advanced_evidence", "label": "Kala/Yuddha/Sputa 增强证据", "status": "active" if advanced_enabled else "unavailable", "source": "scripts/shadbala_advanced.py"}, + ], + "boundary": "增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。", + } + + +def advanced_layer(handler: Any, body: dict[str, Any], planets: dict[str, Any], base_result: Any) -> dict[str, Any]: + """Kala sub-items, Yuddha and Sputa Drishti evidence; ``{}`` when it cannot be built.""" + from datetime import datetime + + try: + import shadbala_advanced as advanced_mod + + birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23) + birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59) + birth_second = handler._get_birth_second(body) + year = handler._get_int(body, "year", datetime.now().year, 1800, 2400) + month = handler._get_int(body, "month", 1, 1, 12) + day = handler._get_int(body, "day", 1, 1, 31) + lat = handler._get_float(body, "lat", body.get("birth_lat", 0), -90, 90) + lon = handler._get_float(body, "lon", body.get("birth_lon", 0), -180, 180) + tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) + hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + solar_lon = planets.get("Sun", {}).get("lon", 0) + base_planets = base_result.get("planets", {}) if isinstance(base_result, dict) else {} + comparison_planets = {} + kala_additions = {} + drik_sputa = {} + for planet, pdata in planets.items(): + if planet not in base_planets: + continue + comparison_planets[planet] = { + **pdata, + "degree": pdata.get("lon", pdata.get("degree", 0)), + "shadbala_total": base_planets.get(planet, {}).get("total_virupas", 0), + } + for planet in comparison_planets: + kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala( + planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz + ), 2) + drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2) + yuddha = advanced_mod.calc_yuddha_bala(comparison_planets) + active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0} + top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3] + return { + "source": "scripts/shadbala_advanced.py", + "method": "Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层", + "kala_vmdh": kala_additions, + "top_kala_support": [{"planet": planet, "virupas": value} for planet, value in top_kala], + "yuddha_bala": yuddha, + "active_yuddha": active_yuddha, + "sputa_drik_bala": drik_sputa, + "next_action": "若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。", + } + except Exception as exc: + logger.warning("[api_server] shadbala advanced layer failed: %s", exc) + return {} + + +def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]: + import shadbala + + planets, planet_lons, asc_sign_idx = handler._normalized_planets_from_body(body) + if not {"Sun", "Moon"} <= set(planet_lons): + raise ShadbalaInputError("planets must include Sun and Moon longitude data") + birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23) + birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59) + birth_second = handler._get_birth_second(body) + birth_hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + context = birth_context(handler, body, birth_hour, birth_minute, birth_second) + result = shadbala.calc_shadbala( + planets, + SIGNS[asc_sign_idx], + birth_hour_decimal, + planet_lons["Sun"], + planet_lons["Moon"], + birth_minute, + context=context, + ) + advanced = advanced_layer(handler, body, planets, result) + return { + "success": True, + "endpoint": "shadbala", + "rule_variants": rule_variants(bool(advanced)), + "advanced_layer": advanced, + "result": result, + } diff --git a/tests/test_api_server_growth_contract.py b/tests/test_api_server_growth_contract.py index 7b3cccf9..d8007a82 100644 --- a/tests/test_api_server_growth_contract.py +++ b/tests/test_api_server_growth_contract.py @@ -38,7 +38,11 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300 # 新值:217 # 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法 # 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。 -JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217 +# 原值:217 +# 新值:215 +# 原因:BUG-1463 把 /api/shadbala 移到 scripts/shadbala_service/api.py, +# 删除 _shadbala_rule_variants 与 _compute_shadbala_advanced_layer,_compute_shadbala 只转发 +JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 215 # 原值:31 # 新值:5 diff --git a/tests/test_shadbala_service_api.py b/tests/test_shadbala_service_api.py new file mode 100644 index 00000000..5426c4ef --- /dev/null +++ b/tests/test_shadbala_service_api.py @@ -0,0 +1,151 @@ +"""BUG-1463: `/api/shadbala` with the birth moment returned 500 (`NameError: swe`). + +The endpoint now lives in scripts/shadbala_service/api.py and the handler only +forwards the body. These tests go through the real HTTP handler on a local +port, so the regression is caught at the same surface the caller saw. +Birth data is the fictional golden profile shared with the frontend tests +(1990-06-15 12:00 Beijing, raman). +""" + +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import threading +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +for path in (ROOT, SCRIPTS): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from scripts.api_heavy_compute_gate import reset_heavy_compute_gate # noqa: E402 +from scripts.shadbala import build_shadbala_context, calc_dig_bala_precise # noqa: E402 + +GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8")) +PLANETS = GOLDEN["chart"]["planets"] +ASCENDANT = GOLDEN["chart"]["ascendant"] +BIRTH = { + "year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman", +} +SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") + + +@pytest.fixture(scope="module") +def api_base(): + import os + + from scripts.jyotish_api_server import DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, JyotishAPIHandler + + previous = os.environ.get("JYOTISH_API_RATE_LIMIT_PER_MINUTE") + os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = "0" + reset_heavy_compute_gate() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + server = ThreadingHTTPServer(("127.0.0.1", port), JyotishAPIHandler) + server.daemon_threads = True + base = f"http://127.0.0.1:{port}" + server.allowed_origins = set(DEFAULT_ALLOWED_ORIGINS) | {base} + server.allowed_hosts = set(DEFAULT_ALLOWED_HOSTS) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield base + finally: + server.shutdown() + server.server_close() + reset_heavy_compute_gate() + if previous is None: + os.environ.pop("JYOTISH_API_RATE_LIMIT_PER_MINUTE", None) + else: + os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = previous + + +def _post(base: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + request = urllib.request.Request( + f"{base}/api/shadbala", + data=json.dumps(body).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json", "Origin": base, "Host": base.split("://", 1)[1]}, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read().decode("utf-8") or "{}") + + +def test_birth_data_request_returns_precise_shadbala(api_base) -> None: + status, payload = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT}) + + assert status == 200, payload + assert payload["success"] is True + result = payload["result"] + # The birth moment switches Kala and Chesta to the Swiss Ephemeris context. + assert result["method_variants"] == { + "kala": "bphs_local_solar_events_ahargana_declination", + "chesta": "bphs_bounded_surya_mean_motion_seeghrochcha", + } + assert sorted(result["ranking"]) == sorted(SEVEN) + for planet in SEVEN: + row = result["planets"][planet] + assert row["total_rupas"] > 0 + assert row["total_virupas"] == pytest.approx(row["total_rupas"] * 60, abs=0.05) + + # Dig Bala is read from the Placidus bhava midpoints of the same instant (UT = 12:00 - 8h). + context = build_shadbala_context(swe.julday(1990, 6, 15, 4.0), 39.9042, 116.4074, "raman", 8.0) + for planet in SEVEN: + expected = calc_dig_bala_precise(planet, float(PLANETS[planet]["lon"]), context["house_midpoints"]) + assert result["planets"][planet]["dig_bala"] == expected + + assert payload["rule_variants"]["selected"] == ["core_sixfold", "advanced_evidence"] + assert payload["advanced_layer"]["source"] == "scripts/shadbala_advanced.py" + assert set(payload["advanced_layer"]["kala_vmdh"]) == set(SEVEN) + + +def test_birth_data_changes_the_strength_versus_planets_only(api_base) -> None: + status, payload = _post(api_base, {"planets": PLANETS, "ascendant": ASCENDANT, "hour": 12, "minute": 0}) + assert status == 200, payload + legacy = payload["result"] + assert legacy["method_variants"]["kala"] == "legacy_approximation" + # Without the birth moment the answer is the chart page's `modules.shadbala` golden. + golden = GOLDEN["chart_modules_shadbala"]["planets"] + for planet in SEVEN: + assert legacy["planets"][planet]["total_rupas"] == pytest.approx(golden[planet]["total_rupas"], abs=1e-4) + + _, precise = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT}) + assert any( + precise["result"]["planets"][planet]["dig_bala"] != legacy["planets"][planet]["dig_bala"] + for planet in SEVEN + ) + + +def test_bad_inputs_are_400_not_500(api_base) -> None: + status, _ = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT, "ayanamsa": "not-an-ayanamsa"}) + assert status == 400 + status, _ = _post(api_base, {**BIRTH, "planets": {"Mars": PLANETS["Mars"]}, "ascendant": ASCENDANT}) + assert status == 400 + + +def test_shadbala_service_and_api_server_have_no_undefined_names() -> None: + """BUG-1463 was a module name used where it was never imported; ruff F821 sees that statically.""" + ruff = pytest.importorskip("ruff") + targets = ["scripts/jyotish_api_server.py", "scripts/offline_compute_mixins.py", "scripts/shadbala_service"] + completed = subprocess.run( + [ruff.find_ruff_bin(), "check", "--isolated", "--no-cache", "--select", "F821", *targets], + cwd=ROOT, capture_output=True, text=True, check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr From 8f7814846b6650ccc1d18d8da3abff22ab6b6c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:45:55 +0800 Subject: [PATCH 2/2] docs(ops): outage behaviour audit for VedAstro, location and model providers (#119 item 42) Code-and-stub audit instead of a live drill (no staging SSH). Per-dependency tables of what the user sees, charge/refund, logs/alerts and evidence, plus gaps ranked by risk. Four clear bugs recorded as investigating: BUG-1504 (VedAstro truncated read -> 500), BUG-1505 (report lease expiry keeps credits), BUG-1506 (profile edit wipes place on timezone failure), BUG-1507 (legacy China profiles silently +8). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- docs/BUG_HISTORY.md | 64 ++++++++ .../operations/outage-behaviour-2026-11-01.md | 148 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 docs/operations/outage-behaviour-2026-11-01.md diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a96ad30d..b7104882 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20570,3 +20570,67 @@ - 相关记录:BUG-149(门禁里 npm ci 没有安装期限)。 - 复发自:无 - 修复版本:staging 集成分支 `codex/chart-base-layer-label`。 + +## BUG-1504 | VedAstro 响应读到一半连接断开时,报告生成与不带 skip 的排盘返回 500 + +- 状态:investigating +- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计) +- 最近更新:2026-11-01 +- 影响面:同步调 VedAstro 官方快照的路径,包括个人报告生成(排盘主入口概览、生时门禁)和不带 `skip_vedastro_main_entry_overview` 的 `/api/chart`。星盘页、对话不受影响:前者总是跳过,后者在后台线程里调用。 +- 用户现象:报告以 `workflow_server_error` 失败(会退点);接口返回 `ERR_INTERNAL`。 +- 触发条件:VedAstro 已经返回响应头,但响应体读到一半连接被重置或截断(`http.client.IncompleteRead`、`ConnectionResetError`、`ssl.SSLError`)。 +- 根因(读码 + 本机 stub 确认 IncompleteRead):`scripts/vedastro_service_adapter.py` 的快照分发(约 2782–2808 行)和区间扫描(约 3010–3046 行)只接住 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 `JSONDecodeError`;`_attach_vedastro_main_entry_overview` 外面也没有 try,所以异常一直冒到请求总入口。 +- 修复:未修。本轮只审计,修法待产品决定是否排期(把这几类读错误归为 `network_error`)。 +- 验证:本机把 `urlopen` 换成读响应时抛 `IncompleteRead` 的 stub,调用排盘得到 500。脚本是临时的,没有入库;做法见 `docs/operations/outage-behaviour-2026-11-01.md` 第一节。 +- 防复发:待修复单补「读错误降级、不 500」的测试。 +- 相关记录:Gitea #119 第 42 项;`docs/operations/outage-behaviour-2026-11-01.md`。 +- 复发自:无 +- 修复版本:未修。 + +## BUG-1505 | 报告任务最后一次尝试租约过期时,报告标为失败但点数不退 + +- 状态:investigating +- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计) +- 最近更新:2026-11-01 +- 影响面:个人报告、合盘、年运等走 `personal_report_jobs` 的报告任务。 +- 用户现象:报告显示失败(`calculation_unavailable`),点数已经扣掉,没有退回。用户重新生成要再付一次,只有删除这份失败报告时,删除触发器才会退点。 +- 触发条件:任务已经是最后一次尝试(`max_attempts` 为 3),这时 worker 进程退出、重启或卡死,租约(600 秒)过期,下一轮由回收函数收尾。发版重启时最容易遇到。另一种情况:worker 正常走到失败分支,但 `billing.release` 返回 false 或抛错,返回值被忽略。生时校正的 `release()` 也一样(`frontend/src/lib/rectification-agentic/v9/agent-run-finish.ts:49`)。 +- 根因(读码 + 本机 stub 确认):`frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql` 的 `recover_expired_personal_report_jobs` 只把任务和报告改成 failed,不调 `release_usage`;worker 的 tick 也不退点。`frontend/src/lib/personal-report-worker-core.ts:240-243` 不检查 `release` 的返回值。 +- 修复:未修。本轮只审计,待产品决定排期。 +- 验证:本机 stub 调用回收,`failedCount=1`,没有任何退点调用;`release` 返回 false 时报告照样标失败。脚本是临时的,没有入库。 +- 防复发:待修复单补「租约过期终态失败必须退点」「退点失败要重试或告警」的测试。 +- 相关记录:BUG-1461(解读失败不让报告失败);`20260925040000_deleted_report_release.sql`;`docs/operations/outage-behaviour-2026-11-01.md`。 +- 复发自:无 +- 修复版本:未修。 + +## BUG-1506 | 修改已存人物的出生资料时,时区服务失败会默默清空出生地 + +- 状态:investigating +- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计) +- 最近更新:2026-11-01 +- 影响面:用 `BirthPlacePicker` 编辑已存人物的地方(本人资料、人物列表)。新建人物不受影响:新建时会显示错误和「重试」。 +- 用户现象:修改出生日期、时间或地点后,如果 `/api/locations/timezone` 返回 503、连不上或报错,原来显示的「省 · 市 · 区县 … UTC+8」变回「按省、市、区县逐级选择即可。」。没有错误提示,也没有「重试」按钮,用户要重新选地点,而且可能以为已经存好了。 +- 触发条件:编辑已有地点的人物时,时区解析失败。 +- 根因(读码 + 本机渲染确认):失败时 `frontend/src/components/birth-place-picker.tsx` 调 `onChange(null)`(约 274 行)。`frontend/src/components/birth-location-fields.tsx:21-35` 把地点代码全部清空,选择器的 `externalKey` 因此变化,草稿按空值重置(`birth-place-picker.tsx:129-132`),刚设置的错误状态也一起被丢掉。 +- 修复:未修。本轮只审计,待产品决定排期。 +- 验证:本机用真实 React 渲染 `BirthPlacePicker`,把 fetch 换成返回 503 的 stub,复现上述现象。脚本是临时的,没有入库。 +- 防复发:待修复单补「编辑时时区失败保留原地点并显示重试」的行为测试;现有 `frontend/tests/birth-place-picker.test.ts` 只断言源码文本。 +- 相关记录:`docs/operations/outage-behaviour-2026-11-01.md` 第二节。 +- 复发自:无 +- 修复版本:未修。 + +## BUG-1507 | 早期中国出生资料没有时区 ID,被默默按 +8 计算(1986–1991 夏令时出生差一小时) + +- 状态:investigating +- 首次发现:2026-11-01(Gitea #119 第 42 项,外部依赖故障审计时顺带发现,与故障无关) +- 最近更新:2026-11-01 +- 影响面:只有中国省市代码、没有坐标、地点名和 `timezone_id` 的早期资料(`20260724010000_global_birth_locations.sql` 没有回填)。前端首页资料和服务端今日星语、合盘的出生参数都会用到。受影响行数未取证。 +- 用户现象:没有报错。1986–1991 年夏季(中国实行夏令时期间)出生的人,按 +8 起盘,应为 +9,差一小时。重新保存本人资料时,`timezone_offset: 8`、`timezone_id: null` 会被写进库。 +- 触发条件:上述早期资料参与排盘或被重新保存。 +- 根因(读码 + 本机确认):`frontend/src/lib/home-profile.ts:74` 写死 `tz: country.timezone`(8);`frontend/src/lib/global-birth-payloads.ts:36-37`、`:65-66` 在没有 offset 时回落到 `chinaLocations.country.timezone`。新资料走 IANA 历史时区,Python `resolve_timezone_context` 对 1988-07-01 北京给 +9,两条路径结果不一致。 +- 修复:未修。需要产品决定:是否回填 `timezone_id=Asia/Shanghai` 并按历史时区重算,以及要不要通知受影响用户。 +- 验证:本机 stub 下,今日星语和合盘的出生参数都得到 tz 8,没有联网;早期资料保存后写入 `timezone_offset: 8`、`timezone_id: null`。Python 对同一时刻给 +9。脚本是临时的,没有入库。 +- 防复发:待修复单补测试。 +- 相关记录:BUG-1506;`docs/operations/outage-behaviour-2026-11-01.md` 第二节。新疆 `Asia/Urumqi`(+6)对报北京时间的用户差两小时,另待产品决定,没有单独记 Bug。 +- 复发自:无 +- 修复版本:未修。 diff --git a/docs/operations/outage-behaviour-2026-11-01.md b/docs/operations/outage-behaviour-2026-11-01.md new file mode 100644 index 00000000..3ef80ae3 --- /dev/null +++ b/docs/operations/outage-behaviour-2026-11-01.md @@ -0,0 +1,148 @@ +# 外部依赖挂掉时网站会怎样(2026-11-01) + +Gitea #119 第 42 项:「VedAstro、地点服务、模型供应商挂掉时网站会怎样,没演练过」。 + +- 做法:没有 staging 的 SSH,所以没做现场断网演练。改成读代码,加上在本机把依赖换成假服务(stub)跑真实代码。stub 没有连任何外部服务,也没有用任何模型密钥。 +- 代码版本:分支 `codex/outage-shadbala-119`,基于 `origin/staging` `0f1c179e`。 +- 标注:**[实测]** 是在本机跑过代码确认的;**[读码]** 只是读代码得出的结论。 +- stub 脚本是一次性的,没有入库。做法写在每张表的「依据」一栏,能照着复现。 +- 生产参数按 `deploy/README.md` 取值,例如 `VEDASTRO_TIMEOUT_SECONDS=20`(`deploy/README.md:111`)。 + +## 先说结论 + +1. **三类依赖都不会让整站挂掉。** 星盘页、建档完全不依赖 VedAstro 和模型;地点和时区在本机计算,没有外部供应商。 +2. **对话的扣点做得对。** 模型失败、超时、中途断流都会退点,测试有覆盖。 +3. **报告这一侧最弱。** + - 报告生成要同步等 VedAstro,总耗时没有上限。VedAstro 卡住时会超过前端 90 秒的等待,报告失败。 + - 报告任务的最后一次尝试如果因进程重启而租约过期,点数不退。 + - 解读段(模型写的部分)失败时,报告照常交付、全额扣点,用户看不到任何提示。 +4. **任何一家外部依赖挂了都不会告警。** + - `/api/health` 只检查配置和 swisseph,不探测 VedAstro、模型供应商或时区库。 + - VedAstro 模块不写日志。 + - 报告侧的模型失败不写 `errorCode`,错误汇总脚本数不到它。 +5. **模型调用没有重试,也没有备用供应商。** 一次 429 或 503 就让这一轮失败(会退点)。 + +## 一、VedAstro(外部参照引擎) + +关键参数: + +- 单次请求超时 20 秒(生产值)。代码默认值是 4 秒(`scripts/vedastro_service_adapter.py:341`),生产不用它。 +- 遇到 429 / 502 / 503 / 504 重试 2 次,间隔 1 秒(`:344-348`)。超时不重试。 +- 一次完整快照最多发 24 个 POST。 + +| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 | +| --- | --- | --- | --- | --- | +| 星盘页 `/api/chart-view` | 不受影响。前端一律带 `skip_vedastro_main_entry_overview: true` | 不涉及 | 无 | `frontend/src/lib/chart-view-load.ts:95` [读码] | +| 建档 / 保存人物 | 不受影响。只预热本地数据包,不调 VedAstro | 不涉及 | 无 | `frontend/src/lib/consult-packet-warm.ts:61` [读码] | +| 对话:VedAstro 超时、5xx 或连不上 | 后台线程去取,前台最多等 1.5 秒。等不到就标 `official_blocked / foreground_optional_evidence_timeout`,回答照常出 | 正常扣点(VedAstro 只是补充证据) | 无日志 | `scripts/vedastro_foreground.py:62-65`;[实测] stub 下前台 1.50 秒返回 | +| 对话:同一张盘当天再问 | 直接用当天的失败记录(#130「失败记一天」),0 秒返回,不再请求 VedAstro | 同上 | 无 | 失败记录存在 `scratch/local/vedastro_snapshot_negative_cache/`,键是出生资料、岁差、交点和 UTC 日期的哈希,UTC 换日后失效(`scripts/vedastro_consultation_snapshot.py:93-189`)。[实测] 第二轮 0.000 秒,`freshness=negative`。测试 `tests/test_vedastro_negative_day_20261028.py` | +| 报告生成:VedAstro 卡住 | 报告任务同步执行两段。一段是排盘时的主入口概览,约 4 × 20 ≈ 80 秒;另一段是生时门禁里的官方快照,约 48 秒。合计约 128 秒,超过前端 90 秒的等待(`frontend/src/mastra/consultation-workflow.ts:149`),报告以 `workflow_timeout` 失败。期间一直占着进程级两个重计算名额中的一个 | 报告任务失败后退点;但见下文「模型供应商」一节的租约过期缺口 | 无 VedAstro 日志 | `frontend/src/app/api/reports/route.ts:384` 和 `frontend/src/lib/personal-report-worker.ts:137` 都没传 `foreground`,所以 `defer_optional_external_evidence=false`。[实测] 把超时缩到 2 秒,排盘段 8.0 秒、门禁段 15.3 秒,按比例推到生产值 | +| 报告生成:VedAstro 返回 5xx 但很快 | 报告完成,VedAstro 一栏标 partial | 正常扣点 | 无 | `frontend/src/lib/personal-report-generation.ts:552` [读码];[实测] 503 下排盘段 27 秒,主要是重试间隔 | +| **报告生成 / 不带 skip 的 `/api/chart`:读响应时连接被截断** | **HTTP 500 `ERR_INTERNAL`,报告以 `workflow_server_error` 失败** | 报告失败后退点 | `[api_server] request failed`(`scripts/jyotish_api_server.py` 请求总入口的 `logging.exception`) | 快照分发只接 `HTTPError`、`URLError`、`RemoteDisconnected`、超时和 JSON 错误(`scripts/vedastro_service_adapter.py:2782-2808`、`:3010-3046`)。`http.client.IncompleteRead`、`ConnectionResetError` 漏出去,`_attach_vedastro_main_entry_overview` 外面也没有 try。[实测] IncompleteRead → 500。记为 BUG-1504 | +| 生时校正 `/api/rectification/v5/vedastro-validate`:VedAstro 整体不通 | 11 秒后返回 `status=fail`(`vedastro_timeout`),页面显示不能确认到分钟 | 校正按案例扣点,VedAstro 失败不单独退 | 无 | `scripts/rectification/vedastro_validate_cache.py:36`(11 秒预算)[实测] 11.04 秒 | +| 生时校正同上:VedAstro 连续返回 503 | Python 端要 24 秒,前端 12 秒就放弃(`frontend/src/lib/rectification-agentic/v9/engine-client.ts:942`),按超时失败处理。Python 线程继续跑完,继续占名额 | 同上 | 无 | 11 秒预算只管「能不能开始一次调用」,一次快照调用内部还有 24 个 POST,每个含重试。[实测] 24.05 秒 | +| 生时校正:同一组候选当天再验 | 直接用失败记录,24 小时内不重试;存在进程内存里,重启就丢 | 同上 | 无 | `vedastro_validate_cache.py:33`(`KEEP_SECONDS`)[实测] 第二次 0.00 秒 | + +## 二、地点与时区服务 + +架构上没有外部供应商可挂: + +- 出生地选择器是逐级下拉。中国数据随包打包;其他国家读站内静态文件 `/data/world-locations/.json`(`frontend/src/components/birth-place-picker.tsx:152-155`)。 +- 时区走浏览器 → `POST /api/locations/timezone` → Python `/api/location/timezone` → `timezonefinder` + `zoneinfo`,全在本机。[实测] 每次 7–100 毫秒。 +- 唯一的外部供应商 Geoapify 只在 `GET /api/locations/search` 里用,而界面上没有任何地方调用这个接口。 + +所以这里的「挂掉」实际指 Python 容器不可用或卡住。 + +| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 | +| --- | --- | --- | --- | --- | +| 新建人物,时区接口 503 或连不上 | 「时区服务暂时不可用,稍后重试即可。」加「重试」按钮,草稿保留,「确定」按钮不可点。不能手填经纬度或时区 | 不涉及 | 路由和服务都不写日志 | `birth-place-picker.tsx:272-276`、`:490-493`;`home-onboarding-shell.tsx:137` [读码];[实测] stub 503 / 连接拒绝 / 坏 JSON 都返回 `unavailable` | +| 新建人物,时区接口卡住 | 「正在确认该地点的时区…」一直转,我们的代码没有超时,要等 Node 默认约 300 秒 | 不涉及 | 无 | 选择器到 Next 再到 Python 三段都没有 AbortSignal 或超时 [读码];[实测] stub 卡住时转圈不结束 | +| **修改已存人物的出生日期、时间或地点,时区接口失败** | **已存的出生地被清空,没有报错,也没有「重试」按钮**,提示回到「按省、市、区县逐级选择即可。」用户可能以为已经存好 | 不涉及 | 无 | 失败时 `onChange(null)`(`birth-place-picker.tsx:274-275`),父组件把地点代码清空(`birth-location-fields.tsx:21-35`),选择器按新键重置草稿(`:129-132`)。[实测] 真实渲染 + fetch stub。记为 BUG-1506 | +| 海外国家地区列表加载失败 | 「该国家的地区列表暂时不可用,请稍后重试。」没有重试按钮,要换国家再换回来 | 不涉及 | 无 | `birth-place-picker.tsx:149-171`、`:336` [读码] | +| 星盘页:本人资料没存时区,查询时时区失败 | 「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」引导去改资料。这是临时故障,文案却让用户以为资料有错;「已经记录下来」实际只是一行 `console.warn`。卡住时 25 秒后显示「这张盘等了太久没有回来…」 | `billed:false` | `console.warn("chart_view_failure", {reason:"timezone_resolver_failure"})`,不告警 | `frontend/src/lib/chart-view-service.ts:190-199`、`chart-view-labels.ts:184`、`:205` [读码];测试 `chart-profile-update-consistency.test.ts`「classifies query, timezone…」 | +| 对话:本人资料没存时区,时区失败 | 503「暂时无法核对完整出生资料…请重新保存后再试,本次不会扣点。」同样把临时故障说成资料问题 | **不扣点**:时区在 `reserve()` 之前解析 | 不写日志 | `consultation-route-service.ts:567-574` 对比 `:622` [读码];测试 `consultation-route-service.test.ts`「historical timezone failure remains pre-billing」[实测通过] | +| 报告、合盘、今日星语 | 不受影响:这些入口要求资料里已存时区,不再联网解析 | 不涉及 | 不涉及 | `frontend/src/lib/subject-birth.ts:83-91` [读码] | +| 生时校正开案 | 500「校正服务暂时不可用」 | 不扣点:时区在开案 RPC 之前 | 不写日志 | `frontend/src/lib/rectification-agentic/v9/case-service.ts:479-481` 对比 `:512` [读码] | +| 生时评估流程 | 503「生时评估暂时不可用 / 已保留当前资料,请稍后重试。」只有这一处文案准确 | 不涉及 | 不写日志 | `frontend/src/app/api/birth-time-journey/route.ts:254-260` [读码] | +| 星历页 | 本人盘部分直接变空,没有提示 | 不涉及 | 无 | `frontend/src/app/api/ephemeris/route.ts:124` [读码] | + +顺带发现的正确性问题,与故障无关: + +- 早期中国资料只有省市代码,没有坐标和 `timezone_id`。这类资料在前后端都会被默默按 +8 计算,重新保存时还会把 `timezone_offset: 8` 写进库。 + - 出处:`frontend/src/lib/home-profile.ts:74`、`frontend/src/lib/global-birth-payloads.ts:36-37`、`:65-66`。 + - 1986–1991 年夏季(中国实行夏令时)出生的人,正确值是 +9,所以差一小时。[实测] Python 对 1988-07-01 北京给 +9。 + - 记为 BUG-1507。 +- `timezonefinder` 把乌鲁木齐、喀什定为 `Asia/Urumqi`(+6)[实测]。如果用户报的是北京时间,会差两小时。要不要处理,需要产品决定。 + +## 三、模型供应商 + +关键参数: + +- 模型目录从数据库读(`frontend/src/lib/model-catalog.ts:60-77`),`frontend/src/mastra/model.ts` 只是占位。 +- **不重试**:Mastra 智能体的 `maxRetries` 默认是 0,项目里没有任何地方改它。[实测] 503、429、连接拒绝都只发了 1 次请求。 +- **没有备用供应商**:`frontend/src/lib/consult/session-model-fallback.ts:33-50` 只在会话固定的模型版本被下架时换成默认模型,调用失败时不换。 +- 对话的计时:工具阶段 110 秒(`frontend/src/mastra/consultation-tools.ts:77`),回答阶段另计 300 秒(`:108`)。浏览器端没有超时。 +- 报告解读:每次 120 秒,最多 2 次(`personal-report-reading.ts:33`、`:183`)。 +- 合盘:每节 90 秒,最多 2 次。 +- 生时校正:每次 210 秒,每轮最多 225 秒、2 次(`rectification-run-budget.ts:8-11`)。 + +| 场景 | 用户看到什么 | 扣点 / 退点 | 日志 / 告警 | 依据 | +| --- | --- | --- | --- | --- | +| 对话:供应商 503、429 或连不上 | 「咨询暂时无法完成,本次不会扣点。」输入框解锁。没有自动重试,也不提示换模型 | **退点**(`frontend/src/app/api/consult/route.ts:1641-1645` → `settlement.ts:143-167`) | `[consult-provider-error]`,`[agent-observability]` 带 `errorCode=provider_error`。错误汇总脚本 5 分钟内同类满 10 次才告警 | [实测] 真实 Mastra 智能体 + 本机 stub 供应商。测试「generic provider stream error is calculation_failed」(BUG-938) | +| 对话:回答写到一半断流 | 已经显示的文字保留,后面接失败提示。保留的文字只在浏览器里,刷新就没了 | 退点 | 同上 | [实测] 已送出 283 字后断流,`run.failed` | +| 对话:供应商接了连接但不回 | 转圈加进度文字,最长约 300 秒;工具阶段卡住时最坏约 410 秒。最后显示「计算已完成,但这次没有生成回答…请再发送一次」,文案不对 | 退点 | `errorCode=empty_answer` | [实测] 把回答时钟缩到 2 秒复现 | +| 对话:浏览器断开或服务器进程退出 | 页面转入「回答仍在后台生成…」,每 1.75 秒查一次状态。15 分钟后显示「回答已取消…」 | 进程还在:正常结算。进程已退出:15 分钟后由状态接口补退,前提是用户页面还在查 | 无 | `frontend/src/app/api/consult/status/route.ts:79-106` [读码];测试「reserved consultations recover through the status endpoint」 | +| 个人报告:解读段模型失败(2 × 120 秒) | 报告显示「已完成」,但没有解读段,也没有任何提示(BUG-1461 的设计是解读失败不让报告失败) | **全额扣点** | 只有 `console.info [personal-report] reading=model_failed`,不告警 | `frontend/src/lib/personal-report-worker-core.ts:436-451` [读码] | +| 合盘 / 年运报告:模型失败 | 任务最多试 3 次,然后显示「报告生成失败」和错误码。页面 8 分钟后停止轮询并显示超时 | 最终失败时退点 | 没有 observability 行,不告警 | `synastry-full-run.ts:213-219`、`annual-report-server.ts:463-467` [读码];测试「terminal durable generation failure releases…」 | +| **报告任务最后一次尝试时进程重启或卡死(租约过期)** | 报告变成失败(`calculation_unavailable`) | **不退点。** 回收函数 `recover_expired_personal_report_jobs` 只改状态、不调 `release_usage`;只有用户删除报告时才由删除触发器退回。用户重新生成就要再付一次 | 无 | `frontend/supabase/migrations/20260814040000_personal_report_jobs_v2.sql`(回收函数)对比 `20260925040000_deleted_report_release.sql`。[实测] stub 下回收 `failedCount=1`、没有任何退点调用。记为 BUG-1505 | +| 报告或校正的退点 RPC 本身失败 | 报告照常标失败 | 不退,返回值被忽略 | 无 | `personal-report-worker-core.ts:242`;`rectification-agentic/v9/agent-run-finish.ts:49` [实测 报告侧] | +| 生时校正:模型失败 | 自动重试一次,然后「本轮没有完成,状态已记录。」中途失败时可能显示「服务端运行超时」,但实际不是超时 | 退点(返回值被忽略,同上) | `[rectification-v10] attempt failed …` | `v9/agent-run-attempt.ts:434`、`:503-514` [读码] | +| 星盘页 / 建档 | 不调用模型 | 不涉及 | 不涉及 | [读码] | +| 旧版对话路径(`CONSULTATION_AGENTIC_RUNTIME=legacy`)中途出错 | 文字流中断 | 按已输出部分扣点 | 无 | `consult/route.ts:1683-1690` [读码]。默认是 `enabled`,这条路径目前不走 | + +## 四、告警现状(三类依赖共用) + +- 探活脚本 `deploy/monitor/healthcheck-alert.sh` 只看 `/api/health`。 + - Next 侧检查的是数据库迁移、模型目录配置和 Python 是否在线。 + - Python 侧只检查 swisseph。 + - 都不探测 VedAstro、模型供应商或时区库,所以三者挂掉时探活仍是绿的。 +- 错误汇总 `deploy/monitor/error-digest.sh` 只数 `[agent-observability]` 的 `errorCode` 和 5xx。 + - 对话失败是 200 流里夹 `run.failed`,只能靠 `errorCode` 计数。 + - VedAstro、地点、报告侧的模型失败都不写 `errorCode`。 +- 第 20 项(飞书 Webhook、在主机上安装监控)还没做,所以以上告警现在都不会发出去。 + +## 五、缺口(按风险从高到低) + +1. **报告任务租约过期不退点(BUG-1505)。** 进程在最后一次尝试时重启就会触发,发版时尤其容易遇到。点数扣掉了、报告失败,用户要么删报告,要么重新付一次。退点 RPC 失败也被忽略。 +2. **报告生成同步等 VedAstro,没有总时限。** VedAstro 卡住时约 128 秒,超过前端 90 秒,报告失败;同时占着两个重计算名额中的一个,并发两份报告会让对话和校正拿到 429。这条路径的失败也不写失败缓存,下一份报告会重来一遍。可选做法:报告也走 `defer_optional_external_evidence`,或者跳过主入口概览,或者复用对话的前台预算和失败缓存。 +3. **VedAstro 连接被截断会变成 500(BUG-1504)。** 报告失败(会退点),不带 skip 的 `/api/chart` 也会 500。 +4. **出生资料编辑时,时区服务失败会默默清掉出生地(BUG-1506)。** +5. **早期中国资料默默按 +8 计算(BUG-1507)。** 这是正确性问题,与故障无关:1986–1991 年夏季出生的差一小时。新疆 +6 要产品决定。 +6. **模型调用不重试、没有备用供应商。** 一次 429 或 503 就让付费用户这一轮失败。会退点,但体验差;目录里配了多个模型,失败时也不切换。 +7. **外部依赖挂了没有任何告警或日志。** 健康检查不探测,VedAstro 模块不写日志,报告侧模型失败不写 `errorCode`。凌晨低流量时,对话也达不到 5 分钟 10 次的阈值。 +8. **个人报告解读失败仍全额扣点,用户不知道。** 这是 BUG-1461 的设计结果,需要产品决定:是否降价、提示用户,或者重试。 +9. **生时校正的 VedAstro 验证预算在 5xx 下失效。** Python 端 24 秒,前端 12 秒放弃,线程继续占名额。 +10. **VedAstro 失败缓存不会被成功覆盖。** 对话按 UTC 日期,校正按 24 小时;一次 5 分钟的短暂故障,会让这张盘当天都拿不到官方证据。 +11. **超时与文案。** + - 地点到 Python 的整条链没有超时,卡住时转圈约 5 分钟。 + - 对话卡住时最长约 300 秒,之后显示「计算已完成…」。 + - 星盘页和对话把临时故障说成「资料不对、请重新保存」。 + - 星盘页声称「已经记录下来了」,实际只有 `console.warn`。 +12. **低风险。** + - 没有页面调用的 `/api/locations/search` 仍保留 Geoapify 路径:没有超时,供应商返回非 JSON 时 500。 + - 海外国家列表没有重试按钮;星历页默默丢掉本人盘。 + - 中途断流时保留的部分回答,刷新后就没了。 + - `tests/test_vedastro_service_adapter_executor.py` 有 3 个用例在 teardown 报错:用例把 `time.monotonic` 换成了有限迭代器,conftest 收尾时再调用它就抛 `StopIteration`。这是测试本身的问题,不在 quick 门禁里。 + +## 六、本轮跑过的现有测试 + +- Python:`tests/test_vedastro_negative_day_20261028.py`、`test_rectification_v5_vedastro_validation.py`、`test_vedastro_snapshot_cache.py`、`test_vedastro_consultation_snapshot.py`、`test_vedastro_health_probe_cache.py`、`test_vedastro_service_adapter_executor.py`、`test_vedastro_gateway.py`、`test_vedastro_free_tier_state.py`、`test_vedastro_executor_isolation_20261028.py`、`test_vedastro_runtime_ops.py`、`test_vedastro_rest_bridge.py`。结果:107 通过;另有 3 个 teardown 报错,见上文第 12 条。 +- 前端,地点:`global-birth-location`、`birth-place-picker`、`global-birth-profile-routes`、`adopted-birth-date`、`consultation-route-service`,50/50 通过。 +- 前端,模型与扣点:`consultation-agentic-runtime`、`consultation-billing`、`consultation-recovery`、`consultation-stream-recovery`、`consultation-recovery-poll`、`stream-text-response`、`consult-answer-truncation-20260926`、`consultation-smalltalk`、`consult-session-model-fallback`、`model-catalog`、`personal-report-worker`、`synastry-full-report`、`rectification-v9-stream`、`session-title-agent`、`session-context-summary`、`report-reading-20261027`。结果:307 项中 306 通过;1 项失败是因为本机 Node 环境没有 swisseph,属于环境问题。 +- 没有测试覆盖的: + - 任何一段的超时或卡住 + - 模型重试和备用供应商 + - 租约过期后的退点,以及退点 RPC 失败 + - 选择器的出错、重试和编辑时清空 + - 时区失败时对话和校正的文案 + - 早期中国资料的 +8 回退