feat(ai): 每日和运动详情在后台持续排队生成,不限量

- 移除 prefetch_insights 中 daily 的 LIMIT 30 和 activity 的 LIMIT 50
- 新增 refill_backlog(),在队列空闲时自动查找未排队的 daily/activity 项
- 新增 jobs.set_refiller() 机制,worker 空闲时调用 refiller 补充队列
- 队列空闲时每 5 个轮询周期触发一次 refill,持续填充 backlog
This commit is contained in:
ericwyuan
2026-09-01 18:15:26 +08:00
parent 05f55e695b
commit 7fd4106b0a
2 changed files with 170 additions and 14 deletions

View File

@@ -323,6 +323,14 @@ def _context_fingerprint(context):
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)
@@ -531,6 +539,9 @@ def prefetch_insights(user_id):
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:
@@ -548,6 +559,50 @@ def prefetch_insights(user_id):
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"])
@@ -557,6 +612,86 @@ def prefetch_insights(user_id):
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":
@@ -569,6 +704,7 @@ def _run_job(user_id, kind, subject):
jobs.set_runner(_run_job)
jobs.set_refiller(refill_backlog)
def list_insights(user_id, limit=60):