[阶段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

@@ -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)},
}