Skip to content

Commit ea1c43b

Browse files
committed
fix(ai-gateway): 修复 learning 端点运行时 NameError 并使 ruff/pytest 全绿
CI 的 ruff 检查暴露出一个迁移引入的运行时缺陷(源项目 188 个 ruff 错误、 目标 204 个,该检查在源项目从来就是红的,掩盖了真问题): 1) routers/learning.py 缺失 3 个 schema 导入(F821 undefined-name) 阶段 11 把内联模型抽到 learning_schemas.py 时,AnchorPointItem / PredictItem / RescueLevelItem 未列入导入清单。这三个名字只在函数体内 被构造,import routers 与既有测试都不会触发,但一旦调用 anchor-point / predict / rescue 端点即 NameError → 500。 2) tests/conftest.py fixture 名称不一致(存量,源项目同样) 内部定义 fake_call,却 setattr/return fake_call_with_fallback, 该 fixture 一旦被使用即 NameError。 3) tests/test_e2e_auth.py TestJWTDevMode 依赖外网(§7 测试隔离) fixture 只清空 jwt_secret,但 _jwt_verification_configured() 在 ES256 下检查的是 supabase_jwks_url/supabase_url,导致用例走真实验证 路径去拉 JWKS,离网必败。按同文件 TestJWTPassthrough 的既有范式补齐 算法与端点隔离,令其真正进入被测的开发降级模式。 4) routers/balance.py 删除死变量 providers_state(F841) 新增 server/ai-gateway/ruff.toml 显式锁定规则集为 pyflakes + pycodestyle 错误级(E4/E7/E9/F):ruff 0.16 起默认集纳入 BLE/RUF/UP/DTZ 等风格规则, 不显式声明会让 CI 在依赖升级后突然变红。测试目录豁免 E402(须先 sys.path.insert 注入根目录)与 F401/F403/F405,兼容层 __init__ 豁免 F401。 风格类整治(66 处导入排序、45 处裸 except 等)登记技术债后续专项处理。 验证:ruff All checks passed;pytest 133 passed(此前 130/3,且 3 项依赖外网)。
1 parent 3b71f35 commit ea1c43b

9 files changed

Lines changed: 432 additions & 409 deletions

File tree

‎server/ai-gateway/config/runtime.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import contextvars
1212
import logging
13-
import os
1413
from pathlib import Path
1514

1615
from dotenv import load_dotenv

‎server/ai-gateway/routers/balance.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ async def get_balance(request: Request):
266266

267267
# 逐个查询 Provider 余额
268268
results = []
269-
providers_state = getattr(request.app.state, "providers", {})
270269

271270
for provider_key, (display_name, query_fn) in _PROVIDER_QUERIES.items():
272271
cfg = AI_PROVIDERS.get(provider_key, {})

‎server/ai-gateway/routers/evaluate.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
@ai-context: 费曼评估路由:对用户讲解打分并给反馈,经 fallback 链调用文本模型。
88
"""
99

10-
import time
1110
import logging
1211
from pydantic import BaseModel, Field
1312
from fastapi import APIRouter, Request

‎server/ai-gateway/routers/generate_cards.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
@ai-context: 闪卡生成路由:从笔记抽取问答对生成闪卡(JSON Mode),经 fallback 链。
88
"""
99

10-
import time
1110
import hashlib
1211
import logging
1312
from pydantic import BaseModel, Field

‎server/ai-gateway/routers/learning.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
"""
1111

1212
import logging
13-
from typing import Optional
1413

15-
from pydantic import BaseModel, Field
1614
from fastapi import APIRouter, Request
1715

1816
from config import call_with_fallback_for_request
@@ -21,14 +19,17 @@
2119
from chains.predict_chain import PredictChain
2220
from chains.rescue_chain import RescueChain
2321
from routers.learning_schemas import (
22+
AnchorPointItem,
2423
AnchorPointRequest,
2524
AnchorPointResult,
26-
SocraticRequest,
27-
SocraticResult,
25+
PredictItem,
2826
PredictRequest,
2927
PredictResult,
28+
RescueLevelItem,
3029
RescueRequest,
3130
RescueResult,
31+
SocraticRequest,
32+
SocraticResult,
3233
)
3334

3435
logger = logging.getLogger(__name__)

‎server/ai-gateway/routers/recommend.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
@ai-context: 番茄钟推荐路由:基于历史专注数据推荐时长/策略,含本地规则降级。
88
"""
99

10-
import time
1110
import logging
1211
from pydantic import BaseModel, Field
1312
from fastapi import APIRouter, Request

‎server/ai-gateway/ruff.toml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# @ai-context
2+
# ruff 规则集配置:锁定为 pyflakes(F) + pycodestyle 错误级(E4/E7/E9)。
3+
# Ruff rule set pinned to pyflakes + pycodestyle errors.
4+
# Why: ruff 默认集随版本扩张(0.16 起纳入 BLE/RUF/UP/DTZ 等风格规则),
5+
# 不显式声明会导致 CI 在依赖升级后突然变红;风格类规则待专项整治后再启用。
6+
7+
target-version = "py312"
8+
line-length = 120
9+
10+
[lint]
11+
select = ["E4", "E7", "E9", "F"]
12+
13+
[lint.per-file-ignores]
14+
# 测试文件:允许 fixture 注入产生的未使用导入;
15+
# E402 —— 测试须先 sys.path.insert 注入 ai-gateway 根目录,之后才能导入被测模块
16+
"tests/*" = ["F401", "F403", "F405", "E402"]
17+
# 兼容层 re-export 本质即"未使用导入"
18+
"config/__init__.py" = ["F401"]
19+
"routers/__init__.py" = ["F401"]
Lines changed: 147 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,147 @@
1-
"""
2-
熵减 AI 网关 — pytest 公共夹具
3-
4-
提供测试用的 FastAPI app、TestClient、mock Provider 等。
5-
"""
6-
7-
import sys
8-
import os
9-
import asyncio
10-
from pathlib import Path
11-
from unittest.mock import AsyncMock, MagicMock
12-
13-
import pytest
14-
15-
# 确保 ai-gateway 根目录在 sys.path 中,使 config/errors 等可导入
16-
GATEWAY_ROOT = str(Path(__file__).resolve().parent.parent)
17-
if GATEWAY_ROOT not in sys.path:
18-
sys.path.insert(0, GATEWAY_ROOT)
19-
20-
21-
# ────────────────────────────────────────────────────────────
22-
# Event loop(pytest-asyncio 需要)
23-
# ────────────────────────────────────────────────────────────
24-
25-
@pytest.fixture(scope="session")
26-
def event_loop():
27-
"""创建全局事件循环,供所有 async 测试共享"""
28-
loop = asyncio.new_event_loop()
29-
yield loop
30-
loop.close()
31-
32-
33-
# ────────────────────────────────────────────────────────────
34-
# Mock Provider
35-
# ────────────────────────────────────────────────────────────
36-
37-
class MockProvider:
38-
"""模拟 AI Provider,可自定义 generate 返回值"""
39-
40-
def __init__(self, name: str = "mock", response: dict | None = None):
41-
self.provider_name = name
42-
self.api_key = "mock-key"
43-
self._response = response or {
44-
"content": "这是模拟的 AI 响应内容",
45-
"tokens_used": 100,
46-
"model": "mock-model",
47-
"latency_ms": 50,
48-
}
49-
50-
async def generate(self, prompt, system_prompt="", model="", temperature=0.7,
51-
max_tokens=2048, response_format=None, **kwargs):
52-
return self._response.copy()
53-
54-
async def health_check(self):
55-
return {"status": "healthy", "latency_ms": 1.0, "error": None}
56-
57-
58-
class FailingProvider:
59-
"""总是抛出异常的 Provider,用于测试 fallback 链"""
60-
61-
def __init__(self, name: str = "failing"):
62-
self.provider_name = name
63-
self.api_key = "mock-key"
64-
65-
async def generate(self, *args, **kwargs):
66-
raise RuntimeError(f"Provider [{self.provider_name}] 模拟故障")
67-
68-
async def health_check(self):
69-
return {"status": "unhealthy", "latency_ms": 0, "error": "模拟故障"}
70-
71-
72-
@pytest.fixture
73-
def mock_provider():
74-
"""返回一个可用的 mock Provider"""
75-
return MockProvider()
76-
77-
78-
@pytest.fixture
79-
def failing_provider():
80-
"""返回一个总是失败的 Provider"""
81-
return FailingProvider()
82-
83-
84-
# ────────────────────────────────────────────────────────────
85-
# 测试用 FastAPI 应用(绕过 JWT / RateLimit 中间件)
86-
# ────────────────────────────────────────────────────────────
87-
88-
@pytest.fixture
89-
def test_app():
90-
"""
91-
创建精简版 FastAPI 应用,只注册路由,不挂中间件。
92-
在 app.state.providers 中注入 mock Provider。
93-
"""
94-
from fastapi import FastAPI
95-
from routers import (
96-
summarize_router,
97-
generate_cards_router,
98-
evaluate_router,
99-
recommend_router,
100-
)
101-
102-
app = FastAPI()
103-
104-
# 注入 mock providers(call_with_fallback 会从 app.state.providers 读取)
105-
mock = MockProvider(name="qwen")
106-
app.state.providers = {
107-
"qwen": mock,
108-
"deepseek": MockProvider(name="deepseek"),
109-
"glm": MockProvider(name="glm"),
110-
"fallback": mock,
111-
}
112-
113-
app.include_router(summarize_router)
114-
app.include_router(generate_cards_router)
115-
app.include_router(evaluate_router)
116-
app.include_router(recommend_router)
117-
118-
return app
119-
120-
121-
@pytest.fixture
122-
def client(test_app):
123-
"""同步 TestClient,用于路由测试"""
124-
from fastapi.testclient import TestClient
125-
return TestClient(test_app)
126-
127-
128-
# ────────────────────────────────────────────────────────────
129-
# 模拟 call_with_fallback(直接调用主 Provider,不走 fallback 链)
130-
# ────────────────────────────────────────────────────────────
131-
132-
@pytest.fixture
133-
def mock_call_with_fallback(monkeypatch):
134-
"""
135-
Patch config.call_with_fallback,直接调用第一个可用 Provider。
136-
返回 fixture 函数:调用后获得 patch 上下文。
137-
"""
138-
async def fake_call(app, feature, fn):
139-
provider = list(app.state.providers.values())[0]
140-
from config import MODEL_ROUTING, AI_PROVIDERS
141-
routing = MODEL_ROUTING.get(feature, ("fallback", "free"))
142-
model_name = AI_PROVIDERS.get(routing[0], {}).get("models", {}).get(routing[1], "mock-model")
143-
result = await fn(provider, model_name)
144-
return result, routing[0]
145-
146-
monkeypatch.setattr("config.call_with_fallback", fake_call_with_fallback)
147-
return fake_call_with_fallback
1+
"""
2+
熵减 AI 网关 — pytest 公共夹具
3+
4+
提供测试用的 FastAPI app、TestClient、mock Provider 等。
5+
"""
6+
7+
import sys
8+
import os
9+
import asyncio
10+
from pathlib import Path
11+
from unittest.mock import AsyncMock, MagicMock
12+
13+
import pytest
14+
15+
# 确保 ai-gateway 根目录在 sys.path 中,使 config/errors 等可导入
16+
GATEWAY_ROOT = str(Path(__file__).resolve().parent.parent)
17+
if GATEWAY_ROOT not in sys.path:
18+
sys.path.insert(0, GATEWAY_ROOT)
19+
20+
21+
# ────────────────────────────────────────────────────────────
22+
# Event loop(pytest-asyncio 需要)
23+
# ────────────────────────────────────────────────────────────
24+
25+
@pytest.fixture(scope="session")
26+
def event_loop():
27+
"""创建全局事件循环,供所有 async 测试共享"""
28+
loop = asyncio.new_event_loop()
29+
yield loop
30+
loop.close()
31+
32+
33+
# ────────────────────────────────────────────────────────────
34+
# Mock Provider
35+
# ────────────────────────────────────────────────────────────
36+
37+
class MockProvider:
38+
"""模拟 AI Provider,可自定义 generate 返回值"""
39+
40+
def __init__(self, name: str = "mock", response: dict | None = None):
41+
self.provider_name = name
42+
self.api_key = "mock-key"
43+
self._response = response or {
44+
"content": "这是模拟的 AI 响应内容",
45+
"tokens_used": 100,
46+
"model": "mock-model",
47+
"latency_ms": 50,
48+
}
49+
50+
async def generate(self, prompt, system_prompt="", model="", temperature=0.7,
51+
max_tokens=2048, response_format=None, **kwargs):
52+
return self._response.copy()
53+
54+
async def health_check(self):
55+
return {"status": "healthy", "latency_ms": 1.0, "error": None}
56+
57+
58+
class FailingProvider:
59+
"""总是抛出异常的 Provider,用于测试 fallback 链"""
60+
61+
def __init__(self, name: str = "failing"):
62+
self.provider_name = name
63+
self.api_key = "mock-key"
64+
65+
async def generate(self, *args, **kwargs):
66+
raise RuntimeError(f"Provider [{self.provider_name}] 模拟故障")
67+
68+
async def health_check(self):
69+
return {"status": "unhealthy", "latency_ms": 0, "error": "模拟故障"}
70+
71+
72+
@pytest.fixture
73+
def mock_provider():
74+
"""返回一个可用的 mock Provider"""
75+
return MockProvider()
76+
77+
78+
@pytest.fixture
79+
def failing_provider():
80+
"""返回一个总是失败的 Provider"""
81+
return FailingProvider()
82+
83+
84+
# ────────────────────────────────────────────────────────────
85+
# 测试用 FastAPI 应用(绕过 JWT / RateLimit 中间件)
86+
# ────────────────────────────────────────────────────────────
87+
88+
@pytest.fixture
89+
def test_app():
90+
"""
91+
创建精简版 FastAPI 应用,只注册路由,不挂中间件。
92+
在 app.state.providers 中注入 mock Provider。
93+
"""
94+
from fastapi import FastAPI
95+
from routers import (
96+
summarize_router,
97+
generate_cards_router,
98+
evaluate_router,
99+
recommend_router,
100+
)
101+
102+
app = FastAPI()
103+
104+
# 注入 mock providers(call_with_fallback 会从 app.state.providers 读取)
105+
mock = MockProvider(name="qwen")
106+
app.state.providers = {
107+
"qwen": mock,
108+
"deepseek": MockProvider(name="deepseek"),
109+
"glm": MockProvider(name="glm"),
110+
"fallback": mock,
111+
}
112+
113+
app.include_router(summarize_router)
114+
app.include_router(generate_cards_router)
115+
app.include_router(evaluate_router)
116+
app.include_router(recommend_router)
117+
118+
return app
119+
120+
121+
@pytest.fixture
122+
def client(test_app):
123+
"""同步 TestClient,用于路由测试"""
124+
from fastapi.testclient import TestClient
125+
return TestClient(test_app)
126+
127+
128+
# ────────────────────────────────────────────────────────────
129+
# 模拟 call_with_fallback(直接调用主 Provider,不走 fallback 链)
130+
# ────────────────────────────────────────────────────────────
131+
132+
@pytest.fixture
133+
def mock_call_with_fallback(monkeypatch):
134+
"""
135+
Patch config.call_with_fallback,直接调用第一个可用 Provider。
136+
返回 fixture 函数:调用后获得 patch 上下文。
137+
"""
138+
async def fake_call(app, feature, fn):
139+
provider = list(app.state.providers.values())[0]
140+
from config import MODEL_ROUTING, AI_PROVIDERS
141+
routing = MODEL_ROUTING.get(feature, ("fallback", "free"))
142+
model_name = AI_PROVIDERS.get(routing[0], {}).get("models", {}).get(routing[1], "mock-model")
143+
result = await fn(provider, model_name)
144+
return result, routing[0]
145+
146+
monkeypatch.setattr("config.call_with_fallback", fake_call)
147+
return fake_call

0 commit comments

Comments
 (0)