Files
GarminHealthLab/backend/services/analysis.py
ericwyuan 7fd4106b0a feat(ai): 每日和运动详情在后台持续排队生成,不限量
- 移除 prefetch_insights 中 daily 的 LIMIT 30 和 activity 的 LIMIT 50
- 新增 refill_backlog(),在队列空闲时自动查找未排队的 daily/activity 项
- 新增 jobs.set_refiller() 机制,worker 空闲时调用 refiller 补充队列
- 队列空闲时每 5 个轮询周期触发一次 refill,持续填充 backlog
2026-09-01 18:15:26 +08:00

733 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 services import coach
from services import insights
from services import jobs
from services import scopes
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, per-screen insights, attribution, Copilot ----------
# Same caching rationale as the recommendations above, with one addition: no
# screen can wait on a generation, so every one of them answers immediately
# from the rule engine and queues the model's version, which replaces it on a
# later poll. The queue lives in services/jobs.py.
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: an insight is derived from
all of it, so any change to any field — a corrected sleep stage, a newly
synced activity — should expire the stored answer.
"""
blob = json.dumps(context, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
def has_insight(user_id, kind, subject):
"""True if a generated insight exists for this kind+subject."""
return bool(query_one(
"SELECT 1 FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
[user_id, kind, subject],
))
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"]
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)},
}
# `pending` in the meta is what tells the client to poll again: the card it
# is showing is the placeholder, not the final answer.
state = jobs.enqueue(user_id, "briefing", subject, fingerprint,
jobs.PRIORITY_INTERACTIVE)
return {
"briefing": coach.rule_briefing(context),
"context": context,
"meta": _queued_meta(state, user_id, "briefing", subject),
}
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 generate_scope_insight(user_id, scope, subject=None):
"""Generate and store one screen's insight. Raises on failure."""
built = scopes.build(user_id, scope, subject)
if not built:
return None
resolved, context = built
completion, meta = ai_svc.complete(coach.scope_messages(context))
insight = coach.parse_scope_insight(completion.text)
_write_insight(user_id, scope, resolved, _context_fingerprint(context),
insight, meta)
return insight, meta
def get_scope_insight(user_id, scope, subject=None, refresh=False):
"""One screen's AI insight, answered immediately.
Returns the stored model answer when it matches the current data.
Otherwise the computed highlights are returned right away and the
generation is queued **at interactive priority** — so opening a screen
puts it ahead of whatever backfill is still working through.
"""
if scope not in scopes.SCOPES:
raise KeyError(scope)
built = scopes.build(user_id, scope, subject)
if not built:
return {"insight": None, "context": None,
"meta": {"source": "none", "reason": "这个页面还没有可分析的数据"}}
resolved, context = built
fingerprint = _context_fingerprint(context)
if refresh:
_delete_insight(user_id, scope, resolved)
else:
cached = _read_insight(user_id, scope, resolved, fingerprint)
if cached:
insight, meta = cached
return {"insight": insight, "context": context, "meta": meta}
if not scopes.SCOPES[scope].per_item:
jobs.supersede(user_id, scope, resolved)
state = jobs.enqueue(user_id, scope, resolved, fingerprint,
jobs.PRIORITY_INTERACTIVE)
return {
"insight": coach.rule_scope_insight(context),
"context": context,
"meta": _queued_meta(state, user_id, scope, resolved),
}
def _queued_meta(state, user_id, kind, subject, **extra):
"""Meta for an answer that is standing in for a queued generation.
`pending` drives the client's poll loop, so it must be False once the
queue has given up — otherwise the screen keeps polling for minutes for an
answer that is not coming, and shows a spinner the whole time.
"""
meta = {"source": "rules", "pending": state in ("pending", "running"),
"queue": state, "subject": subject, **extra}
if state == "failed":
info = jobs.status_of(user_id, kind, subject) or {}
meta["reason"] = info.get("error") or "生成失败,稍后会自动重试"
return meta
def prefetch_insights(user_id):
"""Queue every screen's insight at background priority.
Called after a sync: the data has changed, so every stored answer is stale.
These run for as long as they run — anything the user actually opens jumps
the queue ahead of them.
Also queues per-item scopes (daily, activity) that have no insight yet,
so the user's whole history is gradually generated in the background.
"""
queued = []
for scope in scopes.PREFETCH_SCOPES:
try:
built = scopes.build(user_id, scope)
except Exception as e: # noqa: BLE001 - one screen must not stop the rest
print(f"[analysis] prefetch {scope} failed to build: {e}")
continue
if not built:
continue
resolved, context = built
if not scopes.SCOPES[scope].per_item:
jobs.supersede(user_id, scope, resolved)
jobs.enqueue(user_id, scope, resolved, _context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued.append(scope)
# --- daily summaries (每日) ---
# Queue every date that has health data but no insight yet.
try:
dates = query_all(
"SELECT DISTINCT date FROM health_data WHERE user_id = ? "
"AND date IS NOT NULL ORDER BY date DESC",
[user_id],
)
for row in dates:
subject = row["date"]
if has_insight(user_id, "daily", subject):
continue
built = scopes.build(user_id, "daily", subject)
if built:
resolved, context = built
jobs.enqueue(user_id, "daily", subject,
_context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued.append(f"daily:{subject}")
except Exception as e: # noqa: BLE001
print(f"[analysis] prefetch daily failed: {e}")
# --- activity summaries (运动详情) ---
# Queue every activity that has no insight yet.
try:
activities = query_all(
"SELECT id, start_time FROM activities WHERE user_id = ? "
"AND id IS NOT NULL ORDER BY start_time DESC",
[user_id],
)
for row in activities:
subject = str(row["id"])
if has_insight(user_id, "activity", subject):
continue
built = scopes.build(user_id, "activity", subject)
if built:
resolved, context = built
jobs.enqueue(user_id, "activity", subject,
_context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued.append(f"activity:{subject}")
except Exception as e: # noqa: BLE001
print(f"[analysis] prefetch activity failed: {e}")
context = insights.build_context(user_id)
if context:
jobs.supersede(user_id, "briefing", context["snapshotDate"])
jobs.enqueue(user_id, "briefing", context["snapshotDate"],
_context_fingerprint(context), jobs.PRIORITY_PREFETCH)
queued.append("briefing")
return queued
def refill_backlog(max_users=3, max_daily=100, max_activity=100):
"""Called by the jobs worker when idle: queue more items that lack insights.
Iterates over users who have health data or activities, and for each one
queues any daily/activity items that are not yet queued and have no insight
yet. Batched so a single call does not spend too long on SQL — the worker
will call this again on the next idle cycle.
"""
queued = 0
try:
users = query_all(
"SELECT DISTINCT user_id FROM health_data WHERE user_id IS NOT NULL "
"UNION SELECT DISTINCT user_id FROM activities WHERE user_id IS NOT NULL "
"LIMIT ?",
[max_users],
)
except Exception as e: # noqa: BLE001
print(f"[analysis] refill_backlog users query failed: {e}")
return 0
for row in users:
uid = row["user_id"]
if not uid:
continue
# --- daily ---
try:
dates = query_all(
"SELECT DISTINCT hd.date FROM health_data hd "
"WHERE hd.user_id = ? AND hd.date IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM ai_insights ai "
" WHERE ai.user_id = hd.user_id AND ai.kind = 'daily' "
" AND ai.subject = hd.date) "
"AND NOT EXISTS (SELECT 1 FROM ai_jobs aj "
" WHERE aj.user_id = hd.user_id AND aj.kind = 'daily' "
" AND aj.subject = hd.date) "
"ORDER BY hd.date DESC LIMIT ?",
[uid, max_daily],
)
for dr in dates:
subject = dr["date"]
built = scopes.build(uid, "daily", subject)
if built:
resolved, context = built
jobs.enqueue(uid, "daily", subject,
_context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued += 1
except Exception as e: # noqa: BLE001
print(f"[analysis] refill_backlog daily for {uid} failed: {e}")
# --- activity ---
try:
activities = query_all(
"SELECT a.id FROM activities a "
"WHERE a.user_id = ? AND a.id IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM ai_insights ai "
" WHERE ai.user_id = a.user_id AND ai.kind = 'activity' "
" AND ai.subject = CAST(a.id AS TEXT)) "
"AND NOT EXISTS (SELECT 1 FROM ai_jobs aj "
" WHERE aj.user_id = a.user_id AND aj.kind = 'activity' "
" AND aj.subject = CAST(a.id AS TEXT)) "
"ORDER BY a.start_time DESC LIMIT ?",
[uid, max_activity],
)
for ar in activities:
subject = str(ar["id"])
built = scopes.build(uid, "activity", subject)
if built:
resolved, context = built
jobs.enqueue(uid, "activity", subject,
_context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued += 1
except Exception as e: # noqa: BLE001
print(f"[analysis] refill_backlog activity for {uid} failed: {e}")
return queued
def _run_job(user_id, kind, subject):
"""What the queue worker calls. Dispatches on the job's kind."""
if kind == "briefing":
context = insights.build_context(user_id, subject)
if not context:
return
generate_briefing(user_id, context)
return
generate_scope_insight(user_id, kind, subject)
jobs.set_runner(_run_job)
jobs.set_refiller(refill_backlog)
def list_insights(user_id, limit=60):
"""Stored answers, newest first — what the coach has actually produced."""
rows = query_all(
"SELECT kind, subject, model, upstream, created_at FROM ai_insights "
"WHERE user_id = ? ORDER BY created_at DESC",
[user_id],
)
return [{
"kind": r["kind"],
"subject": r["subject"],
"model": r["model"],
"upstream": r["upstream"],
"generatedAt": r["created_at"],
} for r in rows[:limit]]
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])