数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
520 lines
18 KiB
Python
520 lines
18 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
|
||
import threading
|
||
|
||
from services import health
|
||
from services import ai as ai_svc
|
||
from services import coach
|
||
from services import insights
|
||
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])
|
||
|
||
|
||
# --- AI coach: briefing, trend attribution, Copilot -------------------------
|
||
# Same caching rationale as the recommendations above, with one addition: a
|
||
# briefing is the first thing on the 今日 screen, so it can never wait on a
|
||
# generation. The endpoint answers immediately from the rule engine and the
|
||
# model's version replaces it on a later poll.
|
||
_JOB_LOCK = threading.Lock()
|
||
_JOBS = set()
|
||
|
||
|
||
def _insight_key(kind, subject):
|
||
return f"{kind}:{subject}"
|
||
|
||
|
||
def _read_insight(user_id, kind, subject, fingerprint):
|
||
row = query_one(
|
||
"SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
|
||
[user_id, kind, subject],
|
||
)
|
||
if not row or row["fingerprint"] != fingerprint:
|
||
return None
|
||
try:
|
||
payload = json.loads(row["payload"])
|
||
except (ValueError, TypeError):
|
||
return None
|
||
return payload, {
|
||
"source": "ai",
|
||
"model": row["model"],
|
||
"upstream": row["upstream"],
|
||
"cached": True,
|
||
"generatedAt": row.get("created_at"),
|
||
}
|
||
|
||
|
||
def _write_insight(user_id, kind, subject, fingerprint, payload, meta):
|
||
cols = ["id", "user_id", "kind", "subject", "fingerprint", "model",
|
||
"upstream", "payload", "created_at"]
|
||
placeholders = ", ".join(["?"] * len(cols))
|
||
updatable = [c for c in cols if c != "id"]
|
||
if DB_TYPE == "mariadb":
|
||
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
|
||
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
|
||
else:
|
||
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
|
||
f"VALUES ({placeholders}) ON CONFLICT(id) DO UPDATE SET {updates}")
|
||
# The id is derived rather than random so a re-generation overwrites the
|
||
# row it replaces instead of accumulating one per attempt.
|
||
row_id = hashlib.sha256(
|
||
f"{user_id}|{kind}|{subject}".encode("utf-8")
|
||
).hexdigest()[:64]
|
||
execute(sql, [
|
||
row_id, user_id, kind, subject, fingerprint, meta.get("model"),
|
||
meta.get("upstream"), json.dumps(payload, ensure_ascii=False),
|
||
datetime.datetime.utcnow().isoformat(timespec="seconds"),
|
||
])
|
||
|
||
|
||
def _context_fingerprint(context):
|
||
"""Digest of everything the prompt will contain.
|
||
|
||
The whole context rather than a chosen subset: a briefing is derived from
|
||
all of it, so any change to any field — a corrected sleep stage, a newly
|
||
synced activity — should expire the cached answer.
|
||
"""
|
||
blob = json.dumps(context, ensure_ascii=False, sort_keys=True)
|
||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
|
||
|
||
|
||
def _run_in_background(key, target):
|
||
"""Start `target` once per key; a second caller joins the first one's run.
|
||
|
||
Guards against the obvious failure mode of a poll-until-ready endpoint:
|
||
the client polls every few seconds while a generation takes minutes, and
|
||
without this every poll would start another one.
|
||
|
||
The claim is per-process, not per-deployment: with Gunicorn's two workers
|
||
each can start one generation for the same key. That is deliberate rather
|
||
than overlooked — the `job_locks` table would make it exclusive, but the
|
||
cost here is a duplicate call, not a duplicate row (the cache id is derived
|
||
from user+kind+subject, so the second write lands on the first one's row).
|
||
A cross-process lock is worth adding only if the gateway's rate limits
|
||
start to bite.
|
||
"""
|
||
with _JOB_LOCK:
|
||
if key in _JOBS:
|
||
return False
|
||
_JOBS.add(key)
|
||
|
||
def runner():
|
||
try:
|
||
target()
|
||
except Exception as e: # noqa: BLE001 - a background job must not die silently
|
||
print(f"[analysis] background job {key} failed: {e}")
|
||
finally:
|
||
with _JOB_LOCK:
|
||
_JOBS.discard(key)
|
||
|
||
threading.Thread(target=runner, name=f"ai-{key}", daemon=True).start()
|
||
return True
|
||
|
||
|
||
def _generating(key):
|
||
with _JOB_LOCK:
|
||
return key in _JOBS
|
||
|
||
|
||
def generate_briefing(user_id, context, model=None):
|
||
"""Ask a model for the briefing and store it. Returns (briefing, meta)."""
|
||
completion, meta = ai_svc.complete(coach.briefing_messages(context), model)
|
||
briefing = coach.parse_briefing(completion.text)
|
||
_write_insight(
|
||
user_id, "briefing", context["snapshotDate"],
|
||
_context_fingerprint(context), briefing, meta,
|
||
)
|
||
return briefing, meta
|
||
|
||
|
||
def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
|
||
"""The morning briefing for one day.
|
||
|
||
Non-blocking by default: a cached answer is returned if it matches the
|
||
current data, otherwise the rule-based briefing is returned straight away
|
||
and a model generation starts in the background. `wait=True` blocks for
|
||
the model instead — for callers that can afford minutes, such as a manual
|
||
"regenerate" or a scheduled pre-warm.
|
||
"""
|
||
context = insights.build_context(user_id, date)
|
||
if not context:
|
||
return {
|
||
"briefing": None,
|
||
"context": None,
|
||
"meta": {"source": "none", "reason": "无健康数据"},
|
||
}
|
||
|
||
fingerprint = _context_fingerprint(context)
|
||
subject = context["snapshotDate"]
|
||
key = _insight_key("briefing", subject)
|
||
|
||
if not refresh:
|
||
cached = _read_insight(user_id, "briefing", subject, fingerprint)
|
||
if cached:
|
||
briefing, meta = cached
|
||
return {"briefing": briefing, "context": context, "meta": meta}
|
||
else:
|
||
# Drop the stored answer, not just skip it. Without this the poll that
|
||
# follows a regenerate reads the *old* row, sees `cached: true`, and
|
||
# stops polling — so the user keeps looking at the text they just
|
||
# asked to replace until something else expires it.
|
||
_delete_insight(user_id, "briefing", subject)
|
||
|
||
if wait:
|
||
try:
|
||
briefing, meta = generate_briefing(user_id, context, model)
|
||
return {
|
||
"briefing": briefing, "context": context,
|
||
"meta": {**meta, "source": "ai", "cached": False},
|
||
}
|
||
except ai_svc.AIError as e:
|
||
return {
|
||
"briefing": coach.rule_briefing(context), "context": context,
|
||
"meta": {"source": "rules", "reason": str(e)},
|
||
}
|
||
|
||
started = _run_in_background(
|
||
key, lambda: generate_briefing(user_id, context, model)
|
||
)
|
||
return {
|
||
"briefing": coach.rule_briefing(context),
|
||
"context": context,
|
||
"meta": {
|
||
"source": "rules",
|
||
# `pending` is what tells the client to poll again: the card it is
|
||
# showing is the placeholder, not the final answer.
|
||
"pending": True,
|
||
"generating": started or _generating(key),
|
||
},
|
||
}
|
||
|
||
|
||
def get_trend_insight(user_id, metric, start, end, model=None, refresh=False):
|
||
"""Attribution for a user-selected span of one metric (chart brush).
|
||
|
||
Blocking, unlike the briefing: this one is requested by an explicit
|
||
gesture on a chart, so there is a spinner to attach the wait to and no
|
||
useful placeholder to show in the meantime.
|
||
"""
|
||
window = insights.window_context(user_id, metric, start, end)
|
||
if not window:
|
||
return {"insight": None, "window": None,
|
||
"meta": {"source": "none", "reason": "所选区间没有数据"}}
|
||
|
||
subject = f"{metric}:{start}:{end}"
|
||
fingerprint = _context_fingerprint(window)
|
||
if not refresh:
|
||
cached = _read_insight(user_id, "trend", subject, fingerprint)
|
||
if cached:
|
||
insight, meta = cached
|
||
return {"insight": insight, "window": window, "meta": meta}
|
||
|
||
try:
|
||
completion, meta = ai_svc.complete(coach.trend_messages(window), model)
|
||
insight = coach.parse_trend_insight(completion.text)
|
||
except ai_svc.AIError as e:
|
||
return {
|
||
"insight": coach.rule_trend_insight(window), "window": window,
|
||
"meta": {"source": "rules", "reason": str(e)},
|
||
}
|
||
|
||
try:
|
||
_write_insight(user_id, "trend", subject, fingerprint, insight, meta)
|
||
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
|
||
print(f"[analysis] failed to cache trend insight: {e}")
|
||
return {"insight": insight, "window": window,
|
||
"meta": {**meta, "source": "ai", "cached": False}}
|
||
|
||
|
||
def copilot_stream(user_id, question, history=None, date=None, model=None):
|
||
"""Stream a Copilot answer, yielding (event, data) pairs.
|
||
|
||
A generator rather than a return value so the route can forward each delta
|
||
as it arrives; the health context is assembled once, here, so the route
|
||
stays free of feature logic.
|
||
"""
|
||
context = insights.build_context(user_id, date)
|
||
if not context:
|
||
yield "error", {"message": "暂无健康数据,请先同步 Garmin 数据。"}
|
||
return
|
||
|
||
messages = coach.copilot_messages(context, history or [], question)
|
||
yield "start", {"snapshotDate": context["snapshotDate"]}
|
||
upstream = None
|
||
try:
|
||
for delta in ai_svc.stream_chat(messages, model):
|
||
upstream = delta.upstream or upstream
|
||
yield "delta", {"text": delta.text}
|
||
except ai_svc.AIError as e:
|
||
yield "error", {"message": str(e)}
|
||
return
|
||
yield "done", {"upstream": upstream}
|
||
|
||
|
||
def _delete_insight(user_id, kind, subject):
|
||
execute(
|
||
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
|
||
[user_id, kind, subject],
|
||
)
|
||
|
||
|
||
def clear_insight_cache(user_id, kind=None):
|
||
if kind:
|
||
execute(
|
||
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ?", [user_id, kind]
|
||
)
|
||
else:
|
||
execute("DELETE FROM ai_insights WHERE user_id = ?", [user_id])
|