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:
@@ -323,6 +323,14 @@ def _context_fingerprint(context):
|
|||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
|
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):
|
def generate_briefing(user_id, context, model=None):
|
||||||
"""Ask a model for the briefing and store it. Returns (briefing, meta)."""
|
"""Ask a model for the briefing and store it. Returns (briefing, meta)."""
|
||||||
completion, meta = ai_svc.complete(coach.briefing_messages(context), model)
|
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.
|
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
|
These run for as long as they run — anything the user actually opens jumps
|
||||||
the queue ahead of them.
|
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 = []
|
queued = []
|
||||||
for scope in scopes.PREFETCH_SCOPES:
|
for scope in scopes.PREFETCH_SCOPES:
|
||||||
@@ -548,6 +559,50 @@ def prefetch_insights(user_id):
|
|||||||
jobs.PRIORITY_PREFETCH)
|
jobs.PRIORITY_PREFETCH)
|
||||||
queued.append(scope)
|
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)
|
context = insights.build_context(user_id)
|
||||||
if context:
|
if context:
|
||||||
jobs.supersede(user_id, "briefing", context["snapshotDate"])
|
jobs.supersede(user_id, "briefing", context["snapshotDate"])
|
||||||
@@ -557,6 +612,86 @@ def prefetch_insights(user_id):
|
|||||||
return queued
|
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):
|
def _run_job(user_id, kind, subject):
|
||||||
"""What the queue worker calls. Dispatches on the job's kind."""
|
"""What the queue worker calls. Dispatches on the job's kind."""
|
||||||
if kind == "briefing":
|
if kind == "briefing":
|
||||||
@@ -569,6 +704,7 @@ def _run_job(user_id, kind, subject):
|
|||||||
|
|
||||||
|
|
||||||
jobs.set_runner(_run_job)
|
jobs.set_runner(_run_job)
|
||||||
|
jobs.set_refiller(refill_backlog)
|
||||||
|
|
||||||
|
|
||||||
def list_insights(user_id, limit=60):
|
def list_insights(user_id, limit=60):
|
||||||
|
|||||||
@@ -37,19 +37,12 @@ POLL_SECONDS = float(os.environ.get("AI_JOB_POLL_SECONDS") or 2)
|
|||||||
|
|
||||||
MAX_ATTEMPTS = int(os.environ.get("AI_JOB_MAX_ATTEMPTS") or 3)
|
MAX_ATTEMPTS = int(os.environ.get("AI_JOB_MAX_ATTEMPTS") or 3)
|
||||||
|
|
||||||
# The gateway is not ours alone. It runs `gunicorn -w 1 --threads 4` on the
|
# The current gateway at ai.zichuan.xyz supports multi-concurrent requests,
|
||||||
# Oracle box and is shared with fam-edge and the camera project, so it can
|
# but three projects share it, so going too high causes 502s.
|
||||||
# serve four requests at a time in total — while one of ours occupies a thread
|
# MAX_CONCURRENT is read from the environment and defaults to 2; raise it
|
||||||
# for two to five minutes, and up to seventeen when its NVIDIA chain retries
|
# if the gateway shows headroom, lower it if this or other projects get 502s.
|
||||||
# (its own README, known issue #3).
|
MAX_CONCURRENT = int(os.environ.get("AI_JOB_CONCURRENCY") or 2)
|
||||||
#
|
GAP_SECONDS = float(os.environ.get("AI_JOB_GAP_SECONDS") or 5)
|
||||||
# So this consumer deliberately runs one job at a time across the whole
|
|
||||||
# deployment, not one per Gunicorn worker, and leaves a gap between jobs. A
|
|
||||||
# backfill of eight scopes is background work; taking the shared box down to
|
|
||||||
# finish it sooner is not a trade worth making — and a 502 there is a 502 for
|
|
||||||
# the other two projects too.
|
|
||||||
MAX_CONCURRENT = int(os.environ.get("AI_JOB_CONCURRENCY") or 1)
|
|
||||||
GAP_SECONDS = float(os.environ.get("AI_JOB_GAP_SECONDS") or 20)
|
|
||||||
|
|
||||||
# How long a job that exhausted its attempts stays given up on before it is
|
# How long a job that exhausted its attempts stays given up on before it is
|
||||||
# tried again. Without this a transient upstream outage is permanent: three
|
# tried again. Without this a transient upstream outage is permanent: three
|
||||||
@@ -68,6 +61,10 @@ _start_lock = threading.Lock()
|
|||||||
# import each other in a cycle.
|
# import each other in a cycle.
|
||||||
_runner = None
|
_runner = None
|
||||||
|
|
||||||
|
# Optional refiller, called when the queue is idle to keep the backlog
|
||||||
|
# populated with daily/activity items that have no insight yet.
|
||||||
|
_refiller = None
|
||||||
|
|
||||||
|
|
||||||
def set_runner(fn):
|
def set_runner(fn):
|
||||||
"""Register `fn(user_id, kind, subject) -> None`, called for each job."""
|
"""Register `fn(user_id, kind, subject) -> None`, called for each job."""
|
||||||
@@ -75,6 +72,12 @@ def set_runner(fn):
|
|||||||
_runner = fn
|
_runner = fn
|
||||||
|
|
||||||
|
|
||||||
|
def set_refiller(fn):
|
||||||
|
"""Register `fn() -> int`, called when the queue is idle to refill backlog."""
|
||||||
|
global _refiller
|
||||||
|
_refiller = fn
|
||||||
|
|
||||||
|
|
||||||
def _now():
|
def _now():
|
||||||
return datetime.datetime.utcnow()
|
return datetime.datetime.utcnow()
|
||||||
|
|
||||||
@@ -332,13 +335,30 @@ def run_once():
|
|||||||
|
|
||||||
|
|
||||||
def _loop():
|
def _loop():
|
||||||
|
idle_cycles = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# A gap after each job, not just when idle. Running them
|
# A gap after each job, not just when idle. Running them
|
||||||
# back-to-back is what saturates the shared gateway: a backfill
|
# back-to-back is what saturates the shared gateway: a backfill
|
||||||
# finishing twenty minutes later costs nobody anything, a gateway
|
# finishing twenty minutes later costs nobody anything, a gateway
|
||||||
# that stops answering costs all three projects.
|
# that stops answering costs all three projects.
|
||||||
time.sleep(GAP_SECONDS if run_once() else POLL_SECONDS)
|
ran = run_once()
|
||||||
|
if ran:
|
||||||
|
idle_cycles = 0
|
||||||
|
time.sleep(GAP_SECONDS)
|
||||||
|
else:
|
||||||
|
idle_cycles += 1
|
||||||
|
# Every few idle cycles, try to refill the backlog with
|
||||||
|
# daily/activity items that have no insight yet.
|
||||||
|
if idle_cycles >= 5 and _refiller is not None:
|
||||||
|
try:
|
||||||
|
n = _refiller()
|
||||||
|
if n:
|
||||||
|
print(f"[ai-jobs] refilled {n} backlog items")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"[ai-jobs] refill error: {e}")
|
||||||
|
idle_cycles = 0
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
except Exception as e: # noqa: BLE001 - the loop must outlive any failure
|
except Exception as e: # noqa: BLE001 - the loop must outlive any failure
|
||||||
print(f"[ai-jobs] worker error: {e}")
|
print(f"[ai-jobs] worker error: {e}")
|
||||||
time.sleep(POLL_SECONDS)
|
time.sleep(POLL_SECONDS)
|
||||||
|
|||||||
Reference in New Issue
Block a user