feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、 身体年龄、挑战赛、运动详情。 不是给每个页面写一套,而是一个通用管线: - services/scopes.py:一个页面一个 context builder,返回同一个信封。 context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不 可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。 没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。 - coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面 的差异全在 context 里,加页面 = 加一个 builder。 - 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成 lib/insight.ts 的 usePolledInsight,晨报卡也改用它。 ## 队列 一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队, worker 负责消费(services/jobs.py)。 优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按 背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务 立刻提到队首、下一个就跑。你在看什么,队列就在算什么。 队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder 声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由 user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一 个任务。 ## 网关中断时踩到的两个坑(当场修了) 写完正好赶上 oracle 那台机器不通,于是看到: - 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把 那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置 尝试次数再排一次。 - 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending 现在跟着队列状态走,并把失败原因带给卡片。 顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py: 教练要拿它做参照,而 services 不该反向依赖 routes。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,13 @@ 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 services import jobs
|
||||
from services import scopes
|
||||
from db import query_all, query_one, execute
|
||||
from config import DB_TYPE
|
||||
|
||||
@@ -261,19 +262,11 @@ 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}"
|
||||
|
||||
|
||||
# --- 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 = ?",
|
||||
@@ -322,52 +315,14 @@ def _write_insight(user_id, kind, subject, fingerprint, payload, meta):
|
||||
def _context_fingerprint(context):
|
||||
"""Digest of everything the prompt will contain.
|
||||
|
||||
The whole context rather than a chosen subset: a briefing is derived from
|
||||
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 cached answer.
|
||||
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 _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)
|
||||
@@ -398,7 +353,6 @@ def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
|
||||
|
||||
fingerprint = _context_fingerprint(context)
|
||||
subject = context["snapshotDate"]
|
||||
key = _insight_key("briefing", subject)
|
||||
|
||||
if not refresh:
|
||||
cached = _read_insight(user_id, "briefing", subject, fingerprint)
|
||||
@@ -425,19 +379,14 @@ def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
|
||||
"meta": {"source": "rules", "reason": str(e)},
|
||||
}
|
||||
|
||||
started = _run_in_background(
|
||||
key, lambda: generate_briefing(user_id, context, model)
|
||||
)
|
||||
# `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": {
|
||||
"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),
|
||||
},
|
||||
"meta": _queued_meta(state, user_id, "briefing", subject),
|
||||
}
|
||||
|
||||
|
||||
@@ -510,6 +459,113 @@ def _delete_insight(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}
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
jobs.enqueue(user_id, scope, resolved, _context_fingerprint(context),
|
||||
jobs.PRIORITY_PREFETCH)
|
||||
queued.append(scope)
|
||||
|
||||
context = insights.build_context(user_id)
|
||||
if context:
|
||||
jobs.enqueue(user_id, "briefing", context["snapshotDate"],
|
||||
_context_fingerprint(context), jobs.PRIORITY_PREFETCH)
|
||||
queued.append("briefing")
|
||||
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)
|
||||
|
||||
|
||||
def clear_insight_cache(user_id, kind=None):
|
||||
if kind:
|
||||
execute(
|
||||
|
||||
@@ -407,3 +407,97 @@ def rule_trend_insight(window):
|
||||
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
|
||||
"confidence": "low",
|
||||
}
|
||||
|
||||
|
||||
# --- per-screen insights ----------------------------------------------------
|
||||
# One prompt for every screen. The screen-specific part is entirely in the
|
||||
# context `scopes.py` builds, so a new screen needs a builder and nothing here.
|
||||
SCOPE_SCHEMA = """{
|
||||
"headline": "针对这个页面的一句话结论,不超过 45 字",
|
||||
"points": [
|
||||
{"title": "维度名,不超过 8 字", "detail": "该维度的判断与依据,引用具体数值,不超过 70 字"}
|
||||
],
|
||||
"actions": ["可执行的建议,1~3 条,每条不超过 30 字;没有值得建议的就给空数组"],
|
||||
"caution": "需要留意的风险或容易误读之处;没有就填 null",
|
||||
"confidence": "high|medium|low —— 取决于样本量与证据强度"
|
||||
}"""
|
||||
|
||||
|
||||
def scope_messages(context):
|
||||
"""Prompt for one screen's insight.
|
||||
|
||||
`highlights` goes in ahead of the raw data on purpose: they are the facts
|
||||
already computed from it, and leading with them is what stops the model
|
||||
re-deriving (and mis-deriving) numbers that are sitting right there.
|
||||
"""
|
||||
highlights = "\n".join(
|
||||
f"- {h['title']}:{h['detail']}" for h in context.get("highlights") or []
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"我正在看「{context.get('label')}」这个页面。以下是已经算好的要点:\n\n"
|
||||
f"{highlights}\n\n"
|
||||
"完整数据如下(deviations 的 z 值是相对我自身近 28 天基线的偏离,"
|
||||
"trends 是长周期走势,两者都已算好,直接引用即可):\n\n"
|
||||
f"```json\n{_payload(context)}\n```\n\n"
|
||||
"请针对这个页面给出解读与建议,只谈这个页面涉及的内容,"
|
||||
f"严格按以下 JSON 结构输出:\n\n{SCOPE_SCHEMA}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def parse_scope_insight(reply):
|
||||
data = ai_svc.extract_json(reply)
|
||||
if not isinstance(data, dict):
|
||||
raise ai_svc.AIError("模型未返回 JSON 对象")
|
||||
|
||||
points = []
|
||||
for item in data.get("points") or []:
|
||||
if isinstance(item, dict):
|
||||
title = _text(item.get("title"), 20)
|
||||
detail = _text(item.get("detail"), 220)
|
||||
else:
|
||||
title, detail = None, _text(item, 220)
|
||||
if detail:
|
||||
points.append({"title": title or "要点", "detail": detail})
|
||||
|
||||
actions = [_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)]
|
||||
confidence = str(data.get("confidence", "medium")).lower()
|
||||
if confidence not in ("high", "medium", "low"):
|
||||
confidence = "medium"
|
||||
|
||||
headline = _text(data.get("headline"), 140)
|
||||
# A card with no headline and no points is blank space; rejecting it lets
|
||||
# the caller fall back to the rule engine rather than render nothing.
|
||||
if not headline and not points:
|
||||
raise ai_svc.AIError("模型返回的解读没有可用内容")
|
||||
|
||||
return {
|
||||
"headline": headline,
|
||||
"points": points[:5],
|
||||
"actions": actions[:3],
|
||||
"caution": _text(data.get("caution"), 200),
|
||||
"confidence": confidence,
|
||||
}
|
||||
|
||||
|
||||
def rule_scope_insight(context):
|
||||
"""The computed highlights, rendered as-is.
|
||||
|
||||
No interpretation, and it says so: the honest fallback is the facts without
|
||||
the reading of them, not a guess at what a model would have said.
|
||||
"""
|
||||
highlights = context.get("highlights") or []
|
||||
return {
|
||||
# The first highlight leads and is then dropped from the list: showing
|
||||
# it in both places printed the same sentence twice.
|
||||
"headline": highlights[0]["detail"] if highlights else None,
|
||||
"points": [dict(h) for h in highlights[1:6]],
|
||||
"actions": [],
|
||||
"caution": "以下为直接计算结果,尚未经过模型解读。",
|
||||
"confidence": "low",
|
||||
}
|
||||
|
||||
@@ -69,6 +69,15 @@ def _flatten(day):
|
||||
return flat
|
||||
|
||||
|
||||
def flatten_days(rows):
|
||||
"""`get_summary` output as flat metric maps, with derived shares filled in.
|
||||
|
||||
Public counterpart of `_flatten` for the scope builders, which all need the
|
||||
same normalisation before they can compute anything.
|
||||
"""
|
||||
return [_flatten(r) for r in rows]
|
||||
|
||||
|
||||
# Metrics the briefing reasons about. `higher_better` drives the plain-language
|
||||
# verdict; None means the direction is not meaningful on its own (steps on a
|
||||
# rest day are not a failure).
|
||||
@@ -459,3 +468,34 @@ def window_context(user_id, metric, start, end, rows=None):
|
||||
for a in health.get_activities(user_id, start, end)[:40]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Reference bands, kept beside the UI's RANGES table (client/src/lib/ranges.ts).
|
||||
# Each entry says where that metric's band edges came from — the honest answer
|
||||
# for several of them is "general-population orientation figures", and it says
|
||||
# so. Lives here rather than in routes/: the 评分依据 screen serves it, but the
|
||||
# coach also reasons against it, and services must not import from routes.
|
||||
BAND_SOURCES = [
|
||||
{"metric": "步数", "bands": "<5k 偏低 · 5–8k 一般 · 8–12k 达标 · >12k 优秀",
|
||||
"source": "步数与死亡率的队列研究(约 8000 步起获益明显,12000 步后趋平)"},
|
||||
{"metric": "静息心率", "bands": "<50 很低 · 50–65 正常 · 65–75 偏高 · >75 较高",
|
||||
"source": "健康成人静息心率 60–100 bpm 为正常范围,规律运动者常低于 60"},
|
||||
{"metric": "心率变异性", "bands": "<25 偏低 · 25–40 一般 · 40–70 良好 · >70 很好",
|
||||
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
|
||||
{"metric": "睡眠时长", "bands": "<6h 不足 · 6–7h 偏少 · 7–9h 充足 · >9h 偏多",
|
||||
"source": "美国睡眠医学会 / 睡眠研究会成人 7–9 小时建议"},
|
||||
{"metric": "压力", "bands": "0–25 休息 · 26–50 偏低 · 51–75 中等 · >75 偏高",
|
||||
"source": "Garmin 官方压力分级,与手表显示一致"},
|
||||
{"metric": "身体电量", "bands": "0–25 很低 · 26–50 偏低 · 51–75 良好 · >75 充足",
|
||||
"source": "Garmin 官方 Body Battery 分级"},
|
||||
{"metric": "血氧", "bands": "<90 偏低 · 90–94 略低 · ≥95 正常",
|
||||
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
|
||||
{"metric": "呼吸频率", "bands": "<12 偏低 · 12–20 正常 · >20 偏高",
|
||||
"source": "成人静息呼吸频率 12–20 次/分"},
|
||||
{"metric": "强度分钟", "bands": "<10 偏少 · 10–21 一般 · ≥21 达标",
|
||||
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
|
||||
{"metric": "训练准备度", "bands": "0–25 很低 · 26–50 偏低 · 51–75 就绪 · >75 很好",
|
||||
"source": "Garmin 官方 Training Readiness 分级"},
|
||||
{"metric": "爬楼", "bands": "<5 偏少 · 5–10 达标 · >10 优秀",
|
||||
"source": "一般性活动量参考,无权威标准"},
|
||||
]
|
||||
|
||||
271
backend/services/jobs.py
Normal file
271
backend/services/jobs.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
The coach's producer/consumer queue.
|
||||
|
||||
Producing one insight costs 40s to several minutes against the gateway, so it
|
||||
can never happen inside a request. Screens *enqueue*; a worker thread consumes.
|
||||
|
||||
Priority is the whole point of the queue rather than a plain background thread:
|
||||
after a sync the backfill enqueues every scope at low priority, and those jobs
|
||||
may take half an hour to work through — but the moment the user opens a screen,
|
||||
that screen's job is promoted to the front and runs next. What they are looking
|
||||
at is always what the queue is working on.
|
||||
|
||||
The queue lives in the database, not in memory, because Gunicorn runs several
|
||||
workers: a job is claimed with a holder id and re-read to confirm, the same way
|
||||
`scheduler.py` claims its tick, so exactly one worker runs a given job.
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from config import DB_TYPE
|
||||
from db import execute, query_one, query_all
|
||||
|
||||
# Lower runs first.
|
||||
PRIORITY_INTERACTIVE = 0 # a screen the user has open right now
|
||||
PRIORITY_PREFETCH = 10 # backfill after a sync
|
||||
|
||||
# A claim older than this is treated as abandoned: the worker holding it died
|
||||
# mid-generation, and without expiry that job would never run again.
|
||||
CLAIM_TIMEOUT_SECONDS = int(os.environ.get("AI_JOB_CLAIM_TIMEOUT") or 1800)
|
||||
|
||||
# Generations are slow, not frequent; polling this often costs nothing and
|
||||
# keeps an interactive job's wait to a couple of seconds.
|
||||
POLL_SECONDS = float(os.environ.get("AI_JOB_POLL_SECONDS") or 2)
|
||||
|
||||
MAX_ATTEMPTS = int(os.environ.get("AI_JOB_MAX_ATTEMPTS") or 3)
|
||||
|
||||
# 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
|
||||
# quick failures while the gateway is unreachable would retire that screen's
|
||||
# insight until its underlying data happened to change, which for a screen the
|
||||
# user is not syncing could be days.
|
||||
FAILED_RETRY_SECONDS = int(os.environ.get("AI_JOB_RETRY_AFTER") or 1800)
|
||||
|
||||
ENABLED = (os.environ.get("AI_JOBS") or "true").lower() not in ("0", "false", "no")
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
|
||||
# Set by analysis.py at import time. Injected rather than imported so this
|
||||
# module stays free of the feature logic it schedules — and so the two do not
|
||||
# import each other in a cycle.
|
||||
_runner = None
|
||||
|
||||
|
||||
def set_runner(fn):
|
||||
"""Register `fn(user_id, kind, subject) -> None`, called for each job."""
|
||||
global _runner
|
||||
_runner = fn
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.utcnow()
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _parse(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(str(value).replace(" ", "T"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def job_id(user_id, kind, subject):
|
||||
return hashlib.sha256(
|
||||
f"{user_id}|{kind}|{subject}".encode("utf-8")
|
||||
).hexdigest()[:64]
|
||||
|
||||
|
||||
def enqueue(user_id, kind, subject, fingerprint=None,
|
||||
priority=PRIORITY_PREFETCH):
|
||||
"""Queue one generation, or promote it if it is already queued.
|
||||
|
||||
Returns the job's current status. Idempotent by design: the screen polls
|
||||
every few seconds while it waits, and every one of those polls calls this.
|
||||
|
||||
A finished job is re-queued only when the data it was derived from has
|
||||
changed — that is what `fingerprint` is for, and it is why a poll on
|
||||
unchanged data does not restart the work that just completed.
|
||||
"""
|
||||
jid = job_id(user_id, kind, subject)
|
||||
now = _iso(_now())
|
||||
row = query_one("SELECT * FROM ai_jobs WHERE id = ?", [jid])
|
||||
|
||||
if row:
|
||||
if row["status"] == "running":
|
||||
claimed = _parse(row.get("claimed_at"))
|
||||
if claimed and (_now() - claimed).total_seconds() < CLAIM_TIMEOUT_SECONDS:
|
||||
# Already being generated. Promoting it now would not make the
|
||||
# in-flight call any faster.
|
||||
return "running"
|
||||
|
||||
stale = fingerprint and row.get("fingerprint") != fingerprint
|
||||
if row["status"] == "done" and not stale:
|
||||
return "done"
|
||||
if row["status"] == "failed" and row["attempts"] >= MAX_ATTEMPTS and not stale:
|
||||
gave_up = _parse(row.get("updated_at"))
|
||||
if gave_up and (_now() - gave_up).total_seconds() < FAILED_RETRY_SECONDS:
|
||||
return "failed"
|
||||
# Past the cooldown: reset the attempt count so the outage that
|
||||
# exhausted it does not count against the retry.
|
||||
execute(
|
||||
"UPDATE ai_jobs SET status = 'pending', attempts = 0, error = NULL, "
|
||||
"holder = NULL, claimed_at = NULL, priority = ?, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
[min(priority, row["priority"]), now, jid],
|
||||
)
|
||||
return "pending"
|
||||
|
||||
# Promote (never demote): a screen the user just opened must not be
|
||||
# pushed back by the prefetch entry that was already sitting there.
|
||||
execute(
|
||||
"UPDATE ai_jobs SET status = 'pending', priority = ?, "
|
||||
"fingerprint = ?, holder = NULL, claimed_at = NULL, "
|
||||
"attempts = ?, updated_at = ? WHERE id = ?",
|
||||
[
|
||||
min(priority, row["priority"]),
|
||||
fingerprint or row.get("fingerprint"),
|
||||
0 if stale else row["attempts"],
|
||||
now, jid,
|
||||
],
|
||||
)
|
||||
return "pending"
|
||||
|
||||
cols = ["id", "user_id", "kind", "subject", "fingerprint", "priority",
|
||||
"status", "attempts", "created_at", "updated_at"]
|
||||
execute(
|
||||
f"INSERT INTO ai_jobs ({', '.join(cols)}) "
|
||||
f"VALUES ({', '.join(['?'] * len(cols))})",
|
||||
[jid, user_id, kind, subject, fingerprint, priority, "pending", 0,
|
||||
now, now],
|
||||
)
|
||||
return "pending"
|
||||
|
||||
|
||||
def status_of(user_id, kind, subject):
|
||||
row = query_one("SELECT * FROM ai_jobs WHERE id = ?",
|
||||
[job_id(user_id, kind, subject)])
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"status": row["status"],
|
||||
"priority": row["priority"],
|
||||
"attempts": row["attempts"],
|
||||
"error": row.get("error"),
|
||||
"updatedAt": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def pending_count(user_id=None):
|
||||
sql = "SELECT COUNT(*) AS n FROM ai_jobs WHERE status IN ('pending', 'running')"
|
||||
params = []
|
||||
if user_id:
|
||||
sql += " AND user_id = ?"
|
||||
params.append(user_id)
|
||||
row = query_one(sql, params)
|
||||
return (row or {}).get("n") or 0
|
||||
|
||||
|
||||
def _claim_next():
|
||||
"""Take the highest-priority runnable job, or None.
|
||||
|
||||
Ordered by priority then age so the interactive job wins and, among equals,
|
||||
the one that has waited longest goes first.
|
||||
"""
|
||||
cutoff = _iso(_now() - datetime.timedelta(seconds=CLAIM_TIMEOUT_SECONDS))
|
||||
rows = query_all(
|
||||
"SELECT * FROM ai_jobs WHERE status = 'pending' "
|
||||
"OR (status = 'running' AND (claimed_at IS NULL OR claimed_at < ?)) "
|
||||
"ORDER BY priority ASC, created_at ASC",
|
||||
[cutoff],
|
||||
)
|
||||
holder = f"{os.getpid()}-{threading.get_ident()}"
|
||||
|
||||
for row in rows:
|
||||
if row["attempts"] >= MAX_ATTEMPTS:
|
||||
continue
|
||||
execute(
|
||||
"UPDATE ai_jobs SET status = 'running', holder = ?, claimed_at = ?, "
|
||||
"attempts = ?, updated_at = ? WHERE id = ? AND status = ?",
|
||||
[holder, _iso(_now()), row["attempts"] + 1, _iso(_now()),
|
||||
row["id"], row["status"]],
|
||||
)
|
||||
# Re-read: another worker may have claimed it between the SELECT and
|
||||
# the UPDATE, in which case its holder is the one now recorded.
|
||||
check = query_one("SELECT holder FROM ai_jobs WHERE id = ?", [row["id"]])
|
||||
if check and check.get("holder") == holder:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _finish(jid, error=None):
|
||||
execute(
|
||||
"UPDATE ai_jobs SET status = ?, error = ?, holder = NULL, "
|
||||
"claimed_at = NULL, updated_at = ? WHERE id = ?",
|
||||
["failed" if error else "done", (error or "")[:500] if error else None,
|
||||
_iso(_now()), jid],
|
||||
)
|
||||
|
||||
|
||||
def run_once():
|
||||
"""Claim and run one job. Returns True when something was run."""
|
||||
if _runner is None:
|
||||
return False
|
||||
row = _claim_next()
|
||||
if not row:
|
||||
return False
|
||||
try:
|
||||
_runner(row["user_id"], row["kind"], row["subject"])
|
||||
except Exception as e: # noqa: BLE001 - one bad job must not stop the queue
|
||||
_finish(row["id"], f"{type(e).__name__}: {e}")
|
||||
print(f"[ai-jobs] {row['kind']}:{row['subject']} failed: {e}")
|
||||
return True
|
||||
_finish(row["id"])
|
||||
return True
|
||||
|
||||
|
||||
def _loop():
|
||||
while True:
|
||||
try:
|
||||
# Straight on to the next job when one was just run: after a
|
||||
# sync there is a whole backfill waiting, and sleeping between
|
||||
# each would add hours to it for no reason.
|
||||
if not run_once():
|
||||
time.sleep(POLL_SECONDS)
|
||||
except Exception as e: # noqa: BLE001 - the loop must outlive any failure
|
||||
print(f"[ai-jobs] worker error: {e}")
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
def start():
|
||||
"""Start one consumer per process."""
|
||||
global _started
|
||||
if not ENABLED:
|
||||
print("[ai-jobs] disabled by AI_JOBS")
|
||||
return
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
threading.Thread(target=_loop, daemon=True, name="ai-jobs").start()
|
||||
print("[ai-jobs] worker started")
|
||||
|
||||
|
||||
def reset_stale_claims():
|
||||
"""Release jobs a previous process was running when it stopped.
|
||||
|
||||
Without this they sit in `running` until the claim expires, which for the
|
||||
screen waiting on one looks exactly like a generation that never finishes.
|
||||
"""
|
||||
execute(
|
||||
"UPDATE ai_jobs SET status = 'pending', holder = NULL, claimed_at = NULL "
|
||||
"WHERE status = 'running'"
|
||||
)
|
||||
@@ -22,6 +22,7 @@ import time
|
||||
|
||||
from config import DB_TYPE
|
||||
from db import execute, query_one, query_all
|
||||
from services import analysis as analysis_svc
|
||||
from services import garmin as garmin_svc
|
||||
from services import settings as settings_svc
|
||||
|
||||
@@ -163,6 +164,14 @@ def sync_all_accounts(days=None, respect_schedule=False):
|
||||
out = garmin_svc.sync_data(uid, {}, days=d)
|
||||
results.append({"user": uid, "status": out.get("status"),
|
||||
"records": out.get("recordsSynced")})
|
||||
# New data invalidates every stored insight, so queue them all at
|
||||
# background priority. They may take a while; anything the user
|
||||
# opens meanwhile jumps ahead of them.
|
||||
if out.get("recordsSynced"):
|
||||
try:
|
||||
analysis_svc.prefetch_insights(uid)
|
||||
except Exception as e: # noqa: BLE001 - never fail a sync over this
|
||||
print(f"[scheduler] prefetch failed for {uid}: {e}")
|
||||
except Exception as e: # noqa: BLE001 - one account must not stop the rest
|
||||
results.append({"user": uid, "status": "error", "error": str(e)[:200]})
|
||||
return results
|
||||
|
||||
601
backend/services/scopes.py
Normal file
601
backend/services/scopes.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Per-screen contexts for the AI coach.
|
||||
|
||||
One builder per screen, all returning the same envelope, so the queue, the
|
||||
prompt, the cache and the UI component stay generic — adding a screen means
|
||||
adding an entry here, not another endpoint and another card.
|
||||
|
||||
Every builder returns `(subject, context)`:
|
||||
|
||||
* `subject` identifies *what* the insight is about (a date, an activity id, a
|
||||
window). Together with the scope name it keys the cache and the job queue,
|
||||
so 睡眠 for last week and 睡眠 for today are separate entries rather than one
|
||||
overwriting the other.
|
||||
* `context["highlights"]` is the plain-language facts, already computed. The
|
||||
model interprets them; the rule engine renders them verbatim when no model
|
||||
answers. Both versions therefore quote the same numbers, which is what keeps
|
||||
a fallback from reading like a different app.
|
||||
|
||||
Builders return None when the screen has nothing to say about — no nights
|
||||
recorded, no scale readings — so the caller can stay quiet instead of asking a
|
||||
model to comment on an empty table.
|
||||
"""
|
||||
import datetime
|
||||
import statistics
|
||||
|
||||
from services import garmin as garmin_svc
|
||||
from services import garmin_extras as extras
|
||||
from services import fitness_age
|
||||
from services import health
|
||||
from services import insights
|
||||
from services import settings as settings_svc
|
||||
|
||||
# The same reference bands the 评分依据 screen shows. Passed to the model so it
|
||||
# cannot call a number 偏高 that the card beside it labels 正常.
|
||||
from services.insights import BAND_SOURCES
|
||||
|
||||
|
||||
def _round(value, digits=2):
|
||||
return None if value is None else round(float(value), digits)
|
||||
|
||||
|
||||
def _mean(values):
|
||||
values = [v for v in values if v is not None]
|
||||
return round(statistics.fmean(values), 2) if values else None
|
||||
|
||||
|
||||
def _recent(rows, days):
|
||||
"""The last `days` calendar days of flattened rows, relative to the newest."""
|
||||
if not rows:
|
||||
return []
|
||||
end = datetime.date.fromisoformat(rows[-1]["date"])
|
||||
cutoff = (end - datetime.timedelta(days=days - 1)).isoformat()
|
||||
return [r for r in rows if r["date"] >= cutoff]
|
||||
|
||||
|
||||
def _summary(user_id):
|
||||
return insights.flatten_days(health.get_summary(user_id))
|
||||
|
||||
|
||||
def _deviations_for(rows, metrics):
|
||||
"""Today's departures, narrowed to the metrics this screen is about."""
|
||||
if not rows:
|
||||
return []
|
||||
picked = set(metrics)
|
||||
return [
|
||||
d for d in insights.deviations(rows, rows[-1])
|
||||
if d["metric"] in picked
|
||||
]
|
||||
|
||||
|
||||
def _trends_for(rows, metrics):
|
||||
picked = set(metrics)
|
||||
return [t for t in insights.trends(rows) if t["metric"] in picked]
|
||||
|
||||
|
||||
# --- 健康 --------------------------------------------------------------------
|
||||
def build_health(user_id, subject=None):
|
||||
rows = _summary(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
today = rows[-1]
|
||||
|
||||
deviations = insights.deviations(rows, today)
|
||||
notable = [d for d in deviations
|
||||
if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE]
|
||||
|
||||
highlights = [{
|
||||
"title": d["label"],
|
||||
# The label is repeated inside the sentence because the first
|
||||
# highlight is also used as a standalone headline, where the title
|
||||
# beside it is not shown.
|
||||
"detail": (
|
||||
f"{d['label']} {d['value']}{d['unit']},近 {d['baselineDays']} 天基线 "
|
||||
f"{d['baselineMean']}{d['unit']},偏离 {abs(d['z']):.1f} 个标准差。"
|
||||
),
|
||||
} for d in notable[:5]]
|
||||
if not highlights:
|
||||
highlights = [{"title": "整体", "detail": "今日各项指标均在个人基线的正常波动范围内。"}]
|
||||
|
||||
return today["date"], {
|
||||
"scope": "health",
|
||||
"label": "健康总览",
|
||||
"snapshotDate": today["date"],
|
||||
"highlights": highlights,
|
||||
"deviations": deviations,
|
||||
"trends": insights.trends(rows)[:8],
|
||||
"referenceBands": BAND_SOURCES,
|
||||
"profile": _profile(user_id, rows),
|
||||
}
|
||||
|
||||
|
||||
def _profile(user_id, rows):
|
||||
raw = settings_svc.get_raw(user_id)
|
||||
age = settings_svc.age_from(raw["birth_date"])
|
||||
return {
|
||||
"age": age,
|
||||
"sex": raw["sex"],
|
||||
"bmi": settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]),
|
||||
"vo2max": insights.latest_of(rows, "vo2max"),
|
||||
"enduranceScore": insights.latest_of(rows, "enduranceScore"),
|
||||
}
|
||||
|
||||
|
||||
# --- 睡眠 --------------------------------------------------------------------
|
||||
SLEEP_METRICS = ("sleepDuration", "sleepQuality", "sleepDeepPct", "sleepRemPct")
|
||||
NIGHTS = 14
|
||||
|
||||
|
||||
def build_sleep(user_id, subject=None):
|
||||
rows = _summary(user_id)
|
||||
nights = [r for r in rows if r.get("sleepDuration") is not None]
|
||||
if not nights:
|
||||
return None
|
||||
|
||||
window = _recent(nights, NIGHTS)
|
||||
durations = [n["sleepDuration"] for n in window]
|
||||
avg = _mean(durations)
|
||||
debt = round(sum(insights.SLEEP_TARGET_HOURS - d for d in durations
|
||||
if d < insights.SLEEP_TARGET_HOURS), 1)
|
||||
|
||||
deep = _mean([n.get("sleepDeepPct") for n in window])
|
||||
rem = _mean([n.get("sleepRemPct") for n in window])
|
||||
rem_low, rem_high = insights.REM_REFERENCE_PCT
|
||||
deep_low, _ = insights.DEEP_REFERENCE_PCT
|
||||
|
||||
highlights = [{
|
||||
"title": f"近 {len(window)} 晚",
|
||||
"detail": (
|
||||
f"平均 {avg} 小时(目标 {insights.SLEEP_TARGET_HOURS:g}),"
|
||||
f"累计缺口 {debt} 小时,平均评分 "
|
||||
f"{_mean([n.get('sleepQuality') for n in window])}。"
|
||||
),
|
||||
}]
|
||||
if rem is not None:
|
||||
highlights.append({
|
||||
"title": "REM 占比",
|
||||
"detail": f"平均 {rem}%(参考 {rem_low:g}~{rem_high:g}%)"
|
||||
+ (",偏低。" if rem < rem_low else "。"),
|
||||
})
|
||||
if deep is not None:
|
||||
highlights.append({
|
||||
"title": "深睡占比",
|
||||
"detail": f"平均 {deep}%(参考 ≥{deep_low:g}%)"
|
||||
+ (",偏低。" if deep < deep_low else ",达标。"),
|
||||
})
|
||||
|
||||
return f"{window[-1]['date']}:{len(window)}", {
|
||||
"scope": "sleep",
|
||||
"label": "睡眠",
|
||||
"windowNights": len(window),
|
||||
"highlights": highlights,
|
||||
"nights": [{
|
||||
"date": n["date"],
|
||||
"hours": _round(n.get("sleepDuration")),
|
||||
"score": n.get("sleepQuality"),
|
||||
"deepPct": n.get("sleepDeepPct"),
|
||||
"remPct": n.get("sleepRemPct"),
|
||||
"awakePct": n.get("sleepAwakePct"),
|
||||
"sleepHr": n.get("sleepRespirationAvg"),
|
||||
"sleepStress": n.get("sleepStressAvg"),
|
||||
"spo2": n.get("sleepSpo2Avg"),
|
||||
} for n in window],
|
||||
"averages": {
|
||||
"hours": avg, "debtHours": debt, "deepPct": deep, "remPct": rem,
|
||||
"targetHours": insights.SLEEP_TARGET_HOURS,
|
||||
},
|
||||
"deviations": _deviations_for(rows, SLEEP_METRICS),
|
||||
"trends": _trends_for(rows, SLEEP_METRICS),
|
||||
# The night's autonomic readings are what tell recovery apart from
|
||||
# merely lying down for eight hours.
|
||||
"autonomic": {
|
||||
"restingHr": rows[-1].get("heartRate"),
|
||||
"hrv": rows[-1].get("heartRateVariability"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- 运动 --------------------------------------------------------------------
|
||||
EXERCISE_METRICS = ("intensityMinutes", "steps", "trainingReadiness",
|
||||
"enduranceScore", "vo2max")
|
||||
EXERCISE_DAYS = 30
|
||||
|
||||
|
||||
def build_exercise(user_id, subject=None):
|
||||
rows = _summary(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
end = rows[-1]["date"]
|
||||
start = (datetime.date.fromisoformat(end)
|
||||
- datetime.timedelta(days=EXERCISE_DAYS - 1)).isoformat()
|
||||
activities = health.get_activities(user_id, start, end)
|
||||
|
||||
by_sport = {}
|
||||
for a in activities:
|
||||
sport = a.get("activity_type") or "其他"
|
||||
entry = by_sport.setdefault(sport, {"sessions": 0, "minutes": 0, "calories": 0})
|
||||
entry["sessions"] += 1
|
||||
entry["minutes"] += round((a.get("duration") or 0) / 60)
|
||||
entry["calories"] += a.get("calories") or 0
|
||||
|
||||
window = _recent(rows, EXERCISE_DAYS)
|
||||
intensity = [r.get("intensityMinutes") for r in window]
|
||||
weekly = round((sum(v for v in intensity if v) / max(len(window), 1)) * 7)
|
||||
|
||||
highlights = [{
|
||||
"title": f"近 {EXERCISE_DAYS} 天",
|
||||
"detail": (
|
||||
f"共 {len(activities)} 次运动,合计 "
|
||||
f"{sum(e['minutes'] for e in by_sport.values())} 分钟;"
|
||||
f"强度分钟周均约 {weekly}(WHO 建议 150)。"
|
||||
),
|
||||
}]
|
||||
if by_sport:
|
||||
top = max(by_sport.items(), key=lambda kv: kv[1]["minutes"])
|
||||
highlights.append({
|
||||
"title": "主要项目",
|
||||
"detail": f"{top[0]},{top[1]['sessions']} 次 / {top[1]['minutes']} 分钟。",
|
||||
})
|
||||
readiness = insights.latest_of(rows, "trainingReadiness", within=7)
|
||||
if readiness is not None:
|
||||
highlights.append({"title": "训练准备度", "detail": f"最近一次 {readiness:g}/100。"})
|
||||
|
||||
return f"{end}:{EXERCISE_DAYS}", {
|
||||
"scope": "exercise",
|
||||
"label": "运动",
|
||||
"windowDays": EXERCISE_DAYS,
|
||||
"highlights": highlights,
|
||||
"bySport": by_sport,
|
||||
"weeklyIntensityMinutes": weekly,
|
||||
"sessions": [{
|
||||
"date": a.get("start_time"),
|
||||
"sport": a.get("activity_type"),
|
||||
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||
"distanceKm": _round((a["distance"] / 1000) if a.get("distance") else None),
|
||||
"calories": a.get("calories"),
|
||||
"avgHr": a.get("heart_rate_average"),
|
||||
"maxHr": a.get("heart_rate_max"),
|
||||
} for a in activities[-30:]],
|
||||
"deviations": _deviations_for(rows, EXERCISE_METRICS),
|
||||
"trends": _trends_for(rows, EXERCISE_METRICS),
|
||||
"activityShift": insights.activity_shift(rows),
|
||||
"personalRecords": health.get_personal_records(user_id)[:10],
|
||||
"profile": _profile(user_id, rows),
|
||||
}
|
||||
|
||||
|
||||
# --- 趋势 --------------------------------------------------------------------
|
||||
def build_trends(user_id, subject=None):
|
||||
rows = _summary(user_id)
|
||||
if len(rows) < 20:
|
||||
return None
|
||||
|
||||
trends = insights.trends(rows)
|
||||
moved = sorted(
|
||||
[t for t in trends if t.get("direction")],
|
||||
key=lambda t: abs(t["slopePer30d"] or 0), reverse=True,
|
||||
)
|
||||
highlights = [{
|
||||
"title": t["label"],
|
||||
"detail": (
|
||||
f"{t['days']} 天内由 {t['firstMean']}{t['unit']} 到 "
|
||||
f"{t['lastMean']}{t['unit']}({t['direction']}),"
|
||||
f"斜率约 {t['slopePer30d']}{t['unit']}/30 天。"
|
||||
),
|
||||
} for t in moved[:5]]
|
||||
if not highlights:
|
||||
highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}]
|
||||
|
||||
return f"{rows[-1]['date']}:{len(rows)}", {
|
||||
"scope": "trends",
|
||||
"label": "长期趋势",
|
||||
"highlights": highlights,
|
||||
"trends": trends,
|
||||
"activityShift": insights.activity_shift(rows),
|
||||
"coverage": {
|
||||
"days": len(rows), "first": rows[0]["date"], "last": rows[-1]["date"],
|
||||
},
|
||||
"profile": _profile(user_id, rows),
|
||||
}
|
||||
|
||||
|
||||
# --- 每日 --------------------------------------------------------------------
|
||||
def build_daily(user_id, subject=None):
|
||||
"""One calendar day, including the within-day curves the screen plots."""
|
||||
rows = _summary(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
date = subject or rows[-1]["date"]
|
||||
match = [r for r in rows if r["date"] == date]
|
||||
if not match:
|
||||
return None
|
||||
day = match[0]
|
||||
history = [r for r in rows if r["date"] <= date]
|
||||
|
||||
series = extras.get_daily_series(user_id, date) or {}
|
||||
curves = {}
|
||||
for kind, points in series.items():
|
||||
values = [p[1] for p in points if isinstance(p, (list, tuple)) and p[1] is not None]
|
||||
if values:
|
||||
# The curve itself is thousands of points; its shape is what the
|
||||
# reader can see on the chart, so only the summary is sent.
|
||||
curves[kind] = {"min": min(values), "max": max(values),
|
||||
"mean": _mean(values), "samples": len(values)}
|
||||
|
||||
deviations = insights.deviations(history, day)
|
||||
notable = [d for d in deviations
|
||||
if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE]
|
||||
highlights = [{
|
||||
"title": d["label"],
|
||||
"detail": f"{d['label']} {d['value']}{d['unit']},偏离基线 "
|
||||
f"{abs(d['z']):.1f} 个标准差。",
|
||||
} for d in notable[:4]] or [
|
||||
{"title": date, "detail": "这一天各项指标都在常态范围内。"}
|
||||
]
|
||||
|
||||
return date, {
|
||||
"scope": "daily",
|
||||
"label": f"{date} 当日",
|
||||
"date": date,
|
||||
"highlights": highlights,
|
||||
"metrics": {k: v for k, v in day.items() if k != "date"},
|
||||
"curves": curves,
|
||||
"activities": [{
|
||||
"sport": a.get("activity_type"),
|
||||
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||
"calories": a.get("calories"),
|
||||
"avgHr": a.get("heart_rate_average"),
|
||||
} for a in health.get_activities(user_id, date, date)],
|
||||
"deviations": deviations,
|
||||
}
|
||||
|
||||
|
||||
# --- 身体成分 ----------------------------------------------------------------
|
||||
def build_body(user_id, subject=None):
|
||||
series = extras.get_body_composition(user_id)
|
||||
pressure = extras.get_blood_pressure(user_id)
|
||||
if not series and not pressure:
|
||||
return None
|
||||
|
||||
highlights = []
|
||||
if len(series) >= 2:
|
||||
first, last = series[0], series[-1]
|
||||
for key, label, unit in (("weightKg", "体重", "kg"),
|
||||
("bodyFatPct", "体脂率", "%"),
|
||||
("muscleMassKg", "肌肉量", "kg")):
|
||||
a, b = first.get(key), last.get(key)
|
||||
if a is None or b is None:
|
||||
continue
|
||||
highlights.append({
|
||||
"title": label,
|
||||
"detail": f"{first['date']} {a}{unit} → {last['date']} {b}{unit}"
|
||||
f"({'+' if b >= a else ''}{round(b - a, 1)}{unit})。",
|
||||
})
|
||||
elif series:
|
||||
last = series[-1]
|
||||
highlights.append({
|
||||
"title": "最近一次测量",
|
||||
"detail": f"{last['date']}:体重 {last.get('weightKg')} kg,"
|
||||
f"体脂 {last.get('bodyFatPct')}%。",
|
||||
})
|
||||
if pressure:
|
||||
p = pressure[0]
|
||||
highlights.append({
|
||||
"title": "血压",
|
||||
"detail": f"最近一次 {p['systolic']}/{p['diastolic']} mmHg"
|
||||
f"({str(p['measuredAt'])[:16]})。",
|
||||
})
|
||||
|
||||
subject_key = (series[-1]["date"] if series
|
||||
else str(pressure[0]["measuredAt"])[:10])
|
||||
return subject_key, {
|
||||
"scope": "body",
|
||||
"label": "身体成分",
|
||||
"highlights": highlights,
|
||||
"composition": series[-60:],
|
||||
"bloodPressure": pressure[:20],
|
||||
"profile": _profile(user_id, _summary(user_id)),
|
||||
}
|
||||
|
||||
|
||||
# --- 成绩预测 ----------------------------------------------------------------
|
||||
def build_race(user_id, subject=None):
|
||||
predictions = extras.get_race_predictions(user_id)
|
||||
if not predictions:
|
||||
return None
|
||||
rows = _summary(user_id)
|
||||
latest = predictions[-1]
|
||||
|
||||
def mmss(seconds):
|
||||
if not seconds:
|
||||
return None
|
||||
return f"{int(seconds) // 60}:{int(seconds) % 60:02d}"
|
||||
|
||||
highlights = [{
|
||||
"title": "当前预测",
|
||||
"detail": " · ".join(
|
||||
f"{label} {mmss(latest.get(key))}"
|
||||
for key, label in (("time5k", "5K"), ("time10k", "10K"),
|
||||
("timeHalf", "半马"), ("timeMarathon", "全马"))
|
||||
if latest.get(key)
|
||||
) + f"({latest['date']})。",
|
||||
}]
|
||||
if len(predictions) >= 2:
|
||||
first = predictions[0]
|
||||
a, b = first.get("time5k"), latest.get("time5k")
|
||||
if a and b:
|
||||
delta = int(a - b)
|
||||
highlights.append({
|
||||
"title": "5K 变化",
|
||||
"detail": f"自 {first['date']} 起{'快了' if delta > 0 else '慢了'} "
|
||||
f"{abs(delta)} 秒。",
|
||||
})
|
||||
|
||||
return latest["date"], {
|
||||
"scope": "race",
|
||||
"label": "成绩预测",
|
||||
"highlights": highlights,
|
||||
"predictions": predictions[-40:],
|
||||
"trends": _trends_for(rows, ("vo2max", "enduranceScore", "heartRate")),
|
||||
"profile": _profile(user_id, rows),
|
||||
}
|
||||
|
||||
|
||||
# --- 身体年龄 ----------------------------------------------------------------
|
||||
def build_body_age(user_id, subject=None):
|
||||
rows = _summary(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
raw = settings_svc.get_raw(user_id)
|
||||
age = settings_svc.age_from(raw["birth_date"])
|
||||
estimate = fitness_age.estimate(
|
||||
age=age, sex=raw["sex"], vo2max=insights.latest_of(rows, "vo2max"),
|
||||
resting_hr=insights.latest_of(rows, "heartRate", within=30),
|
||||
bmi=settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]),
|
||||
)
|
||||
if not estimate or estimate.get("value") is None:
|
||||
return None
|
||||
|
||||
highlights = [{
|
||||
"title": "身体年龄",
|
||||
"detail": f"{estimate['value']} 岁,实际 {estimate['chronologicalAge']} 岁"
|
||||
f"({'+' if estimate['delta'] >= 0 else ''}{estimate['delta']} 年)。",
|
||||
}] + [{
|
||||
"title": step["label"],
|
||||
"detail": f"输入 {step['input']},贡献 {step['years']} 年。",
|
||||
} for step in estimate.get("steps", [])]
|
||||
|
||||
return rows[-1]["date"], {
|
||||
"scope": "bodyAge",
|
||||
"label": "身体年龄",
|
||||
"highlights": highlights,
|
||||
"estimate": {k: v for k, v in estimate.items() if k != "basis"},
|
||||
"trends": _trends_for(rows, ("vo2max", "heartRate", "enduranceScore")),
|
||||
"profile": _profile(user_id, rows),
|
||||
}
|
||||
|
||||
|
||||
# --- 挑战赛 ------------------------------------------------------------------
|
||||
def build_challenges(user_id, subject=None):
|
||||
rows = extras.get_challenges(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
active = [c for c in rows if (c.get("status") or "").lower() in
|
||||
("active", "in_progress", "inprogress")]
|
||||
highlights = [{
|
||||
"title": "进行中",
|
||||
"detail": f"共 {len(active)} 项进行中,历史累计 {len(rows)} 项。",
|
||||
}]
|
||||
for c in active[:3]:
|
||||
highlights.append({
|
||||
"title": c.get("name") or "挑战",
|
||||
"detail": f"{c.get('startDate')} ~ {c.get('endDate')},状态 {c.get('status')}。",
|
||||
})
|
||||
|
||||
return f"{rows[0].get('startDate')}:{len(rows)}", {
|
||||
"scope": "challenges",
|
||||
"label": "挑战赛",
|
||||
"highlights": highlights,
|
||||
"challenges": [{k: v for k, v in c.items() if k != "payload"}
|
||||
for c in rows[:40]],
|
||||
"activityShift": insights.activity_shift(_summary(user_id)),
|
||||
}
|
||||
|
||||
|
||||
# --- 运动详情 ----------------------------------------------------------------
|
||||
def build_activity(user_id, subject=None):
|
||||
"""One session. `subject` is the activity id."""
|
||||
if not subject:
|
||||
return None
|
||||
detail = garmin_svc.read_activity_detail(user_id, subject)
|
||||
listed = [a for a in health.get_activities(user_id) if str(a.get("id")) == str(subject)]
|
||||
if not detail and not listed:
|
||||
return None
|
||||
|
||||
summary = (detail or {}).get("summary") or {}
|
||||
base = listed[0] if listed else {}
|
||||
zones = (detail or {}).get("hrZones") or []
|
||||
total_zone = sum(z.get("seconds") or 0 for z in zones)
|
||||
|
||||
minutes = round((base.get("duration") or summary.get("duration") or 0) / 60)
|
||||
highlights = [{
|
||||
"title": base.get("activity_type") or (detail or {}).get("activityType") or "运动",
|
||||
"detail": f"{minutes} 分钟"
|
||||
+ (f",{round((base.get('distance') or 0) / 1000, 2)} km"
|
||||
if base.get("distance") else "")
|
||||
+ (f",平均心率 {base.get('heart_rate_average')}"
|
||||
if base.get("heart_rate_average") else "")
|
||||
+ "。",
|
||||
}]
|
||||
if total_zone:
|
||||
share = " · ".join(
|
||||
f"Z{z['zone']} {round((z.get('seconds') or 0) / total_zone * 100)}%"
|
||||
for z in zones if z.get("seconds")
|
||||
)
|
||||
highlights.append({"title": "心率区间分布", "detail": share + "。"})
|
||||
|
||||
return str(subject), {
|
||||
"scope": "activity",
|
||||
"label": "运动详情",
|
||||
"activityId": str(subject),
|
||||
"highlights": highlights,
|
||||
"session": {
|
||||
"sport": base.get("activity_type"),
|
||||
"startTime": base.get("start_time"),
|
||||
"durationMin": minutes or None,
|
||||
"distanceKm": _round((base["distance"] / 1000) if base.get("distance") else None),
|
||||
"calories": base.get("calories"),
|
||||
"avgHr": base.get("heart_rate_average"),
|
||||
"maxHr": base.get("heart_rate_max"),
|
||||
},
|
||||
"hrZones": zones,
|
||||
# The full summary carries dozens of sport-specific fields; only the
|
||||
# numeric ones are useful to reason over and they are already small.
|
||||
"detailSummary": {k: v for k, v in summary.items()
|
||||
if isinstance(v, (int, float))},
|
||||
"laps": ((detail or {}).get("laps") or [])[:20],
|
||||
}
|
||||
|
||||
|
||||
# --- registry ----------------------------------------------------------------
|
||||
class Scope:
|
||||
__slots__ = ("name", "label", "build", "needs_subject")
|
||||
|
||||
def __init__(self, name, label, build, needs_subject=False):
|
||||
self.name = name
|
||||
self.label = label
|
||||
self.build = build
|
||||
self.needs_subject = needs_subject
|
||||
|
||||
|
||||
SCOPES = {
|
||||
s.name: s for s in (
|
||||
Scope("health", "健康总览", build_health),
|
||||
Scope("sleep", "睡眠", build_sleep),
|
||||
Scope("exercise", "运动", build_exercise),
|
||||
Scope("trends", "长期趋势", build_trends),
|
||||
Scope("daily", "每日数据", build_daily),
|
||||
Scope("body", "身体成分", build_body),
|
||||
Scope("race", "成绩预测", build_race),
|
||||
Scope("bodyAge", "身体年龄", build_body_age),
|
||||
Scope("challenges", "挑战赛", build_challenges),
|
||||
Scope("activity", "运动详情", build_activity, needs_subject=True),
|
||||
)
|
||||
}
|
||||
|
||||
# Scopes worth generating ahead of the user asking, after a sync brings new
|
||||
# data in. `activity` and `daily` are excluded on purpose: they are per-item,
|
||||
# so pre-warming them would queue one job per session or per day rather than
|
||||
# one job.
|
||||
PREFETCH_SCOPES = ("health", "sleep", "exercise", "trends", "body", "race",
|
||||
"bodyAge", "challenges")
|
||||
|
||||
|
||||
def build(user_id, scope, subject=None):
|
||||
"""`(subject, context)` for one screen, or None when there is nothing to say."""
|
||||
entry = SCOPES.get(scope)
|
||||
if not entry:
|
||||
raise KeyError(scope)
|
||||
if entry.needs_subject and not subject:
|
||||
return None
|
||||
return entry.build(user_id, subject)
|
||||
Reference in New Issue
Block a user