[阶段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:
374
backend/services/ai.py
Normal file
374
backend/services/ai.py
Normal 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}")
|
||||
@@ -5,6 +5,7 @@ Replicates the original Node AnalysisService logic. Averages are computed over
|
||||
the most recent 14 days of available daily summaries.
|
||||
"""
|
||||
from services import health
|
||||
from services import ai as ai_svc
|
||||
from db import query_all
|
||||
|
||||
METRIC_COLUMNS = {
|
||||
@@ -117,3 +118,31 @@ def get_recommendations(user_id):
|
||||
order = {"high": 0, "medium": 1, "low": 2}
|
||||
recs.sort(key=lambda r: order[r["priority"]])
|
||||
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)},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user