网关首选的推理模型一次生成约 160 秒,每次打开建议页都重跑不可用。 结果落库缓存,页面读缓存,用户想要新的再手动触发。 db.py: - 新增 ai_recommendations 表,每用户一行(重新生成是替换不是累积) - fingerprint 列记录这条建议是基于哪份数据算出来的 services/analysis.py: - _fingerprint() 对全部每日指标 + 运动条数取 sha256,任何一次同步 新增或修正了数值都会让摘要变化,从而使缓存失效 - TTL 默认 24 小时(AI_CACHE_TTL_HOURS 可调) - 指定 model 参数时绕过缓存:点名某个模型意味着想要那个模型的答案 - 降级到规则引擎的结果不写缓存,避免把兜底答案当成 AI 结果存下来 - 缓存写入失败只打日志,不影响本次请求返回 routes: ?refresh=1 强制重新生成 前端: - "重新生成" 按钮走 refresh,并提示需要 1-3 分钟、可以离开本页 - meta 栏显示是否为缓存结果及生成时间,以及网关的上游厂商 - axios 该请求超时放宽到 240s(冷生成远超默认超时) tests/test_ai_cache.py (20 通过): - 第二次调用不再打模型 - 新增一天数据 / 修正某天数值 / 新增一条运动记录,三种情况都失效 - TTL 边界两侧各一条(刚过期重算、未过期沿用) - 缓存按用户隔离,A 的结果不会答给 B - payload 损坏时重新生成而不是抛异常 - 规则兜底结果和无数据用户都不落缓存 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
259 lines
8.6 KiB
Python
259 lines
8.6 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.
|
||
"""
|
||
import datetime
|
||
import hashlib
|
||
import json
|
||
import os
|
||
|
||
from services import health
|
||
from services import ai as ai_svc
|
||
from db import query_all, query_one, execute
|
||
from config import DB_TYPE
|
||
|
||
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
|
||
|
||
|
||
CACHE_TTL_HOURS = int(os.environ.get("AI_CACHE_TTL_HOURS") or 24)
|
||
|
||
|
||
def _fingerprint(summary, activities):
|
||
"""Identify the data a cached answer was derived from.
|
||
|
||
Cheap and order-independent: the day count, the newest and oldest dates,
|
||
and every metric value. Any sync that adds or corrects a value changes the
|
||
digest, which is what expires the cache.
|
||
"""
|
||
parts = [str(len(summary)), str(len(activities))]
|
||
for row in summary:
|
||
parts.append(
|
||
"|".join(
|
||
str(row.get(k))
|
||
for k in ("date", "steps", "heartRate", "heartRateVariability",
|
||
"stress", "caloriesBurned")
|
||
)
|
||
)
|
||
sleep = row.get("sleep") or {}
|
||
parts.append(f"{sleep.get('duration')}/{sleep.get('quality')}")
|
||
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:64]
|
||
|
||
|
||
def _read_cache(user_id, fingerprint):
|
||
row = query_one(
|
||
"SELECT * FROM ai_recommendations WHERE user_id = ?", [user_id]
|
||
)
|
||
if not row or row["fingerprint"] != fingerprint:
|
||
return None
|
||
|
||
created = row.get("created_at")
|
||
if created:
|
||
try:
|
||
ts = datetime.datetime.fromisoformat(str(created).replace(" ", "T"))
|
||
age = datetime.datetime.utcnow() - ts
|
||
if age > datetime.timedelta(hours=CACHE_TTL_HOURS):
|
||
return None
|
||
except ValueError:
|
||
# An unparseable timestamp should not permanently poison the cache.
|
||
return None
|
||
|
||
try:
|
||
recs = json.loads(row["payload"])
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
return {
|
||
"recommendations": recs,
|
||
"meta": {
|
||
"source": "ai",
|
||
"model": row["model"],
|
||
"upstream": row["upstream"],
|
||
"days": row["days"],
|
||
"cached": True,
|
||
"generatedAt": created,
|
||
},
|
||
}
|
||
|
||
|
||
def _write_cache(user_id, fingerprint, recs, meta):
|
||
cols = ["user_id", "fingerprint", "model", "upstream", "days", "payload",
|
||
"created_at"]
|
||
placeholders = ", ".join(["?"] * len(cols))
|
||
if DB_TYPE == "mariadb":
|
||
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id")
|
||
sql = (
|
||
f"INSERT INTO ai_recommendations ({', '.join(cols)}) "
|
||
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
|
||
)
|
||
else:
|
||
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
|
||
sql = (
|
||
f"INSERT INTO ai_recommendations ({', '.join(cols)}) "
|
||
f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}"
|
||
)
|
||
execute(sql, [
|
||
user_id, fingerprint, meta.get("model"), meta.get("upstream"),
|
||
meta.get("days"), json.dumps(recs, ensure_ascii=False),
|
||
datetime.datetime.utcnow().isoformat(timespec="seconds"),
|
||
])
|
||
|
||
|
||
def get_ai_recommendations(user_id, model=None, days=None, refresh=False):
|
||
"""LLM recommendations over the user's history, cached.
|
||
|
||
A generation costs minutes against a large reasoning model, so a stored
|
||
answer is reused until the health data changes (or the TTL lapses).
|
||
`refresh=True` and an explicit `model` both bypass the cache — asking for
|
||
a specific model means wanting that model's answer, not a stored one.
|
||
|
||
Falls back to the rule engine when every model fails, so the endpoint
|
||
always returns something useful; `meta.source` 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)
|
||
fingerprint = _fingerprint(summary, activities)
|
||
|
||
if not refresh and not model:
|
||
cached = _read_cache(user_id, fingerprint)
|
||
if cached:
|
||
return cached
|
||
|
||
budget = days or ai_svc.default_day_budget()
|
||
try:
|
||
recs, meta = ai_svc.generate(
|
||
summary, activities, preferred_model=model, day_budget=budget
|
||
)
|
||
except ai_svc.AIError as e:
|
||
return {
|
||
"recommendations": get_recommendations(user_id),
|
||
"meta": {"model": None, "source": "rules", "reason": str(e)},
|
||
}
|
||
|
||
try:
|
||
_write_cache(user_id, fingerprint, recs, meta)
|
||
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
|
||
print(f"[analysis] failed to cache recommendations: {e}")
|
||
|
||
return {"recommendations": recs, "meta": {**meta, "source": "ai", "cached": False}}
|
||
|
||
|
||
def clear_ai_cache(user_id):
|
||
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])
|