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>
149 lines
4.9 KiB
Python
149 lines
4.9 KiB
Python
"""
|
||
Analysis service: metric trends + a rule-based recommendation engine.
|
||
|
||
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 = {
|
||
"steps": "steps",
|
||
"heart_rate": "heart_rate",
|
||
"sleep_duration": "sleep_duration",
|
||
"sleep_quality": "sleep_quality",
|
||
"stress": "stress",
|
||
"calories_burned": "calories_burned",
|
||
}
|
||
|
||
|
||
def get_trends(metric, user_id, start=None, end=None):
|
||
column = METRIC_COLUMNS.get(metric, "steps")
|
||
params = [user_id]
|
||
sql = "WHERE user_id = ?"
|
||
if start:
|
||
sql += " AND date >= ?"
|
||
params.append(start)
|
||
if end:
|
||
sql += " AND date <= ?"
|
||
params.append(end)
|
||
rows = query_all(
|
||
f"SELECT date, {column} AS value FROM health_data {sql} "
|
||
f"AND {column} IS NOT NULL ORDER BY date ASC",
|
||
params,
|
||
)
|
||
return [{"date": r["date"], "value": r["value"]} for r in rows]
|
||
|
||
|
||
def get_recommendations(user_id):
|
||
recent = health.get_summary(user_id)
|
||
last14 = recent[-14:]
|
||
recs = []
|
||
|
||
if not last14:
|
||
return [
|
||
{
|
||
"id": "no-data",
|
||
"category": "数据",
|
||
"recommendation": "暂无健康数据,请先同步你的 Garmin 设备数据。",
|
||
"priority": "low",
|
||
"basedOn": [],
|
||
}
|
||
]
|
||
|
||
avg = lambda key: sum((r.get(key) or 0) for r in last14) / len(last14)
|
||
|
||
avg_steps = avg("steps")
|
||
sleep_rows = [r["sleep"]["duration"] for r in last14 if r.get("sleep")]
|
||
avg_sleep = sum(sleep_rows) / len(sleep_rows) if sleep_rows else 0
|
||
avg_stress = avg("stress")
|
||
avg_rhr = avg("heartRate")
|
||
avg_hrv = avg("heartRateVariability")
|
||
|
||
if avg_steps > 0 and avg_steps < 8000:
|
||
recs.append({
|
||
"id": "steps",
|
||
"category": "运动",
|
||
"recommendation": f"近 {len(last14)} 天日均步数约 {round(avg_steps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。",
|
||
"priority": "medium",
|
||
"basedOn": ["steps"],
|
||
})
|
||
|
||
if avg_sleep > 0 and avg_sleep < 7:
|
||
recs.append({
|
||
"id": "sleep",
|
||
"category": "睡眠",
|
||
"recommendation": f"日均睡眠约 {avg_sleep:.1f} 小时,偏少。建议固定就寝时间,目标 7-8 小时。",
|
||
"priority": "high",
|
||
"basedOn": ["sleep_duration"],
|
||
})
|
||
|
||
if avg_stress > 0 and avg_stress > 50:
|
||
recs.append({
|
||
"id": "stress",
|
||
"category": "压力",
|
||
"recommendation": f"平均压力指数 {round(avg_stress)} 偏高,建议安排放松活动(冥想/散步)。",
|
||
"priority": "high",
|
||
"basedOn": ["stress"],
|
||
})
|
||
|
||
if avg_rhr > 0 and avg_rhr > 65:
|
||
recs.append({
|
||
"id": "rhr",
|
||
"category": "心肺",
|
||
"recommendation": f"静息心率约 {round(avg_rhr)} bpm 偏高,规律有氧运动有助于改善心肺功能。",
|
||
"priority": "medium",
|
||
"basedOn": ["heart_rate"],
|
||
})
|
||
|
||
if avg_hrv > 0 and avg_hrv < 40:
|
||
recs.append({
|
||
"id": "hrv",
|
||
"category": "恢复",
|
||
"recommendation": f"心率变异性(HRV)约 {round(avg_hrv)} ms 偏低,注意恢复与休息,避免过度训练。",
|
||
"priority": "low",
|
||
"basedOn": ["heart_rate_variability"],
|
||
})
|
||
|
||
if not recs:
|
||
recs.append({
|
||
"id": "good",
|
||
"category": "状态",
|
||
"recommendation": "近期各项指标良好,保持当前作息与运动习惯即可。",
|
||
"priority": "low",
|
||
"basedOn": [],
|
||
})
|
||
|
||
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)},
|
||
}
|