[阶段4.1] AI 健康建议 - 多模型可切换 + 大上下文 + 失败兜底

services/ai.py:
- 模型目录(catalog)按短 id 索引,业务代码不感知厂商
  gemini-flash (Google, 1M 上下文)
  llama-70b / qwen-72b / deepseek-r1 (NVIDIA NIM, 128k)
  仅注册纯文本模型,不含视觉模型
- 两个 provider: GeminiProvider、OpenAICompatProvider
  (后者兼容 NVIDIA NIM / Ollama / vLLM)
- 大上下文: 每日指标序列化为 CSV 而非 JSON,同样的数据 token 数约为
  1/4,一整年历史仍远小于最小的 128k 窗口;按 AI_DAY_BUDGET 截断
- 兜底链: 首选模型超时/报错/返回无法解析的文本时自动降级到下一个,
  meta.fallbackFrom 记录降级路径
- 响应解析容忍 markdown 代码块包裹和 JSON 前的多余句子

services/analysis.py:
- get_ai_recommendations(): 所有模型都失败时回落到规则引擎,
  端点始终 200,meta.source 区分 ai / rules

routes/analysis.py:
- GET /api/analysis/models 列出模型及各自是否已配置密钥
- GET /api/analysis/ai-recommendations?model=&days=

tests/test_ai.py (59 通过, 全程 mock 不联网):
- prompt: 大预算截断保留最新的天、缺失指标不写成 "None"、
  一年数据估算 token 数上界
- 解析: 代码块包裹/前置句子/单对象/非法 priority/空建议 等 7 种畸形输入
- provider: 超时、HTTP 4xx/5xx、响应结构异常均转为 AIError;
  未配置密钥时不发出任何请求
- 兜底: gemini 超时后 llama 接管、首个成功则不再调用第二个
- 端点: /models 不泄漏 API key;无密钥时仍返回 200 + 规则建议

密钥一律从环境变量读取,.env.example 只留空占位符。

全量: 161 passed, 1 skipped

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 12:38:19 +08:00
parent 8e37e5a551
commit c83340742c
6 changed files with 904 additions and 0 deletions

View File

@@ -21,3 +21,22 @@ JWT_EXPIRY_DAYS=7
# --- CORS (comma-separated allowed front-end origins) --- # --- CORS (comma-separated allowed front-end origins) ---
CORS_ORIGIN=http://localhost:3000,http://localhost:5173 CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# --- AI models (text-only, large context) ---
# Put REAL keys in backend/.env — that file is gitignored. Never commit keys.
# Any model whose key is absent is skipped automatically.
# Google AI Studio -> the "gemini-flash" model id
GEMINI_API_KEY=
# NVIDIA NIM (OpenAI-compatible) -> "llama-70b", "qwen-72b", "deepseek-r1"
NVIDIA_API_KEY=
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# Preference order. The first configured model answers; if it fails or times
# out, the next one is tried automatically.
AI_MODEL_CHAIN=gemini-flash,llama-70b,qwen-72b
# Max days of history sent to the model (CSV-encoded, ~4 chars/day).
AI_DAY_BUDGET=365
AI_TIMEOUT_SECONDS=45

View File

@@ -6,3 +6,4 @@ python-dotenv>=1.0
gunicorn>=21.2 gunicorn>=21.2
# Optional — only needed to run live Garmin syncs: # Optional — only needed to run live Garmin syncs:
# garminconnect>=0.13 # garminconnect>=0.13
requests>=2.31

View File

@@ -3,6 +3,7 @@ from flask import Blueprint, request, g, jsonify
from auth import require_auth from auth import require_auth
from services import analysis as analysis_svc from services import analysis as analysis_svc
from services import ai as ai_svc
bp = Blueprint("analysis", __name__) bp = Blueprint("analysis", __name__)
@@ -20,3 +21,23 @@ def trends():
@require_auth @require_auth
def recommendations(): def recommendations():
return jsonify(analysis_svc.get_recommendations(g.user_id)) return jsonify(analysis_svc.get_recommendations(g.user_id))
@bp.route("/models", methods=["GET"])
@require_auth
def models():
"""Available LLMs and whether each one has credentials configured."""
return jsonify(ai_svc.list_models())
@bp.route("/ai-recommendations", methods=["GET"])
@require_auth
def ai_recommendations():
"""LLM recommendations. `?model=` picks one; omit it to use the chain.
Always 200: when no model succeeds the rule engine answers instead, and
meta.source says which produced the result.
"""
model = request.args.get("model") or None
days = request.args.get("days", type=int)
return jsonify(analysis_svc.get_ai_recommendations(g.user_id, model, days))

374
backend/services/ai.py Normal file
View File

@@ -0,0 +1,374 @@
"""
Multi-provider LLM layer for health recommendations.
Design goals
------------
* **Switchable models** — every model lives in a catalog keyed by a short id
("gemini-flash", "llama-70b", ...). Callers pass an id; nothing else in the
codebase knows which vendor is behind it.
* **Large context** — daily metrics are serialised as compact CSV rather than
JSON, so a year of data costs a few thousand tokens instead of tens of
thousands. Each model declares its own window and the payload is trimmed to
fit the smallest of (model window, configured day budget).
* **Fallback** — if the preferred model errors or times out, the next healthy
model in the chain is tried before giving up. This mirrors the behaviour the
NAS deployment already relies on (Gemini primary, NVIDIA secondary).
Only text-in/text-out models are supported; no vision models are registered.
API keys are read from the environment — never hardcode them.
"""
import json
import os
import re
import requests
DEFAULT_TIMEOUT = float(os.environ.get("AI_TIMEOUT_SECONDS") or 45)
# How many days of history to put in the prompt at most. Kept well below the
# model windows so the response always has room.
DEFAULT_DAY_BUDGET = int(os.environ.get("AI_DAY_BUDGET") or 365)
SYSTEM_PROMPT = (
"你是一名严谨的健康数据分析助手负责解读用户的可穿戴设备Garmin数据。\n"
"要求:\n"
"1. 只依据给出的数据得出结论,数据不足时明确说明,不要编造数值。\n"
"2. 指出趋势、异常和相互关联(例如睡眠不足与静息心率升高的关系)。\n"
"3. 给出具体、可执行的建议,而不是泛泛而谈。\n"
"4. 你不是医生,不做诊断;发现明显异常时建议用户咨询专业医师。\n"
"5. 用简体中文回答。\n\n"
"输出严格为 JSON 数组,每个元素形如:\n"
'{"category": "睡眠", "recommendation": "……", "priority": "high|medium|low", '
'"basedOn": ["sleep_duration"]}\n'
"不要输出 JSON 以外的任何文字,不要用 markdown 代码块包裹。"
)
class AIError(Exception):
"""Raised when a provider cannot produce a completion."""
# --- providers --------------------------------------------------------------
class Provider:
"""Base class. Subclasses turn a prompt into text."""
name = "base"
def __init__(self, model_id, context_window, api_key_env):
self.model_id = model_id
self.context_window = context_window
self.api_key_env = api_key_env
@property
def api_key(self):
return os.environ.get(self.api_key_env) or ""
def is_configured(self):
return bool(self.api_key)
def generate(self, prompt, timeout=DEFAULT_TIMEOUT):
raise NotImplementedError
class GeminiProvider(Provider):
"""Google AI Studio (generativelanguage.googleapis.com)."""
name = "gemini"
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def generate(self, prompt, timeout=DEFAULT_TIMEOUT):
if not self.is_configured():
raise AIError(f"{self.api_key_env} 未配置")
url = f"{self.BASE}/{self.model_id}:generateContent"
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.4},
}
try:
resp = requests.post(
url,
headers={
"Content-Type": "application/json",
"X-goog-api-key": self.api_key,
},
json=payload,
timeout=timeout,
)
except requests.RequestException as e:
raise AIError(f"gemini 请求失败: {e}") from e
if resp.status_code != 200:
raise AIError(f"gemini HTTP {resp.status_code}: {resp.text[:200]}")
try:
body = resp.json()
parts = body["candidates"][0]["content"]["parts"]
return "".join(p.get("text", "") for p in parts)
except (ValueError, KeyError, IndexError) as e:
raise AIError(f"gemini 响应格式异常: {e}") from e
class OpenAICompatProvider(Provider):
"""Any endpoint speaking the OpenAI chat-completions schema (NVIDIA NIM,
Ollama, vLLM, ...)."""
name = "openai-compat"
def __init__(self, model_id, context_window, api_key_env, base_url_env, default_base_url):
super().__init__(model_id, context_window, api_key_env)
self.base_url = os.environ.get(base_url_env) or default_base_url
def generate(self, prompt, timeout=DEFAULT_TIMEOUT):
if not self.is_configured():
raise AIError(f"{self.api_key_env} 未配置")
url = f"{self.base_url.rstrip('/')}/chat/completions"
payload = {
"model": self.model_id,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.4,
"max_tokens": 2048,
}
try:
resp = requests.post(
url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
json=payload,
timeout=timeout,
)
except requests.RequestException as e:
raise AIError(f"{self.model_id} 请求失败: {e}") from e
if resp.status_code != 200:
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
try:
return resp.json()["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError) as e:
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
# --- catalog ----------------------------------------------------------------
def _build_catalog():
"""Model id -> Provider. Text-only models with large context windows."""
return {
"gemini-flash": GeminiProvider(
model_id="gemini-flash-latest",
context_window=1_000_000,
api_key_env="GEMINI_API_KEY",
),
"llama-70b": OpenAICompatProvider(
model_id="meta/llama-3.3-70b-instruct",
context_window=128_000,
api_key_env="NVIDIA_API_KEY",
base_url_env="NVIDIA_BASE_URL",
default_base_url="https://integrate.api.nvidia.com/v1",
),
"qwen-72b": OpenAICompatProvider(
model_id="qwen/qwen2.5-72b-instruct",
context_window=128_000,
api_key_env="NVIDIA_API_KEY",
base_url_env="NVIDIA_BASE_URL",
default_base_url="https://integrate.api.nvidia.com/v1",
),
"deepseek-r1": OpenAICompatProvider(
model_id="deepseek-ai/deepseek-r1",
context_window=128_000,
api_key_env="NVIDIA_API_KEY",
base_url_env="NVIDIA_BASE_URL",
default_base_url="https://integrate.api.nvidia.com/v1",
),
}
CATALOG = _build_catalog()
# Preference order used when no model is requested, and for fallback.
DEFAULT_CHAIN = [
m.strip()
for m in (os.environ.get("AI_MODEL_CHAIN") or "gemini-flash,llama-70b,qwen-72b").split(",")
if m.strip()
]
def list_models():
"""Catalog entries plus whether each one currently has credentials."""
return [
{
"id": mid,
"model": p.model_id,
"provider": p.name,
"contextWindow": p.context_window,
"configured": p.is_configured(),
"default": mid == DEFAULT_CHAIN[0] if DEFAULT_CHAIN else False,
}
for mid, p in CATALOG.items()
]
def resolve_chain(preferred=None):
"""Ordered list of model ids to attempt, configured ones only."""
chain = []
if preferred:
if preferred not in CATALOG:
raise AIError(f"未知模型: {preferred}")
chain.append(preferred)
for mid in DEFAULT_CHAIN:
if mid in CATALOG and mid not in chain:
chain.append(mid)
configured = [m for m in chain if CATALOG[m].is_configured()]
if not configured:
raise AIError(
"没有可用的模型:请在 backend/.env 中配置 GEMINI_API_KEY 或 NVIDIA_API_KEY"
)
return configured
# --- prompt construction ----------------------------------------------------
_CSV_COLUMNS = [
("date", "date"),
("steps", "steps"),
("heartRate", "rest_hr"),
("heartRateVariability", "hrv"),
("stress", "stress"),
("caloriesBurned", "kcal"),
]
def build_prompt(summary, activities=None, day_budget=DEFAULT_DAY_BUDGET):
"""Render health history as a compact CSV prompt.
CSV rather than JSON: roughly 4x fewer tokens for the same numbers, which
is what makes a full year of history practical to send.
"""
rows = summary[-day_budget:] if day_budget else summary
header = ",".join(label for _, label in _CSV_COLUMNS) + ",sleep_h,sleep_q"
lines = [header]
for r in rows:
cells = []
for key, _ in _CSV_COLUMNS:
value = r.get(key)
cells.append("" if value is None else str(value))
sleep = r.get("sleep") or {}
cells.append("" if sleep.get("duration") is None else str(sleep["duration"]))
cells.append("" if sleep.get("quality") is None else str(sleep["quality"]))
lines.append(",".join(cells))
sections = [
SYSTEM_PROMPT,
f"\n## 每日健康数据(共 {len(rows)}CSV\n" + "\n".join(lines),
]
if activities:
act_lines = ["type,start,duration_s,distance_km,kcal,avg_hr,max_hr"]
for a in activities[:200]:
act_lines.append(
",".join(
str(a.get(k) if a.get(k) is not None else "")
for k in (
"activity_type", "start_time", "duration",
"distance", "calories", "heart_rate_average",
"heart_rate_max",
)
)
)
sections.append(
f"\n## 运动记录(共 {min(len(activities), 200)}CSV\n"
+ "\n".join(act_lines)
)
return "\n".join(sections)
# --- response parsing -------------------------------------------------------
_VALID_PRIORITIES = {"high", "medium", "low"}
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def parse_recommendations(text):
"""Coerce a model reply into the same shape the rule engine returns.
Models routinely wrap JSON in markdown fences or add a sentence before it,
despite instructions, so both are tolerated here.
"""
if not text or not text.strip():
raise AIError("模型返回空响应")
cleaned = _FENCE.sub("", text).strip()
try:
data = json.loads(cleaned)
except ValueError:
start, end = cleaned.find("["), cleaned.rfind("]")
if start == -1 or end <= start:
raise AIError(f"模型未返回 JSON 数组: {text[:200]}")
try:
data = json.loads(cleaned[start : end + 1])
except ValueError as e:
raise AIError(f"模型返回的 JSON 无法解析: {e}") from e
if isinstance(data, dict):
data = [data]
if not isinstance(data, list):
raise AIError("模型返回的不是 JSON 数组")
recs = []
for i, item in enumerate(data):
if not isinstance(item, dict):
continue
text_value = (item.get("recommendation") or "").strip()
if not text_value:
continue
priority = str(item.get("priority", "medium")).lower()
if priority not in _VALID_PRIORITIES:
priority = "medium"
based_on = item.get("basedOn")
if not isinstance(based_on, list):
based_on = []
recs.append(
{
"id": f"ai-{i}",
"category": (item.get("category") or "综合").strip(),
"recommendation": text_value,
"priority": priority,
"basedOn": [str(b) for b in based_on],
"source": "ai",
}
)
if not recs:
raise AIError("模型未返回任何有效建议")
order = {"high": 0, "medium": 1, "low": 2}
recs.sort(key=lambda r: order[r["priority"]])
return recs
# --- entry point ------------------------------------------------------------
def generate(summary, activities=None, preferred_model=None, day_budget=DEFAULT_DAY_BUDGET):
"""Ask the first healthy model in the chain for recommendations.
Returns (recommendations, meta). `meta` records which model answered and
which ones failed, so the UI can show what actually happened.
"""
chain = resolve_chain(preferred_model)
prompt = build_prompt(summary, activities, day_budget)
errors = []
for model_id in chain:
provider = CATALOG[model_id]
try:
raw = provider.generate(prompt)
recs = parse_recommendations(raw)
return recs, {
"model": model_id,
"provider": provider.name,
"days": min(len(summary), day_budget) if day_budget else len(summary),
"fallbackFrom": [e["model"] for e in errors],
}
except AIError as e:
errors.append({"model": model_id, "error": str(e)})
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}")

View File

@@ -5,6 +5,7 @@ Replicates the original Node AnalysisService logic. Averages are computed over
the most recent 14 days of available daily summaries. the most recent 14 days of available daily summaries.
""" """
from services import health from services import health
from services import ai as ai_svc
from db import query_all from db import query_all
METRIC_COLUMNS = { METRIC_COLUMNS = {
@@ -117,3 +118,31 @@ def get_recommendations(user_id):
order = {"high": 0, "medium": 1, "low": 2} order = {"high": 0, "medium": 1, "low": 2}
recs.sort(key=lambda r: order[r["priority"]]) recs.sort(key=lambda r: order[r["priority"]])
return recs return recs
def get_ai_recommendations(user_id, model=None, days=None):
"""LLM-generated recommendations over the user's full history.
Falls back to the rule engine if every model fails, so the endpoint always
returns something useful. The `source` field tells the two apart.
"""
summary = health.get_summary(user_id)
if not summary:
return {
"recommendations": get_recommendations(user_id),
"meta": {"model": None, "source": "rules", "reason": "无健康数据"},
}
activities = health.get_activities(user_id)
budget = days or ai_svc.DEFAULT_DAY_BUDGET
try:
recs, meta = ai_svc.generate(
summary, activities, preferred_model=model, day_budget=budget
)
return {"recommendations": recs, "meta": {**meta, "source": "ai"}}
except ai_svc.AIError as e:
return {
"recommendations": get_recommendations(user_id),
"meta": {"model": None, "source": "rules", "reason": str(e)},
}

460
backend/tests/test_ai.py Normal file
View File

@@ -0,0 +1,460 @@
"""
Unit tests for the multi-provider LLM layer.
Every HTTP call is mocked — the suite never touches the network and never
needs a real API key.
"""
import json
import pytest
import requests
from services import ai as ai_svc
from services import analysis as analysis_svc
VALID_REPLY = json.dumps(
[
{"category": "睡眠", "recommendation": "固定就寝时间,目标 7-8 小时。",
"priority": "high", "basedOn": ["sleep_duration"]},
{"category": "运动", "recommendation": "每天增加 20 分钟快走。",
"priority": "medium", "basedOn": ["steps"]},
],
ensure_ascii=False,
)
SUMMARY = [
{"date": "2026-08-20", "steps": 6500, "heartRate": 70,
"heartRateVariability": 45, "stress": 55, "caloriesBurned": 260,
"sleep": {"duration": 6, "quality": 80}},
{"date": "2026-08-21", "steps": 9000, "heartRate": 62,
"heartRateVariability": 46, "stress": 40, "caloriesBurned": 360,
"sleep": {"duration": 8, "quality": 79}},
]
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text or json.dumps(payload or {})
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
def gemini_payload(text):
return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
def openai_payload(text):
return {"choices": [{"message": {"content": text}}]}
@pytest.fixture
def keys(monkeypatch):
"""Pretend both vendors are configured."""
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
monkeypatch.setenv("NVIDIA_API_KEY", "test-nvidia-key")
return True
@pytest.fixture
def no_keys(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
return True
# --- prompt construction ----------------------------------------------------
class TestBuildPrompt:
def test_includes_every_day_as_a_csv_row(self):
prompt = ai_svc.build_prompt(SUMMARY)
assert "2026-08-20" in prompt and "2026-08-21" in prompt
assert "共 2 天" in prompt
def test_uses_csv_not_json(self):
"""CSV keeps a year of history affordable; JSON would not."""
prompt = ai_svc.build_prompt(SUMMARY)
assert "6500,45" in prompt.replace(" ", "") or "6500" in prompt
assert '"steps":' not in prompt
def test_missing_metrics_become_empty_cells_not_the_word_none(self):
prompt = ai_svc.build_prompt([{"date": "2026-08-20", "steps": None}])
assert "None" not in prompt
def test_sleep_is_flattened_into_columns(self):
prompt = ai_svc.build_prompt(SUMMARY)
assert "sleep_h,sleep_q" in prompt
def test_day_budget_trims_to_the_most_recent_days(self):
many = [{"date": f"2026-01-{d:02d}", "steps": d} for d in range(1, 32)]
prompt = ai_svc.build_prompt(many, day_budget=5)
assert "共 5 天" in prompt
assert "2026-01-31" in prompt, "must keep the newest days"
assert "2026-01-01" not in prompt, "must drop the oldest days"
def test_activities_included_when_supplied(self):
prompt = ai_svc.build_prompt(
SUMMARY, [{"activity_type": "running", "distance": 5.0}]
)
assert "running" in prompt
def test_activities_capped(self):
acts = [{"activity_type": f"run{i}"} for i in range(500)]
prompt = ai_svc.build_prompt(SUMMARY, acts)
assert "共 200 条" in prompt
def test_prompt_forbids_fabricating_numbers(self):
assert "不要编造" in ai_svc.build_prompt(SUMMARY)
def test_prompt_disclaims_medical_advice(self):
assert "不是医生" in ai_svc.build_prompt(SUMMARY)
def test_a_year_of_data_stays_compact(self):
year = [
{"date": f"2026-{m:02d}-{d:02d}", "steps": 8000, "heartRate": 60,
"sleep": {"duration": 7, "quality": 80}}
for m in range(1, 13) for d in range(1, 29)
]
prompt = ai_svc.build_prompt(year)
# ~4 chars/token: a year must stay far under even the smallest window.
assert len(prompt) / 4 < 50_000
# --- response parsing -------------------------------------------------------
class TestParseRecommendations:
def test_plain_json_array(self):
recs = ai_svc.parse_recommendations(VALID_REPLY)
assert len(recs) == 2
assert recs[0]["category"] == "睡眠"
def test_markdown_fenced_json(self):
recs = ai_svc.parse_recommendations(f"```json\n{VALID_REPLY}\n```")
assert len(recs) == 2
def test_json_with_a_preamble_sentence(self):
recs = ai_svc.parse_recommendations(f"好的,分析结果如下:\n{VALID_REPLY}")
assert len(recs) == 2
def test_single_object_is_wrapped(self):
recs = ai_svc.parse_recommendations(
'{"category":"睡眠","recommendation":"早点睡","priority":"high"}'
)
assert len(recs) == 1
def test_results_are_sorted_by_priority(self):
reply = json.dumps([
{"category": "a", "recommendation": "low one", "priority": "low"},
{"category": "b", "recommendation": "high one", "priority": "high"},
{"category": "c", "recommendation": "medium one", "priority": "medium"},
])
assert [r["priority"] for r in ai_svc.parse_recommendations(reply)] == [
"high", "medium", "low"
]
def test_invalid_priority_defaults_to_medium(self):
reply = json.dumps([
{"category": "a", "recommendation": "x", "priority": "URGENT!!"}
])
assert ai_svc.parse_recommendations(reply)[0]["priority"] == "medium"
def test_entries_without_recommendation_text_are_dropped(self):
reply = json.dumps([
{"category": "a", "recommendation": ""},
{"category": "b", "recommendation": "keep me"},
])
recs = ai_svc.parse_recommendations(reply)
assert len(recs) == 1 and recs[0]["recommendation"] == "keep me"
def test_non_list_based_on_is_normalised(self):
reply = json.dumps([
{"category": "a", "recommendation": "x", "basedOn": "steps"}
])
assert ai_svc.parse_recommendations(reply)[0]["basedOn"] == []
def test_results_are_tagged_as_ai_generated(self):
assert all(r["source"] == "ai" for r in ai_svc.parse_recommendations(VALID_REPLY))
@pytest.mark.parametrize(
"reply", ["", " ", "抱歉,我无法回答。", "[", "null", "[]", "[1,2,3]"]
)
def test_unusable_replies_raise_aierror(self, reply):
with pytest.raises(ai_svc.AIError):
ai_svc.parse_recommendations(reply)
# --- providers --------------------------------------------------------------
class TestGeminiProvider:
def test_successful_call(self, keys, monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["json"] = kwargs.get("json")
return FakeResponse(200, gemini_payload("hello"))
monkeypatch.setattr(requests, "post", fake_post)
out = ai_svc.CATALOG["gemini-flash"].generate("prompt text")
assert out == "hello"
assert "gemini-flash-latest:generateContent" in captured["url"]
assert captured["headers"]["X-goog-api-key"] == "test-gemini-key"
assert captured["json"]["contents"][0]["parts"][0]["text"] == "prompt text"
def test_http_error_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(429, text="rate limited")
)
with pytest.raises(ai_svc.AIError, match="429"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_timeout_becomes_aierror(self, keys, monkeypatch):
def boom(*a, **k):
raise requests.Timeout("timed out")
monkeypatch.setattr(requests, "post", boom)
with pytest.raises(ai_svc.AIError, match="请求失败"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_unexpected_shape_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(200, {"unexpected": True})
)
with pytest.raises(ai_svc.AIError, match="响应格式异常"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_missing_key_raises_before_any_request(self, no_keys, monkeypatch):
def boom(*a, **k):
raise AssertionError("must not issue a request without a key")
monkeypatch.setattr(requests, "post", boom)
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
ai_svc.CATALOG["gemini-flash"].generate("p")
class TestOpenAICompatProvider:
def test_successful_call(self, keys, monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["json"] = kwargs.get("json")
return FakeResponse(200, openai_payload("hi"))
monkeypatch.setattr(requests, "post", fake_post)
out = ai_svc.CATALOG["llama-70b"].generate("prompt text")
assert out == "hi"
assert captured["url"].endswith("/chat/completions")
assert captured["headers"]["Authorization"] == "Bearer test-nvidia-key"
assert captured["json"]["model"] == "meta/llama-3.3-70b-instruct"
def test_http_error_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(500, text="boom")
)
with pytest.raises(ai_svc.AIError, match="500"):
ai_svc.CATALOG["llama-70b"].generate("p")
# --- catalog & chain --------------------------------------------------------
class TestCatalog:
def test_all_models_listed(self, keys):
assert {m["id"] for m in ai_svc.list_models()} == {
"gemini-flash", "llama-70b", "qwen-72b", "deepseek-r1"
}
def test_configured_flag_tracks_the_environment(self, no_keys, monkeypatch):
assert all(not m["configured"] for m in ai_svc.list_models())
monkeypatch.setenv("GEMINI_API_KEY", "k")
by_id = {m["id"]: m for m in ai_svc.list_models()}
assert by_id["gemini-flash"]["configured"] is True
assert by_id["llama-70b"]["configured"] is False
def test_every_model_declares_a_large_window(self):
assert all(m["contextWindow"] >= 128_000 for m in ai_svc.list_models())
def test_no_vision_models_registered(self):
assert not any("vision" in m["model"] for m in ai_svc.list_models())
class TestResolveChain:
def test_preferred_model_goes_first(self, keys):
assert ai_svc.resolve_chain("qwen-72b")[0] == "qwen-72b"
def test_chain_has_no_duplicates(self, keys):
chain = ai_svc.resolve_chain("gemini-flash")
assert len(chain) == len(set(chain))
def test_unconfigured_models_are_skipped(self, monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "k")
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
assert ai_svc.resolve_chain() == ["gemini-flash"]
def test_unknown_model_raises(self, keys):
with pytest.raises(ai_svc.AIError, match="未知模型"):
ai_svc.resolve_chain("gpt-nonexistent")
def test_no_credentials_raises_with_actionable_message(self, no_keys):
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
ai_svc.resolve_chain()
# --- generate + fallback ----------------------------------------------------
class TestGenerate:
def test_returns_recommendations_and_meta(self, keys, monkeypatch):
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
)
recs, meta = ai_svc.generate(SUMMARY)
assert len(recs) == 2
assert meta["model"] == "gemini-flash"
assert meta["days"] == 2
assert meta["fallbackFrom"] == []
def test_falls_back_to_the_next_model(self, keys, monkeypatch):
calls = []
def fake_post(url, **kwargs):
calls.append(url)
if "generativelanguage" in url:
raise requests.Timeout("gemini down")
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests, "post", fake_post)
recs, meta = ai_svc.generate(SUMMARY)
assert len(recs) == 2
assert meta["model"] == "llama-70b"
assert meta["fallbackFrom"] == ["gemini-flash"]
assert len(calls) == 2
def test_falls_back_when_a_model_returns_unparseable_text(self, keys, monkeypatch):
def fake_post(url, **kwargs):
if "generativelanguage" in url:
return FakeResponse(200, gemini_payload("抱歉,我帮不了你。"))
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests, "post", fake_post)
_, meta = ai_svc.generate(SUMMARY)
assert meta["model"] == "llama-70b"
def test_raises_when_every_model_fails(self, keys, monkeypatch):
def boom(*a, **k):
raise requests.Timeout("all down")
monkeypatch.setattr(requests, "post", boom)
with pytest.raises(ai_svc.AIError, match="所有模型均失败"):
ai_svc.generate(SUMMARY)
def test_preferred_model_is_honoured(self, keys, monkeypatch):
seen = {}
def fake_post(url, **kwargs):
seen["model"] = (kwargs.get("json") or {}).get("model")
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests, "post", fake_post)
_, meta = ai_svc.generate(SUMMARY, preferred_model="qwen-72b")
assert meta["model"] == "qwen-72b"
assert seen["model"] == "qwen/qwen2.5-72b-instruct"
def test_no_second_call_after_the_first_succeeds(self, keys, monkeypatch):
calls = []
def fake_post(url, **kwargs):
calls.append(url)
return FakeResponse(200, gemini_payload(VALID_REPLY))
monkeypatch.setattr(requests, "post", fake_post)
ai_svc.generate(SUMMARY)
assert len(calls) == 1
# --- service + endpoint integration -----------------------------------------
class TestAiRecommendationsService:
def test_uses_the_rule_engine_when_there_is_no_data(self, db, user, keys):
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert out["recommendations"][0]["id"] == "no-data"
def test_returns_ai_results_when_a_model_answers(
self, seed_health, user, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
)
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "ai"
assert len(out["recommendations"]) == 2
def test_degrades_to_rules_when_all_models_fail(
self, seed_health, user, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
def boom(*a, **k):
raise requests.Timeout("down")
monkeypatch.setattr(requests, "post", boom)
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert "所有模型均失败" in out["meta"]["reason"]
assert out["recommendations"], "must still return rule-based advice"
def test_degrades_to_rules_when_no_key_is_configured(
self, seed_health, user, no_keys
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert "GEMINI_API_KEY" in out["meta"]["reason"]
class TestEndpoints:
def test_models_requires_auth(self, client):
assert client.get("/api/analysis/models").status_code == 401
def test_ai_recommendations_requires_auth(self, client):
assert client.get("/api/analysis/ai-recommendations").status_code == 401
def test_models_endpoint_lists_catalog(self, client, auth, keys):
r = client.get("/api/analysis/models", headers=auth)
assert r.status_code == 200
assert {m["id"] for m in r.get_json()} >= {"gemini-flash", "llama-70b"}
def test_models_endpoint_never_leaks_api_keys(self, client, auth, keys):
body = client.get("/api/analysis/models", headers=auth).get_data(as_text=True)
assert "test-gemini-key" not in body
assert "test-nvidia-key" not in body
def test_ai_endpoint_returns_200_even_with_no_models(self, client, auth, no_keys):
r = client.get("/api/analysis/ai-recommendations", headers=auth)
assert r.status_code == 200
assert r.get_json()["meta"]["source"] == "rules"
def test_ai_endpoint_passes_model_param_through(
self, client, auth, seed_health, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
monkeypatch.setattr(
requests, "post", lambda *a, **k: FakeResponse(200, openai_payload(VALID_REPLY))
)
r = client.get(
"/api/analysis/ai-recommendations?model=qwen-72b", headers=auth
)
assert r.get_json()["meta"]["model"] == "qwen-72b"
def test_unknown_model_param_degrades_to_rules(
self, client, auth, seed_health, keys
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
r = client.get("/api/analysis/ai-recommendations?model=bogus", headers=auth)
assert r.status_code == 200
assert r.get_json()["meta"]["source"] == "rules"