MariaDB 不支持 CAST 到 TEXT(仅 SQLite 支持),导致该语句在 prepare 阶段 直接 1064,activity 分支每次 idle 循环都报错刷屏——且无论 backlog 是否有 数据都会失败(语法错误先于执行)。activities.id 本身是 VARCHAR(64), ai_jobs/ai_insights.subject 是 VARCHAR(96),两侧同类型,CAST 本就不必要。 改为直接比较 ai.subject = a.id / aj.subject = a.id,双后端(SQLite/MariaDB) 语义一致。 已在生产 MariaDB 上只读验证两段 SQL 均正常执行(daily-backlog=0, activity-backlog=0),本地 analysis/ai/coach 测试全绿。
733 lines
27 KiB
Python
733 lines
27 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 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 = a.id) "
|
||
"AND NOT EXISTS (SELECT 1 FROM ai_jobs aj "
|
||
" WHERE aj.user_id = a.user_id AND aj.kind = 'activity' "
|
||
" AND aj.subject = a.id) "
|
||
"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])
|