Skip to content

Commit 81e9ca8

Browse files
authored
fix(issue-1543): [bug]-alphasift-可用性检测不应静默吞掉非预期异常 (#1544)
1 parent 3e08589 commit 81e9ca8

4 files changed

Lines changed: 167 additions & 40 deletions

File tree

api/v1/endpoints/alphasift.py

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55

66
import importlib
77
import inspect
8+
import logging
89
import math
910
import os
1011
import subprocess
1112
import sys
1213
from dataclasses import asdict, is_dataclass
1314
from pathlib import Path
14-
from typing import Any, Dict, List
15+
from typing import Any, Dict, List, Optional, Tuple
1516

1617
from fastapi import APIRouter, Depends, HTTPException, Request
1718
from pydantic import BaseModel, Field
@@ -21,8 +22,10 @@
2122
from src.auth import COOKIE_NAME, is_auth_enabled, verify_session
2223

2324
router = APIRouter()
25+
logger = logging.getLogger(__name__)
2426

2527
ALPHASIFT_DSA_ADAPTER_MODULE = "alphasift.dsa_adapter"
28+
ALPHASIFT_EXPECTED_MISSING_MODULES = frozenset({"alphasift", ALPHASIFT_DSA_ADAPTER_MODULE})
2629
ALLOWED_ALPHASIFT_INSTALL_SPECS = frozenset({DEFAULT_ALPHASIFT_INSTALL_SPEC})
2730

2831

@@ -46,23 +49,18 @@ class AlphaSiftStrategyResponse(BaseModel):
4649

4750
@router.get("/status")
4851
def alphasift_status(config: Config = Depends(get_config_dep)) -> Dict[str, Any]:
49-
adapter_status: Dict[str, Any] = {}
50-
available = _is_alphasift_available()
51-
if available:
52-
try:
53-
adapter_status = _call_alphasift_status()
54-
available = bool(adapter_status.get("available", True))
55-
except Exception:
56-
available = False
57-
58-
return {
52+
adapter_status, available, diagnostics = _get_alphasift_status_snapshot()
53+
payload = {
5954
"enabled": bool(config.alphasift_enabled),
6055
"available": available,
6156
"install_spec_is_default": _is_default_alphasift_install_spec(config.alphasift_install_spec),
6257
"contract_version": adapter_status.get("contract_version"),
6358
"version": adapter_status.get("version"),
6459
"strategy_count": adapter_status.get("strategy_count"),
6560
}
61+
if diagnostics:
62+
payload["diagnostics"] = diagnostics
63+
return payload
6664

6765

6866
@router.get("/strategies")
@@ -251,10 +249,20 @@ def _ensure_alphasift_enabled(config: Config) -> None:
251249

252250

253251
def _is_alphasift_available() -> bool:
252+
_, available, _ = _get_alphasift_status_snapshot()
253+
return available
254+
255+
256+
def _get_alphasift_status_snapshot() -> Tuple[Dict[str, Any], bool, Optional[Dict[str, str]]]:
254257
try:
255-
return _is_adapter_available(_call_alphasift_status())
256-
except Exception:
257-
return False
258+
adapter_status = _call_alphasift_status()
259+
except HTTPException as exc:
260+
return {}, False, _extract_alphasift_diagnostics(exc)
261+
except Exception as exc:
262+
diagnostics = _log_unexpected_alphasift_exception("status_probe", exc)
263+
return {}, False, diagnostics
264+
265+
return adapter_status, _is_adapter_available(adapter_status), None
258266

259267

260268
def _is_adapter_available(adapter_status: Any) -> bool:
@@ -264,16 +272,24 @@ def _is_adapter_available(adapter_status: Any) -> bool:
264272

265273

266274
def _import_alphasift() -> Any:
267-
_prepare_alphasift_runtime_env()
268275
try:
276+
_prepare_alphasift_runtime_env()
269277
return importlib.import_module(ALPHASIFT_DSA_ADAPTER_MODULE)
278+
except ModuleNotFoundError as exc:
279+
if _is_expected_alphasift_missing(exc):
280+
raise _alphasift_unavailable_exception(
281+
f"AlphaSift 未安装或未挂载到当前 Python 环境,无法导入 {ALPHASIFT_DSA_ADAPTER_MODULE}{exc}"
282+
) from exc
283+
diagnostics = _log_unexpected_alphasift_exception("import_adapter", exc)
284+
raise _alphasift_unavailable_exception(
285+
f"AlphaSift 适配层导入失败,请检查依赖完整性和当前 Python 环境:{exc}",
286+
diagnostics=diagnostics,
287+
) from exc
270288
except Exception as exc:
271-
raise HTTPException(
272-
status_code=424,
273-
detail={
274-
"error": "alphasift_unavailable",
275-
"message": f"AlphaSift 未安装或未挂载到当前 Python 环境,无法导入 {ALPHASIFT_DSA_ADAPTER_MODULE}{exc}",
276-
},
289+
diagnostics = _log_unexpected_alphasift_exception("import_adapter", exc)
290+
raise _alphasift_unavailable_exception(
291+
f"AlphaSift 适配层导入失败,请检查依赖完整性和当前 Python 环境:{exc}",
292+
diagnostics=diagnostics,
277293
) from exc
278294

279295

@@ -309,22 +325,64 @@ def _get_adapter_callable(adapter: Any, name: str, missing_error: str) -> Any:
309325

310326
def _call_alphasift_status() -> Dict[str, Any]:
311327
adapter = _import_alphasift()
312-
get_status = _get_adapter_callable(adapter, "get_status", "get_status() 不可调用。")
328+
try:
329+
get_status = _get_adapter_callable(adapter, "get_status", "get_status() 不可调用。")
330+
except HTTPException as exc:
331+
diagnostics = _log_unexpected_alphasift_exception("get_status_callable", exc)
332+
raise _alphasift_unavailable_exception(
333+
"AlphaSift 适配层 get_status 不可调用,请检查适配层版本。",
334+
diagnostics=diagnostics,
335+
) from exc
313336
try:
314337
result = _to_plain(get_status())
315338
except Exception as exc:
316-
raise HTTPException(
317-
status_code=424,
318-
detail={
319-
"error": "alphasift_unavailable",
320-
"message": f"AlphaSift 适配层 get_status 调用失败:{exc}",
321-
},
339+
diagnostics = _log_unexpected_alphasift_exception("get_status", exc)
340+
raise _alphasift_unavailable_exception(
341+
f"AlphaSift 适配层 get_status 调用失败:{exc}",
342+
diagnostics=diagnostics,
322343
) from exc
323344
if not isinstance(result, dict):
324-
return {}
345+
exc = TypeError(f"get_status returned {type(result).__name__}, expected dict")
346+
diagnostics = _log_unexpected_alphasift_exception("get_status_result", exc)
347+
raise _alphasift_unavailable_exception(
348+
"AlphaSift 适配层 get_status 返回结构非法,请检查适配层版本。",
349+
diagnostics=diagnostics,
350+
) from exc
325351
return result
326352

327353

354+
def _is_expected_alphasift_missing(exc: ModuleNotFoundError) -> bool:
355+
return getattr(exc, "name", None) in ALPHASIFT_EXPECTED_MISSING_MODULES
356+
357+
358+
def _alphasift_unavailable_exception(
359+
message: str,
360+
*,
361+
diagnostics: Optional[Dict[str, str]] = None,
362+
) -> HTTPException:
363+
detail: Dict[str, Any] = {"error": "alphasift_unavailable", "message": message}
364+
if diagnostics:
365+
detail["diagnostics"] = diagnostics
366+
return HTTPException(status_code=424, detail=detail)
367+
368+
369+
def _log_unexpected_alphasift_exception(stage: str, exc: BaseException) -> Dict[str, str]:
370+
logger.warning("Unexpected AlphaSift %s failure: %s", stage, exc, exc_info=exc.__traceback__ is not None)
371+
return {
372+
"reason": "unexpected_exception",
373+
"stage": stage,
374+
"error_type": exc.__class__.__name__,
375+
}
376+
377+
378+
def _extract_alphasift_diagnostics(exc: HTTPException) -> Optional[Dict[str, str]]:
379+
detail = exc.detail if isinstance(exc.detail, dict) else {}
380+
diagnostics = detail.get("diagnostics")
381+
if not isinstance(diagnostics, dict):
382+
return None
383+
return {str(key): str(value) for key, value in diagnostics.items()}
384+
385+
328386
def _list_strategies() -> List[Dict[str, Any]]:
329387
adapter = _get_dsa_adapter()
330388
list_strategies = _get_adapter_callable(adapter, "list_strategies", "list_strategies() 不可调用。")

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
2222
- [修复] AlphaSift 默认安装来源改为锁定 commit 的受信任 GitHub 地址,自动安装接口要求管理员会话并限制安装来源。
2323
- [修复] 修复 Web 开启 AlphaSift 时先安装后写配置导致默认关闭状态无法开启的问题。
2424
- [修复] AlphaSift 状态与安装接口不再返回 `install_spec` 明文,仅返回 `install_spec_is_default` 等非敏感状态字段。
25+
- [修复] AlphaSift 状态探测区分可选依赖缺失与非预期异常,异常场景记录 warning 并返回非敏感诊断信息。
2526
- [修复] 调整 AlphaSift 筛选调用兼容:`screen``max_results` 为主并支持历史 `max_output` 关键词,同时允许策略透传以对齐前端手动策略参数。
2627
- [修复] AlphaSift Web 选股请求使用独立长超时,避免开启 LLM 重排后被通用 30 秒 API 超时提前中断。
2728
- [修复] 桌面端打包阶段预置 AlphaSift 并收集适配层,避免发布包运行时再要求管理员自动安装。

docs/alphasift-integration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ AlphaSift 侧已在 `ZhuLinsen/alphasift@b2ca66dd47001b9a09890cfe21c2b18c7219ccf
7070
- 任何配置清理、回退与告警仍沿用当前后端配置解析链路(包括运行时保存前验证、兼容别名解析和非法值 fallback);AlphaSift 功能本身不引入额外清理/迁移副作用。
7171
- 如果用户已在 `.env`/设置页配置过历史值,AlphaSift 开启前后应保持可用行为一致;需要恢复旧行为时,按既有方式回退到原配置(例如恢复旧的模型名/BASE URL,或关闭相关模型通道)即可。
7272
- 失败可见性:`status`/`screen` 接口返回明确错误码与 `message`,前端在设置页或选股页会将 `403/424/400/422` 等错误直接提示给用户,便于定位并回退到“关闭 AlphaSift + 保持原有 LLM 运行链路”。
73+
- 状态诊断:`/api/v1/alphasift/status` 对 AlphaSift 包或 `alphasift.dsa_adapter` 未安装仍保持 `200` + `available=false` 的兼容语义;如果导入过程、`get_status()` 调用或返回结构出现非预期异常,后端会记录 warning,并在响应中追加不含安装来源明文的 `diagnostics` 字段,便于从接口状态和服务端日志定位问题。
7374

7475
错误策略:
7576

tests/test_alphasift_api.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,19 @@ def test_default_install_spec_is_commit_pinned(self) -> None:
7373
def test_status_defaults_to_disabled(self) -> None:
7474
config = self._config(enabled=False)
7575

76-
with patch("api.v1.endpoints.alphasift._is_alphasift_available", return_value=False):
76+
with patch("api.v1.endpoints.alphasift._call_alphasift_status", side_effect=_raise_alphasift_unavailable):
7777
payload = alphasift_endpoint.alphasift_status(config=config)
7878

7979
self.assertEqual(payload["enabled"], False)
80+
self.assertEqual(payload["available"], False)
8081
self.assertEqual(payload["install_spec_is_default"], True)
82+
self.assertNotIn("diagnostics", payload)
8183
self.assertNotIn("install_spec", payload)
8284

8385
def test_status_marks_custom_install_source(self) -> None:
8486
config = self._config(enabled=False, install_spec="git+https://example.com/private/alphasift.git")
8587

86-
with patch("api.v1.endpoints.alphasift._is_alphasift_available", return_value=False):
88+
with patch("api.v1.endpoints.alphasift._call_alphasift_status", side_effect=_raise_alphasift_unavailable):
8789
payload = alphasift_endpoint.alphasift_status(config=config)
8890

8991
self.assertEqual(payload["install_spec_is_default"], False)
@@ -92,29 +94,94 @@ def test_status_marks_custom_install_source(self) -> None:
9294
def test_status_includes_adapter_contract_metadata(self) -> None:
9395
config = self._config(enabled=True)
9496

95-
with (
96-
patch("api.v1.endpoints.alphasift._is_alphasift_available", return_value=True),
97-
patch(
98-
"api.v1.endpoints.alphasift._call_alphasift_status",
99-
return_value={"available": True, "contract_version": "1", "version": "0.2.0", "strategy_count": 8},
100-
),
97+
with patch(
98+
"api.v1.endpoints.alphasift._call_alphasift_status",
99+
return_value={"available": True, "contract_version": "1", "version": "0.2.0", "strategy_count": 8},
101100
):
102101
payload = alphasift_endpoint.alphasift_status(config=config)
103102

103+
self.assertTrue(payload["available"])
104104
self.assertEqual(payload["contract_version"], "1")
105105
self.assertEqual(payload["version"], "0.2.0")
106106
self.assertEqual(payload["strategy_count"], 8)
107107

108-
def test_status_maps_adapter_runtime_exception_to_unavailable(self) -> None:
108+
def test_status_preserves_adapter_available_false_without_diagnostics(self) -> None:
109+
config = self._config(enabled=False)
110+
111+
with patch(
112+
"api.v1.endpoints.alphasift._call_alphasift_status",
113+
return_value={"available": False, "contract_version": "1", "version": "0.2.0", "strategy_count": 0},
114+
):
115+
payload = alphasift_endpoint.alphasift_status(config=config)
116+
117+
self.assertFalse(payload["available"])
118+
self.assertEqual(payload["contract_version"], "1")
119+
self.assertNotIn("diagnostics", payload)
120+
121+
def test_status_logs_and_reports_adapter_runtime_exception_diagnostics(self) -> None:
122+
config = self._config(enabled=False)
123+
fake_module = _make_adapter_module(get_status=MagicMock(side_effect=RuntimeError("get_status failed")))
124+
125+
with (
126+
patch("api.v1.endpoints.alphasift._import_alphasift", return_value=fake_module),
127+
self.assertLogs("api.v1.endpoints.alphasift", level="WARNING") as captured,
128+
):
129+
payload = alphasift_endpoint.alphasift_status(config=config)
130+
131+
self.assertFalse(payload["available"])
132+
self.assertEqual(payload["diagnostics"]["reason"], "unexpected_exception")
133+
self.assertEqual(payload["diagnostics"]["stage"], "get_status")
134+
self.assertEqual(payload["diagnostics"]["error_type"], "RuntimeError")
135+
self.assertIn("Unexpected AlphaSift get_status failure", "\n".join(captured.output))
136+
137+
def test_status_logs_and_reports_unexpected_import_exception_diagnostics(self) -> None:
138+
config = self._config(enabled=False)
139+
missing_sub_dependency = ModuleNotFoundError("No module named 'optional_dep'", name="optional_dep")
140+
141+
with (
142+
patch("api.v1.endpoints.alphasift._prepare_alphasift_runtime_env"),
143+
patch("api.v1.endpoints.alphasift.importlib.import_module", side_effect=missing_sub_dependency),
144+
self.assertLogs("api.v1.endpoints.alphasift", level="WARNING") as captured,
145+
):
146+
payload = alphasift_endpoint.alphasift_status(config=config)
147+
148+
self.assertFalse(payload["available"])
149+
self.assertEqual(payload["diagnostics"]["reason"], "unexpected_exception")
150+
self.assertEqual(payload["diagnostics"]["stage"], "import_adapter")
151+
self.assertEqual(payload["diagnostics"]["error_type"], "ModuleNotFoundError")
152+
self.assertIn("Unexpected AlphaSift import_adapter failure", "\n".join(captured.output))
153+
154+
def test_status_logs_and_reports_invalid_get_status_result_diagnostics(self) -> None:
155+
config = self._config(enabled=False)
156+
fake_module = _make_adapter_module(get_status=lambda: ["not", "a", "dict"])
157+
158+
with (
159+
patch("api.v1.endpoints.alphasift._import_alphasift", return_value=fake_module),
160+
self.assertLogs("api.v1.endpoints.alphasift", level="WARNING") as captured,
161+
):
162+
payload = alphasift_endpoint.alphasift_status(config=config)
163+
164+
self.assertFalse(payload["available"])
165+
self.assertEqual(payload["diagnostics"]["reason"], "unexpected_exception")
166+
self.assertEqual(payload["diagnostics"]["stage"], "get_status_result")
167+
self.assertEqual(payload["diagnostics"]["error_type"], "TypeError")
168+
self.assertIn("Unexpected AlphaSift get_status_result failure", "\n".join(captured.output))
169+
170+
def test_status_logs_and_reports_missing_get_status_callable_diagnostics(self) -> None:
109171
config = self._config(enabled=False)
172+
fake_module = SimpleNamespace(list_strategies=lambda: [], screen=MagicMock(return_value=[]))
110173

111174
with (
112-
patch("api.v1.endpoints.alphasift._is_alphasift_available", return_value=True),
113-
patch("api.v1.endpoints.alphasift._call_alphasift_status", side_effect=RuntimeError("get_status failed")),
175+
patch("api.v1.endpoints.alphasift._import_alphasift", return_value=fake_module),
176+
self.assertLogs("api.v1.endpoints.alphasift", level="WARNING") as captured,
114177
):
115178
payload = alphasift_endpoint.alphasift_status(config=config)
116179

117180
self.assertFalse(payload["available"])
181+
self.assertEqual(payload["diagnostics"]["reason"], "unexpected_exception")
182+
self.assertEqual(payload["diagnostics"]["stage"], "get_status_callable")
183+
self.assertEqual(payload["diagnostics"]["error_type"], "HTTPException")
184+
self.assertIn("Unexpected AlphaSift get_status_callable failure", "\n".join(captured.output))
118185

119186
def test_strategies_returns_adapter_strategies(self) -> None:
120187
config = self._config(enabled=True)

0 commit comments

Comments
 (0)