[阶段4.4] AI 建议结果缓存 - 页面不再阻塞等待 160 秒

网关首选的推理模型一次生成约 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>
This commit is contained in:
ericwyuan
2026-08-23 17:55:12 +08:00
parent 8882bf44a4
commit acc6a2474b
6 changed files with 418 additions and 14 deletions

View File

@@ -4,9 +4,15 @@ 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
from db import query_all, query_one, execute
from config import DB_TYPE
METRIC_COLUMNS = {
"steps": "steps",
@@ -120,11 +126,99 @@ def get_recommendations(user_id):
return recs
def get_ai_recommendations(user_id, model=None, days=None):
"""LLM-generated recommendations over the user's full history.
CACHE_TTL_HOURS = int(os.environ.get("AI_CACHE_TTL_HOURS") or 24)
Falls back to the rule engine if every model fails, so the endpoint always
returns something useful. The `source` field tells the two apart.
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:
@@ -134,15 +228,31 @@ def get_ai_recommendations(user_id, model=None, days=None):
}
activities = health.get_activities(user_id)
budget = days or ai_svc.default_day_budget()
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
)
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)},
}
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])