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:
@@ -13,6 +13,7 @@ import db
|
|||||||
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
||||||
from routes import auth, garmin, health, analysis, settings
|
from routes import auth, garmin, health, analysis, settings
|
||||||
from services import scheduler
|
from services import scheduler
|
||||||
|
from services import jobs as ai_jobs
|
||||||
from services import garmin as garmin_svc
|
from services import garmin as garmin_svc
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ def create_app():
|
|||||||
# one of them actually runs a given tick.
|
# one of them actually runs a given tick.
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
# The AI coach's consumer. Same story: the queue is in the database, so
|
||||||
|
# every worker can run one and a given job is still generated once.
|
||||||
|
# Claims held by the previous process are released first — otherwise they
|
||||||
|
# sit in `running` until they time out, which to the screen waiting on one
|
||||||
|
# is indistinguishable from a generation that never finishes.
|
||||||
|
ai_jobs.reset_stale_claims()
|
||||||
|
ai_jobs.start()
|
||||||
|
|
||||||
# In production the built React app is served by this same process, so the
|
# In production the built React app is served by this same process, so the
|
||||||
# deployment is a single port with no reverse proxy to configure. In
|
# deployment is a single port with no reverse proxy to configure. In
|
||||||
# development STATIC_DIR does not exist and the CRA dev server serves the
|
# development STATIC_DIR does not exist and the CRA dev server serves the
|
||||||
|
|||||||
@@ -302,6 +302,31 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
|
|||||||
-- attribution, and `subject` is the day (briefing) or metric+range (trend).
|
-- attribution, and `subject` is the day (briefing) or metric+range (trend).
|
||||||
-- Same reasoning as ai_recommendations above — a generation costs minutes, so
|
-- Same reasoning as ai_recommendations above — a generation costs minutes, so
|
||||||
-- it can never sit inside a page load.
|
-- it can never sit inside a page load.
|
||||||
|
-- The coach's work queue. Generating one insight costs minutes against the
|
||||||
|
-- gateway, so nothing is produced inside a request: screens enqueue, a worker
|
||||||
|
-- consumes. `priority` is what makes the screen the user is actually looking
|
||||||
|
-- at jump ahead of the backfill queued after a sync (lower runs first).
|
||||||
|
--
|
||||||
|
-- `id` is derived from user+kind+subject, so enqueueing the same work twice
|
||||||
|
-- updates one row rather than piling up duplicates — which is what keeps a
|
||||||
|
-- poll every few seconds from queueing a job every few seconds.
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
|
id VARCHAR(160) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
kind VARCHAR(32) NOT NULL,
|
||||||
|
subject VARCHAR(96) NOT NULL,
|
||||||
|
fingerprint VARCHAR(64),
|
||||||
|
priority INT NOT NULL DEFAULT 10,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
holder VARCHAR(64),
|
||||||
|
claimed_at DATETIME,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_insights (
|
CREATE TABLE IF NOT EXISTS ai_insights (
|
||||||
id VARCHAR(160) PRIMARY KEY,
|
id VARCHAR(160) PRIMARY KEY,
|
||||||
user_id VARCHAR(64) NOT NULL,
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from auth import require_auth
|
|||||||
from services import analysis as analysis_svc
|
from services import analysis as analysis_svc
|
||||||
from services import ai as ai_svc
|
from services import ai as ai_svc
|
||||||
from services import insights
|
from services import insights
|
||||||
|
from services import jobs as ai_jobs
|
||||||
|
from services import scopes
|
||||||
|
|
||||||
bp = Blueprint("analysis", __name__)
|
bp = Blueprint("analysis", __name__)
|
||||||
|
|
||||||
@@ -135,3 +137,39 @@ def copilot():
|
|||||||
# until it completes, which would undo the point of streaming it.
|
# until it completes, which would undo the point of streaming it.
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/insight", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def insight():
|
||||||
|
"""One screen's AI reading. `?scope=` names the screen.
|
||||||
|
|
||||||
|
Answers immediately, like the briefing: the computed highlights come back
|
||||||
|
with `meta.pending` while the model's version is generated. Opening a
|
||||||
|
screen queues it at interactive priority, ahead of any backfill still
|
||||||
|
running, so what you are looking at is what the queue works on next.
|
||||||
|
|
||||||
|
`?subject=` identifies the item for per-item screens (`activity` needs an
|
||||||
|
activity id, `daily` takes a date).
|
||||||
|
"""
|
||||||
|
scope = request.args.get("scope")
|
||||||
|
if scope not in scopes.SCOPES:
|
||||||
|
return jsonify({
|
||||||
|
"error": f"不支持的页面: {scope}",
|
||||||
|
"supported": sorted(scopes.SCOPES),
|
||||||
|
}), 400
|
||||||
|
return jsonify(analysis_svc.get_scope_insight(
|
||||||
|
g.user_id, scope,
|
||||||
|
subject=request.args.get("subject") or None,
|
||||||
|
refresh=_flag("refresh"),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/insight/queue", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def insight_queue():
|
||||||
|
"""What the coach still has to generate — for a progress indicator."""
|
||||||
|
return jsonify({
|
||||||
|
"pending": ai_jobs.pending_count(g.user_id),
|
||||||
|
"enabled": ai_jobs.ENABLED,
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask import Blueprint, request, g, jsonify
|
|||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from services import settings as settings_svc
|
from services import settings as settings_svc
|
||||||
|
from services.insights import BAND_SOURCES
|
||||||
from services import fitness_age
|
from services import fitness_age
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__)
|
bp = Blueprint("settings", __name__)
|
||||||
@@ -54,32 +55,3 @@ def rating_basis():
|
|||||||
"AI 只负责解读这些结果,不参与设定任何阈值。"
|
"AI 只负责解读这些结果,不参与设定任何阈值。"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
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": "一般性活动量参考,无权威标准"},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import datetime
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
|
|
||||||
from services import health
|
from services import health
|
||||||
from services import ai as ai_svc
|
from services import ai as ai_svc
|
||||||
from services import coach
|
from services import coach
|
||||||
from services import insights
|
from services import insights
|
||||||
|
from services import jobs
|
||||||
|
from services import scopes
|
||||||
from db import query_all, query_one, execute
|
from db import query_all, query_one, execute
|
||||||
from config import DB_TYPE
|
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])
|
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])
|
||||||
|
|
||||||
|
|
||||||
# --- AI coach: briefing, trend attribution, Copilot -------------------------
|
# --- AI coach: briefing, per-screen insights, attribution, Copilot ----------
|
||||||
# Same caching rationale as the recommendations above, with one addition: a
|
# Same caching rationale as the recommendations above, with one addition: no
|
||||||
# briefing is the first thing on the 今日 screen, so it can never wait on a
|
# screen can wait on a generation, so every one of them answers immediately
|
||||||
# generation. The endpoint answers immediately from the rule engine and the
|
# from the rule engine and queues the model's version, which replaces it on a
|
||||||
# model's version replaces it on a later poll.
|
# later poll. The queue lives in services/jobs.py.
|
||||||
_JOB_LOCK = threading.Lock()
|
|
||||||
_JOBS = set()
|
|
||||||
|
|
||||||
|
|
||||||
def _insight_key(kind, subject):
|
|
||||||
return f"{kind}:{subject}"
|
|
||||||
|
|
||||||
|
|
||||||
def _read_insight(user_id, kind, subject, fingerprint):
|
def _read_insight(user_id, kind, subject, fingerprint):
|
||||||
row = query_one(
|
row = query_one(
|
||||||
"SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
|
"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):
|
def _context_fingerprint(context):
|
||||||
"""Digest of everything the prompt will contain.
|
"""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
|
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)
|
blob = json.dumps(context, ensure_ascii=False, sort_keys=True)
|
||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
|
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):
|
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)
|
||||||
@@ -398,7 +353,6 @@ def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
|
|||||||
|
|
||||||
fingerprint = _context_fingerprint(context)
|
fingerprint = _context_fingerprint(context)
|
||||||
subject = context["snapshotDate"]
|
subject = context["snapshotDate"]
|
||||||
key = _insight_key("briefing", subject)
|
|
||||||
|
|
||||||
if not refresh:
|
if not refresh:
|
||||||
cached = _read_insight(user_id, "briefing", subject, fingerprint)
|
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)},
|
"meta": {"source": "rules", "reason": str(e)},
|
||||||
}
|
}
|
||||||
|
|
||||||
started = _run_in_background(
|
# `pending` in the meta is what tells the client to poll again: the card it
|
||||||
key, lambda: generate_briefing(user_id, context, model)
|
# is showing is the placeholder, not the final answer.
|
||||||
)
|
state = jobs.enqueue(user_id, "briefing", subject, fingerprint,
|
||||||
|
jobs.PRIORITY_INTERACTIVE)
|
||||||
return {
|
return {
|
||||||
"briefing": coach.rule_briefing(context),
|
"briefing": coach.rule_briefing(context),
|
||||||
"context": context,
|
"context": context,
|
||||||
"meta": {
|
"meta": _queued_meta(state, user_id, "briefing", subject),
|
||||||
"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),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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):
|
def clear_insight_cache(user_id, kind=None):
|
||||||
if kind:
|
if kind:
|
||||||
execute(
|
execute(
|
||||||
|
|||||||
@@ -407,3 +407,97 @@ def rule_trend_insight(window):
|
|||||||
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
|
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
|
||||||
"confidence": "low",
|
"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
|
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
|
# 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
|
# verdict; None means the direction is not meaningful on its own (steps on a
|
||||||
# rest day are not a failure).
|
# 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]
|
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 config import DB_TYPE
|
||||||
from db import execute, query_one, query_all
|
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 garmin as garmin_svc
|
||||||
from services import settings as settings_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)
|
out = garmin_svc.sync_data(uid, {}, days=d)
|
||||||
results.append({"user": uid, "status": out.get("status"),
|
results.append({"user": uid, "status": out.get("status"),
|
||||||
"records": out.get("recordsSynced")})
|
"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
|
except Exception as e: # noqa: BLE001 - one account must not stop the rest
|
||||||
results.append({"user": uid, "status": "error", "error": str(e)[:200]})
|
results.append({"user": uid, "status": "error", "error": str(e)[:200]})
|
||||||
return results
|
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)
|
||||||
@@ -13,6 +13,8 @@ from services import ai as ai_svc
|
|||||||
from services import analysis as analysis_svc
|
from services import analysis as analysis_svc
|
||||||
from services import coach
|
from services import coach
|
||||||
from services import insights
|
from services import insights
|
||||||
|
from services import jobs
|
||||||
|
from services import scopes
|
||||||
|
|
||||||
|
|
||||||
def day(date, **metrics):
|
def day(date, **metrics):
|
||||||
@@ -487,27 +489,20 @@ class TestGetBriefing:
|
|||||||
def test_the_non_blocking_path_answers_without_calling_a_model(
|
def test_the_non_blocking_path_answers_without_calling_a_model(
|
||||||
self, month, gateway, monkeypatch
|
self, month, gateway, monkeypatch
|
||||||
):
|
):
|
||||||
|
"""It enqueues instead. No worker runs in the suite, so a model call
|
||||||
|
here would mean the request generated inline."""
|
||||||
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
|
||||||
out = analysis_svc.get_briefing(month["id"])
|
out = analysis_svc.get_briefing(month["id"])
|
||||||
assert out["meta"]["pending"] is True
|
assert out["meta"]["pending"] is True
|
||||||
assert out["briefing"]["status"]
|
assert out["briefing"]["status"]
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
assert jobs.pending_count(month["id"]) == 1
|
||||||
|
|
||||||
def test_one_generation_per_key_no_matter_how_often_it_is_polled(self):
|
def test_polling_does_not_queue_a_job_per_poll(self, month, gateway):
|
||||||
"""The poll runs every few seconds; a generation takes minutes."""
|
"""The screen polls every few seconds; a generation takes minutes."""
|
||||||
started = []
|
|
||||||
# A job that never finishes, so the key stays claimed across polls.
|
|
||||||
blocked = analysis_svc.threading.Event()
|
|
||||||
analysis_svc._run_in_background("test-key", lambda: (
|
|
||||||
started.append(1), blocked.wait(5)
|
|
||||||
))
|
|
||||||
try:
|
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
analysis_svc._run_in_background("test-key", lambda: started.append(1))
|
analysis_svc.get_briefing(month["id"])
|
||||||
assert len(started) == 1
|
assert jobs.pending_count(month["id"]) == 1
|
||||||
finally:
|
|
||||||
blocked.set()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTrendInsight:
|
class TestGetTrendInsight:
|
||||||
@@ -654,10 +649,8 @@ class TestEndpoints:
|
|||||||
assert "可以,注意强度。" in body
|
assert "可以,注意强度。" in body
|
||||||
|
|
||||||
def test_briefing_never_leaks_the_gateway_token(
|
def test_briefing_never_leaks_the_gateway_token(
|
||||||
self, client, auth, month, gateway, monkeypatch
|
self, client, auth, month, gateway
|
||||||
):
|
):
|
||||||
# No real background generation: this suite must not reach the network.
|
|
||||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
|
||||||
body = client.get("/api/analysis/briefing", headers=auth).get_data(as_text=True)
|
body = client.get("/api/analysis/briefing", headers=auth).get_data(as_text=True)
|
||||||
assert "test-token" not in body
|
assert "test-token" not in body
|
||||||
|
|
||||||
@@ -698,9 +691,293 @@ class TestRegenerate:
|
|||||||
analysis_svc.get_briefing(month["id"], wait=True)
|
analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
assert analysis_svc.get_briefing(month["id"])["meta"]["cached"] is True
|
assert analysis_svc.get_briefing(month["id"])["meta"]["cached"] is True
|
||||||
|
|
||||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
|
||||||
analysis_svc.get_briefing(month["id"], refresh=True)
|
analysis_svc.get_briefing(month["id"], refresh=True)
|
||||||
|
|
||||||
after = analysis_svc.get_briefing(month["id"])
|
after = analysis_svc.get_briefing(month["id"])
|
||||||
assert after["meta"].get("cached") is not True
|
assert after["meta"].get("cached") is not True
|
||||||
assert after["meta"]["pending"] is True
|
assert after["meta"]["pending"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- the producer/consumer queue --------------------------------------------
|
||||||
|
class TestJobQueue:
|
||||||
|
def test_enqueue_then_claim(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
|
||||||
|
claimed = jobs._claim_next()
|
||||||
|
assert claimed["kind"] == "sleep"
|
||||||
|
assert claimed["status"] == "pending", "the row read is the pre-claim one"
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "running"
|
||||||
|
|
||||||
|
def test_the_same_work_queued_twice_is_one_row(self, db, user):
|
||||||
|
for _ in range(6):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
assert jobs.pending_count(user["id"]) == 1
|
||||||
|
|
||||||
|
def test_opening_a_screen_promotes_it_ahead_of_the_backfill(self, db, user):
|
||||||
|
for scope in ("health", "exercise", "trends"):
|
||||||
|
jobs.enqueue(user["id"], scope, "s", priority=jobs.PRIORITY_PREFETCH)
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
|
||||||
|
# The user opens 睡眠 while the backfill is still queued.
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
|
||||||
|
assert jobs._claim_next()["kind"] == "sleep"
|
||||||
|
|
||||||
|
def test_priority_is_never_demoted(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "s")["priority"] == \
|
||||||
|
jobs.PRIORITY_INTERACTIVE
|
||||||
|
|
||||||
|
def test_equal_priority_runs_oldest_first(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "health", "s")
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
assert jobs._claim_next()["kind"] == "health"
|
||||||
|
|
||||||
|
def test_a_finished_job_is_not_run_again(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||||
|
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
|
||||||
|
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "done"
|
||||||
|
assert jobs._claim_next() is None
|
||||||
|
|
||||||
|
def test_changed_data_requeues_a_finished_job(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||||
|
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
|
||||||
|
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="xyz") == "pending"
|
||||||
|
|
||||||
|
def test_a_running_job_is_not_restarted_by_a_poll(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
jobs._claim_next()
|
||||||
|
assert jobs.enqueue(user["id"], "sleep", "s") == "running"
|
||||||
|
|
||||||
|
def test_a_claim_left_by_a_dead_worker_is_retried(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
jobs._claim_next()
|
||||||
|
assert jobs._claim_next() is None, "still within the claim window"
|
||||||
|
# Backdate the claim past its timeout, as a killed worker would leave it.
|
||||||
|
stale = (jobs._now() - jobs.datetime.timedelta(
|
||||||
|
seconds=jobs.CLAIM_TIMEOUT_SECONDS + 60))
|
||||||
|
db.execute("UPDATE ai_jobs SET claimed_at = ?", [jobs._iso(stale)])
|
||||||
|
assert jobs._claim_next() is not None
|
||||||
|
|
||||||
|
def test_restart_releases_whatever_was_running(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
jobs._claim_next()
|
||||||
|
jobs.reset_stale_claims()
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "pending"
|
||||||
|
|
||||||
|
def test_a_job_that_keeps_failing_is_eventually_dropped(self, db, user):
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
for _ in range(jobs.MAX_ATTEMPTS):
|
||||||
|
row = jobs._claim_next()
|
||||||
|
assert row is not None
|
||||||
|
jobs._finish(row["id"], "boom")
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
assert jobs._claim_next() is None
|
||||||
|
|
||||||
|
def test_the_runner_failing_does_not_stop_the_queue(self, db, user, monkeypatch):
|
||||||
|
monkeypatch.setattr(jobs, "_runner", lambda *a: (_ for _ in ()).throw(RuntimeError("x")))
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
assert jobs.run_once() is True
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "failed"
|
||||||
|
|
||||||
|
def test_run_once_dispatches_to_the_registered_runner(self, db, user, monkeypatch):
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(jobs, "_runner", lambda u, k, s: seen.append((u, k, s)))
|
||||||
|
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
|
||||||
|
assert jobs.run_once() is True
|
||||||
|
assert seen == [(user["id"], "sleep", "2026-08-30:14")]
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "done"
|
||||||
|
|
||||||
|
def test_run_once_is_a_no_op_on_an_empty_queue(self, db, user, monkeypatch):
|
||||||
|
monkeypatch.setattr(jobs, "_runner", lambda *a: None)
|
||||||
|
assert jobs.run_once() is False
|
||||||
|
|
||||||
|
def test_jobs_are_per_account(self, db, user, make_user):
|
||||||
|
other = make_user("other@example.com")
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s")
|
||||||
|
assert jobs.pending_count(other["id"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-screen scopes ------------------------------------------------------
|
||||||
|
class TestScopes:
|
||||||
|
def test_every_registered_scope_builds_or_declines(self, month):
|
||||||
|
"""No builder may raise on a real account — an empty screen returns
|
||||||
|
None so the caller can stay quiet."""
|
||||||
|
for name in scopes.SCOPES:
|
||||||
|
out = scopes.build(month["id"], name, subject="1" if name == "activity" else None)
|
||||||
|
assert out is None or (isinstance(out, tuple) and len(out) == 2)
|
||||||
|
|
||||||
|
def test_a_scope_with_data_carries_highlights_and_a_subject(self, month):
|
||||||
|
subject, context = scopes.build(month["id"], "sleep")
|
||||||
|
assert subject
|
||||||
|
assert context["scope"] == "sleep"
|
||||||
|
assert context["highlights"]
|
||||||
|
|
||||||
|
def test_contexts_stay_small_enough_to_prompt_with(self, month):
|
||||||
|
for name in ("health", "sleep", "exercise", "trends", "daily"):
|
||||||
|
built = scopes.build(month["id"], name)
|
||||||
|
if not built:
|
||||||
|
continue
|
||||||
|
blob = json.dumps(built[1], ensure_ascii=False)
|
||||||
|
assert len(blob) < 30_000, f"{name} context is {len(blob)} chars"
|
||||||
|
|
||||||
|
def test_an_empty_screen_declines_rather_than_inventing_one(self, db, user):
|
||||||
|
assert scopes.build(user["id"], "body") is None
|
||||||
|
assert scopes.build(user["id"], "race") is None
|
||||||
|
|
||||||
|
def test_per_item_scopes_need_a_subject(self, month):
|
||||||
|
assert scopes.build(month["id"], "activity") is None
|
||||||
|
|
||||||
|
def test_unknown_scope_raises(self, month):
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
scopes.build(month["id"], "nope")
|
||||||
|
|
||||||
|
def test_prefetch_list_excludes_per_item_screens(self):
|
||||||
|
"""Pre-warming `activity` would queue one job per session, not one."""
|
||||||
|
assert "activity" not in scopes.PREFETCH_SCOPES
|
||||||
|
assert "daily" not in scopes.PREFETCH_SCOPES
|
||||||
|
assert set(scopes.PREFETCH_SCOPES) <= set(scopes.SCOPES)
|
||||||
|
|
||||||
|
def test_health_scope_carries_the_bands_the_ui_shows(self, month):
|
||||||
|
_, context = scopes.build(month["id"], "health")
|
||||||
|
assert context["referenceBands"], "the model must not contradict the card"
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopeInsight:
|
||||||
|
def test_unknown_scope_raises(self, month):
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
analysis_svc.get_scope_insight(month["id"], "nope")
|
||||||
|
|
||||||
|
def test_an_empty_screen_reports_it(self, db, user, gateway):
|
||||||
|
out = analysis_svc.get_scope_insight(user["id"], "race")
|
||||||
|
assert out["meta"]["source"] == "none"
|
||||||
|
|
||||||
|
def test_first_visit_answers_from_rules_and_queues_the_model(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
calls = answer(monkeypatch, "{}")
|
||||||
|
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||||
|
assert out["meta"]["pending"] is True
|
||||||
|
# The computed facts, with the first one leading; a screen with a
|
||||||
|
# single fact has a headline and no remaining points.
|
||||||
|
assert out["insight"]["headline"]
|
||||||
|
assert calls == [], "nothing may be generated inside the request"
|
||||||
|
assert jobs.pending_count(month["id"]) == 1
|
||||||
|
|
||||||
|
def test_the_stored_answer_is_served_once_generated(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
reply = json.dumps({
|
||||||
|
"headline": "睡眠偏短。", "points": [{"title": "时长", "detail": "6.5 小时。"}],
|
||||||
|
"actions": ["提前入睡"], "caution": None, "confidence": "medium",
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
answer(monkeypatch, reply)
|
||||||
|
analysis_svc.generate_scope_insight(month["id"], "sleep")
|
||||||
|
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||||
|
assert out["meta"]["source"] == "ai"
|
||||||
|
assert out["meta"]["cached"] is True
|
||||||
|
assert out["insight"]["headline"] == "睡眠偏短。"
|
||||||
|
|
||||||
|
def test_rules_fallback_says_it_has_not_been_interpreted(self, month, gateway):
|
||||||
|
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||||
|
assert "尚未经过模型解读" in out["insight"]["caution"]
|
||||||
|
|
||||||
|
def test_prefetch_queues_every_screen_that_has_data(self, month, gateway):
|
||||||
|
queued = analysis_svc.prefetch_insights(month["id"])
|
||||||
|
assert "briefing" in queued
|
||||||
|
assert "sleep" in queued
|
||||||
|
assert jobs.pending_count(month["id"]) == len(queued)
|
||||||
|
|
||||||
|
def test_the_queue_runner_reaches_a_scope(self, month, gateway, monkeypatch):
|
||||||
|
reply = json.dumps({"headline": "ok", "points": [], "actions": [],
|
||||||
|
"confidence": "low"}, ensure_ascii=False)
|
||||||
|
answer(monkeypatch, reply)
|
||||||
|
subject, _ = scopes.build(month["id"], "sleep")
|
||||||
|
analysis_svc._run_job(month["id"], "sleep", subject)
|
||||||
|
assert analysis_svc.get_scope_insight(month["id"], "sleep")["meta"]["source"] == "ai"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseScopeInsight:
|
||||||
|
def test_valid_reply(self):
|
||||||
|
out = coach.parse_scope_insight(json.dumps({
|
||||||
|
"headline": "睡眠不足。",
|
||||||
|
"points": [{"title": "时长", "detail": "平均 6 小时。"}],
|
||||||
|
"actions": ["提前入睡"], "caution": "样本偏少", "confidence": "high",
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
assert out["confidence"] == "high"
|
||||||
|
assert out["actions"] == ["提前入睡"]
|
||||||
|
|
||||||
|
def test_points_written_as_plain_strings(self):
|
||||||
|
out = coach.parse_scope_insight(json.dumps(
|
||||||
|
{"headline": "h", "points": ["纯文本要点"]}, ensure_ascii=False))
|
||||||
|
assert out["points"][0]["detail"] == "纯文本要点"
|
||||||
|
|
||||||
|
def test_blank_reply_is_rejected(self):
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
coach.parse_scope_insight(json.dumps({"confidence": "high"}))
|
||||||
|
|
||||||
|
def test_reasoning_preamble_is_skipped(self):
|
||||||
|
reply = ("Let me think. Maybe {\"headline\": \"draft\"} ... final:\n"
|
||||||
|
+ json.dumps({"headline": "定稿", "points": []}, ensure_ascii=False))
|
||||||
|
assert coach.parse_scope_insight(reply)["headline"] == "定稿"
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopeEndpoint:
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
assert client.get("/api/analysis/insight?scope=sleep").status_code == 401
|
||||||
|
|
||||||
|
def test_unknown_scope_lists_the_supported_ones(self, client, auth, month):
|
||||||
|
resp = client.get("/api/analysis/insight?scope=nope", headers=auth)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "sleep" in resp.get_json()["supported"]
|
||||||
|
|
||||||
|
def test_answers_immediately(self, client, auth, month):
|
||||||
|
resp = client.get("/api/analysis/insight?scope=sleep", headers=auth)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["insight"]["headline"]
|
||||||
|
|
||||||
|
def test_queue_endpoint_reports_outstanding_work(self, client, auth, month):
|
||||||
|
client.get("/api/analysis/insight?scope=sleep", headers=auth)
|
||||||
|
assert client.get("/api/analysis/insight/queue", headers=auth).get_json()["pending"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutageRecovery:
|
||||||
|
"""A gateway outage must delay an insight, not retire it."""
|
||||||
|
|
||||||
|
def test_a_given_up_job_is_retried_after_the_cooldown(self, db, user):
|
||||||
|
jid = jobs.job_id(user["id"], "sleep", "s")
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||||
|
for _ in range(jobs.MAX_ATTEMPTS):
|
||||||
|
row = jobs._claim_next()
|
||||||
|
jobs._finish(row["id"], "connection refused")
|
||||||
|
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||||
|
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "failed"
|
||||||
|
|
||||||
|
# Past the cooldown, as it would be once the upstream is back.
|
||||||
|
old = jobs._now() - jobs.datetime.timedelta(
|
||||||
|
seconds=jobs.FAILED_RETRY_SECONDS + 60)
|
||||||
|
db.execute("UPDATE ai_jobs SET updated_at = ? WHERE id = ?",
|
||||||
|
[jobs._iso(old), jid])
|
||||||
|
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "pending"
|
||||||
|
assert jobs.status_of(user["id"], "sleep", "s")["attempts"] == 0
|
||||||
|
assert jobs._claim_next() is not None
|
||||||
|
|
||||||
|
def test_the_screen_stops_polling_once_the_queue_gives_up(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
"""`pending` must go false, or the card spins for minutes waiting on an
|
||||||
|
answer that is not coming."""
|
||||||
|
def boom(self, messages, timeout=None, max_tokens=None):
|
||||||
|
raise ai_svc.AIError("upstream unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", boom)
|
||||||
|
|
||||||
|
subject, _ = scopes.build(month["id"], "sleep")
|
||||||
|
for _ in range(jobs.MAX_ATTEMPTS):
|
||||||
|
analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||||
|
jobs.run_once()
|
||||||
|
|
||||||
|
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||||
|
assert out["meta"]["queue"] == "failed"
|
||||||
|
assert out["meta"]["pending"] is False
|
||||||
|
assert out["meta"]["reason"], "the card should be able to say why"
|
||||||
|
assert out["insight"]["headline"], "and still show the computed facts"
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
|
apiClient, Briefing, BriefingContext, InsightMeta,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import { usePolledInsight } from '../lib/insight';
|
||||||
import './AiBriefing.css';
|
import './AiBriefing.css';
|
||||||
|
|
||||||
/* A generation runs for minutes on the gateway's reasoning upstream, so the
|
|
||||||
card shows the rule-based briefing immediately and polls for the model's
|
|
||||||
version. The interval is a compromise: often enough that the swap feels
|
|
||||||
like it belongs to this visit, rare enough that a five-minute generation
|
|
||||||
costs a few dozen requests rather than a few hundred. */
|
|
||||||
const POLL_MS = 8000;
|
|
||||||
const POLL_LIMIT_MS = 6 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Day to brief on. Omit for the newest day on record. */
|
/** Day to brief on. Omit for the newest day on record. */
|
||||||
date?: string;
|
date?: string;
|
||||||
@@ -79,65 +72,17 @@ function Deviations({ context }: { context: BriefingContext }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AiBriefing({ date }: Props) {
|
function AiBriefing({ date }: Props) {
|
||||||
const [briefing, setBriefing] = useState<Briefing | null>(null);
|
|
||||||
const [context, setContext] = useState<BriefingContext | null>(null);
|
|
||||||
const [meta, setMeta] = useState<InsightMeta | null>(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
/* The poll is cleared on unmount and whenever the day changes, so stepping
|
|
||||||
back through dates cannot leave a timer writing into a stale card. */
|
|
||||||
const timer = useRef<number>();
|
|
||||||
const startedAt = useRef(0);
|
|
||||||
|
|
||||||
const load = useCallback(async (refresh?: boolean) => {
|
const load = useCallback(async (refresh?: boolean) => {
|
||||||
try {
|
const resp = await apiClient.getBriefing({ date, refresh });
|
||||||
const data = await apiClient.getBriefing({ date, refresh });
|
return { data: resp.briefing, meta: resp.meta, extra: resp.context };
|
||||||
setBriefing(data.briefing);
|
|
||||||
setContext(data.context);
|
|
||||||
setMeta(data.meta);
|
|
||||||
setError('');
|
|
||||||
return data.meta;
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(errorMessage(err, '获取简报失败'));
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
const cancelled = useRef(false);
|
const {
|
||||||
|
data: briefing, meta, extra, loading, error, refresh,
|
||||||
/* One poll loop, shared by the first load and by 重新生成. Each tick asks
|
} = usePolledInsight<Briefing>(load, '获取简报失败');
|
||||||
without `refresh` — only the first request may bypass the cache, or every
|
const context = extra as BriefingContext | null;
|
||||||
tick would restart the generation it is waiting for. */
|
|
||||||
const poll = useCallback(async (refresh?: boolean) => {
|
|
||||||
window.clearTimeout(timer.current);
|
|
||||||
startedAt.current = Date.now();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const tick = async (first: boolean) => {
|
|
||||||
const result = await load(first && refresh);
|
|
||||||
if (cancelled.current) return;
|
|
||||||
// Stop as soon as a model answer lands, and give up after the window a
|
|
||||||
// generation realistically needs — an upstream that has gone quiet
|
|
||||||
// should not leave the tab polling for the rest of the session.
|
|
||||||
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
|
|
||||||
timer.current = window.setTimeout(() => tick(false), POLL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await tick(true);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelled.current = false;
|
|
||||||
poll();
|
|
||||||
return () => {
|
|
||||||
cancelled.current = true;
|
|
||||||
window.clearTimeout(timer.current);
|
|
||||||
};
|
|
||||||
}, [poll]);
|
|
||||||
|
|
||||||
if (loading && !briefing) {
|
if (loading && !briefing) {
|
||||||
return <div className="brief-skeleton" aria-label="正在生成简报" />;
|
return <div className="brief-skeleton" aria-label="正在生成简报" />;
|
||||||
@@ -197,11 +142,7 @@ function AiBriefing({ date }: Props) {
|
|||||||
{context && <Deviations context={context} />}
|
{context && <Deviations context={context} />}
|
||||||
|
|
||||||
<div className="brief-foot">
|
<div className="brief-foot">
|
||||||
<button
|
<button className="brief-link" onClick={refresh} disabled={loading}>
|
||||||
className="brief-link"
|
|
||||||
onClick={() => poll(true)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? '重新生成中…' : '重新生成'}
|
{loading ? '重新生成中…' : '重新生成'}
|
||||||
</button>
|
</button>
|
||||||
{meta.generatedAt && (
|
{meta.generatedAt && (
|
||||||
|
|||||||
150
client/src/components/AiPanel.css
Normal file
150
client/src/components/AiPanel.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* The AI reading shown on every data screen. Deliberately quieter than the
|
||||||
|
今日 briefing card: that one is the hero of its screen, these sit among the
|
||||||
|
charts they comment on. */
|
||||||
|
.aip {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.85rem 0.9rem 0.6rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
animation: aip-in 0.32s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aip-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-skeleton {
|
||||||
|
height: 104px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
100deg, var(--surface-1) 30%, var(--surface-2) 50%, var(--surface-1) 70%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: aip-shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aip-shimmer {
|
||||||
|
from { background-position: 180% 0; }
|
||||||
|
to { background-position: -80% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-error { color: var(--status-critical); font-size: 0.82rem; }
|
||||||
|
|
||||||
|
.aip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-head .sec-title { margin: 0; }
|
||||||
|
|
||||||
|
/* Always visible: a model reading and a plain calculation look alike on the
|
||||||
|
page, and which one it is changes how much weight it deserves. */
|
||||||
|
.aip-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.14rem 0.42rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-badge-ai {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-badge-pending { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.aip-spinner {
|
||||||
|
width: 0.52rem;
|
||||||
|
height: 0.52rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: aip-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aip-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.aip-headline {
|
||||||
|
margin: 0.4rem 0 0.7rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-point { margin-bottom: 0.55rem; }
|
||||||
|
|
||||||
|
.aip-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.14rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-point p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-actions {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-left: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-actions li {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-caution {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
border-left: 2px solid var(--status-warning);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--grid);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-link:disabled { color: var(--text-muted); cursor: default; }
|
||||||
99
client/src/components/AiPanel.tsx
Normal file
99
client/src/components/AiPanel.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
apiClient, InsightScope, InsightMeta, ScopeInsight,
|
||||||
|
} from '../services/api';
|
||||||
|
import { usePolledInsight } from '../lib/insight';
|
||||||
|
import './AiPanel.css';
|
||||||
|
|
||||||
|
const CONFIDENCE_LABEL: Record<string, string> = {
|
||||||
|
high: '证据充分', medium: '证据一般', low: '证据薄弱',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: InsightScope;
|
||||||
|
/** Identifies the item for per-item screens: an activity id, a date. */
|
||||||
|
subject?: string;
|
||||||
|
/** Heading. Defaults to the generic one. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ meta }: { meta: InsightMeta }) {
|
||||||
|
if (meta.source === 'ai') {
|
||||||
|
return (
|
||||||
|
<span className="aip-badge aip-badge-ai" title={meta.model ?? undefined}>
|
||||||
|
AI 生成{meta.upstream ? ` · ${meta.upstream}` : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (meta.pending) {
|
||||||
|
return (
|
||||||
|
<span className="aip-badge aip-badge-pending">
|
||||||
|
<span className="aip-spinner" aria-hidden="true" />
|
||||||
|
排队生成中
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="aip-badge" title={meta.reason}>直接计算</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One screen's AI reading.
|
||||||
|
*
|
||||||
|
* The same component on every screen: what differs between 睡眠 and 运动 is
|
||||||
|
* entirely in the context the backend builds, so a new screen is one more
|
||||||
|
* `<AiPanel scope="…" />` and a builder — not another card.
|
||||||
|
*
|
||||||
|
* It always renders something. The computed facts appear immediately, and the
|
||||||
|
* model's interpretation replaces them when the coach's queue reaches this
|
||||||
|
* screen — which opening the screen moves to the front of.
|
||||||
|
*/
|
||||||
|
function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const resp = await apiClient.getInsight(scope, { subject });
|
||||||
|
return { data: resp.insight, meta: resp.meta };
|
||||||
|
}, [scope, subject]);
|
||||||
|
|
||||||
|
const { data, meta, loading, error, refresh } =
|
||||||
|
usePolledInsight<ScopeInsight>(load);
|
||||||
|
|
||||||
|
if (loading && !data) return <div className="aip-skeleton" aria-label="正在读取解读" />;
|
||||||
|
if (error) return <section className="aip aip-error">{error}</section>;
|
||||||
|
if (!data || !meta) return null;
|
||||||
|
// Nothing to read yet on this screen — a card saying so is worse than none.
|
||||||
|
if (meta.source === 'none') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="aip">
|
||||||
|
<div className="aip-head">
|
||||||
|
<h3 className="sec-title">{title}</h3>
|
||||||
|
<Badge meta={meta} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.headline && <p className="aip-headline">{data.headline}</p>}
|
||||||
|
|
||||||
|
{data.points.map((p) => (
|
||||||
|
<div className="aip-point" key={p.title + p.detail}>
|
||||||
|
<span className="aip-tag">{p.title}</span>
|
||||||
|
<p>{p.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!!data.actions.length && (
|
||||||
|
<ul className="aip-actions">
|
||||||
|
{data.actions.map((a) => <li key={a}>{a}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.caution && <p className="aip-caution">{data.caution}</p>}
|
||||||
|
|
||||||
|
<div className="aip-foot">
|
||||||
|
<button className="aip-link" onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? '重新生成中…' : '重新生成'}
|
||||||
|
</button>
|
||||||
|
<span>{CONFIDENCE_LABEL[data.confidence]}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AiPanel;
|
||||||
99
client/src/lib/insight.ts
Normal file
99
client/src/lib/insight.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { errorMessage, InsightMeta } from '../services/api';
|
||||||
|
|
||||||
|
/* A generation runs for minutes on the gateway, so every AI card shows the
|
||||||
|
rule-based version first and polls for the model's. The interval is a
|
||||||
|
compromise: often enough that the swap belongs to this visit, rare enough
|
||||||
|
that a five-minute wait costs a few dozen requests rather than a few
|
||||||
|
hundred. */
|
||||||
|
export const POLL_MS = 8000;
|
||||||
|
export const POLL_LIMIT_MS = 8 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface Loaded<T> {
|
||||||
|
data: T | null;
|
||||||
|
meta: InsightMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Polled<T> {
|
||||||
|
data: T | null;
|
||||||
|
meta: InsightMeta | null;
|
||||||
|
/** Extra payload the loader returned alongside the insight (the context). */
|
||||||
|
extra: unknown;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
/** Discard the stored answer and generate again. */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an AI insight, then keep polling while the model is still working.
|
||||||
|
*
|
||||||
|
* Shared by every AI card. `load` must be stable — wrap it in `useCallback`
|
||||||
|
* keyed on whatever identifies the thing being read — because a new identity
|
||||||
|
* restarts the poll, which is exactly right when the screen changes what it
|
||||||
|
* is about and exactly wrong on an unrelated re-render.
|
||||||
|
*/
|
||||||
|
export function usePolledInsight<T>(
|
||||||
|
load: (refresh?: boolean) => Promise<Loaded<T> & { extra?: unknown }>,
|
||||||
|
fallbackMessage = '获取 AI 解读失败'
|
||||||
|
): Polled<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [meta, setMeta] = useState<InsightMeta | null>(null);
|
||||||
|
const [extra, setExtra] = useState<unknown>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const timer = useRef<number>();
|
||||||
|
const startedAt = useRef(0);
|
||||||
|
const cancelled = useRef(false);
|
||||||
|
|
||||||
|
const fetchOnce = useCallback(async (refresh?: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await load(refresh);
|
||||||
|
if (cancelled.current) return null;
|
||||||
|
setData(result.data);
|
||||||
|
setMeta(result.meta);
|
||||||
|
setExtra(result.extra ?? null);
|
||||||
|
setError('');
|
||||||
|
return result.meta;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled.current) setError(errorMessage(err, fallbackMessage));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (!cancelled.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [load, fallbackMessage]);
|
||||||
|
|
||||||
|
const poll = useCallback(async (refresh?: boolean) => {
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Only the first request may bypass the cache; a `refresh` on every tick
|
||||||
|
// would restart the generation the poll is waiting for.
|
||||||
|
const tick = async (first: boolean) => {
|
||||||
|
const result = await fetchOnce(first && refresh);
|
||||||
|
if (cancelled.current) return;
|
||||||
|
// Give up after the window a generation realistically needs — an
|
||||||
|
// upstream that has gone quiet must not leave the tab polling forever.
|
||||||
|
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
|
||||||
|
timer.current = window.setTimeout(() => tick(false), POLL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await tick(true);
|
||||||
|
}, [fetchOnce]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelled.current = false;
|
||||||
|
poll();
|
||||||
|
return () => {
|
||||||
|
cancelled.current = true;
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [poll]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data, meta, extra, loading, error,
|
||||||
|
refresh: () => { poll(true); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link } from 'framework7-react';
|
import { Link } from 'framework7-react';
|
||||||
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
|
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './ActivityDetail.css';
|
import './ActivityDetail.css';
|
||||||
@@ -289,6 +291,7 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
|
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
|
||||||
|
{FEATURES.ai && <AiPanel scope="activity" subject={activityId} title="AI 本次运动解读" />}
|
||||||
<div className="metric-tabs">
|
<div className="metric-tabs">
|
||||||
{TABS.map(([tabId, text]) => (
|
{TABS.map(([tabId, text]) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'framework7-react';
|
import { Link } from 'framework7-react';
|
||||||
import { apiClient, errorMessage, FitnessAge } from '../services/api';
|
import { apiClient, errorMessage, FitnessAge } from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './BodyAge.css';
|
import './BodyAge.css';
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ function BodyAgePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{FEATURES.ai && <AiPanel scope="bodyAge" title="AI 身体年龄解读" />}
|
||||||
|
|
||||||
<section className="ba-hero">
|
<section className="ba-hero">
|
||||||
<div className="ba-value">
|
<div className="ba-value">
|
||||||
{data.value}<span className="ba-unit">岁</span>
|
{data.value}<span className="ba-unit">岁</span>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
|
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
@@ -71,6 +73,8 @@ function BodyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{FEATURES.ai && <AiPanel scope="body" title="AI 身体成分解读" />}
|
||||||
|
|
||||||
<section className="md-hero">
|
<section className="md-hero">
|
||||||
<div className="md-value">
|
<div className="md-value">
|
||||||
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
|
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link } from 'framework7-react';
|
import { Link } from 'framework7-react';
|
||||||
import { apiClient, Challenge, errorMessage } from '../services/api';
|
import { apiClient, Challenge, errorMessage } from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Challenges.css';
|
import './Challenges.css';
|
||||||
|
|
||||||
@@ -54,6 +56,8 @@ function ChallengesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{FEATURES.ai && <AiPanel scope="challenges" title="AI 挑战赛解读" />}
|
||||||
|
|
||||||
{kinds.length > 2 && (
|
{kinds.length > 2 && (
|
||||||
<div className="metric-tabs">
|
<div className="metric-tabs">
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Chart from '../components/charts/Chart';
|
|||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Daily.css';
|
import './Daily.css';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import { iso, today as todayIso } from '../lib/day';
|
import { iso, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
/** Every stored metric, grouped the way the device groups them. */
|
/** Every stored metric, grouped the way the device groups them. */
|
||||||
@@ -189,6 +191,7 @@ function DailyPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="每日数据" backLink>
|
<Screen title="每日数据" backLink>
|
||||||
|
{FEATURES.ai && <AiPanel scope="daily" subject={date} title="AI 当日解读" />}
|
||||||
|
|
||||||
<div className="day-nav">
|
<div className="day-nav">
|
||||||
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天">‹</button>
|
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天">‹</button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import MetricCard from '../components/charts/MetricCard';
|
import MetricCard from '../components/charts/MetricCard';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
@@ -126,6 +128,8 @@ function ExercisePage() {
|
|||||||
<Screen title="运动" subtitle={`近 ${WINDOW_DAYS} 天`}>
|
<Screen title="运动" subtitle={`近 ${WINDOW_DAYS} 天`}>
|
||||||
{error && <div className="screen-error">{error}</div>}
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{FEATURES.ai && <AiPanel scope="exercise" title="AI 训练解读" />}
|
||||||
|
|
||||||
<section className="sec">
|
<section className="sec">
|
||||||
<div className="mcard-grid">
|
<div className="mcard-grid">
|
||||||
<MetricCard label="运动时长" value={totalMinutes} unit="分钟"
|
<MetricCard label="运动时长" value={totalMinutes} unit="分钟"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { METRICS, metricHref } from '../lib/metrics';
|
|||||||
import MetricCard from '../components/charts/MetricCard';
|
import MetricCard from '../components/charts/MetricCard';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import './Health.css';
|
import './Health.css';
|
||||||
import './Settings.css';
|
import './Settings.css';
|
||||||
@@ -143,6 +145,8 @@ function HealthPage() {
|
|||||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
<BodyAge data={bodyAge} />
|
<BodyAge data={bodyAge} />
|
||||||
|
|
||||||
|
{FEATURES.ai && <AiPanel scope="health" title="AI 健康解读" />}
|
||||||
|
|
||||||
{SECTIONS.map((section) => (
|
{SECTIONS.map((section) => (
|
||||||
<section className="sec" key={section.title}>
|
<section className="sec" key={section.title}>
|
||||||
<h3 className="sec-title">{section.title}</h3>
|
<h3 className="sec-title">{section.title}</h3>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'framework7-react';
|
import { Link } from 'framework7-react';
|
||||||
import { apiClient, errorMessage, RacePrediction } from '../services/api';
|
import { apiClient, errorMessage, RacePrediction } from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './MetricDetail.css';
|
import './MetricDetail.css';
|
||||||
@@ -75,6 +77,8 @@ function RacePage() {
|
|||||||
<Screen title="成绩预测" backLink subtitle={latest.date}>
|
<Screen title="成绩预测" backLink subtitle={latest.date}>
|
||||||
{error && <div className="screen-error">{error}</div>}
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{FEATURES.ai && <AiPanel scope="race" title="AI 成绩解读" />}
|
||||||
|
|
||||||
<div className="race-list">
|
<div className="race-list">
|
||||||
{DISTANCES.map(([key, label]) => (
|
{DISTANCES.map(([key, label]) => (
|
||||||
<div className="race-row" key={key}>
|
<div className="race-row" key={key}>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Chart from '../components/charts/Chart';
|
|||||||
import StatTile from '../components/charts/StatTile';
|
import StatTile from '../components/charts/StatTile';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
const RANGES = [7, 14, 30, 90];
|
const RANGES = [7, 14, 30, 90];
|
||||||
@@ -75,6 +77,7 @@ function SleepPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
|
<Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
|
||||||
|
{FEATURES.ai && <AiPanel scope="sleep" title="AI 睡眠解读" />}
|
||||||
|
|
||||||
<div className="segmented-row">
|
<div className="segmented-row">
|
||||||
<span className="segmented-label">范围</span>
|
<span className="segmented-label">范围</span>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||||
} from '../lib/aggregate';
|
} from '../lib/aggregate';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import AiPanel from '../components/AiPanel';
|
||||||
|
import { FEATURES } from '../features';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
@@ -221,6 +223,7 @@ function TrendsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="趋势">
|
<Screen title="趋势">
|
||||||
|
{FEATURES.ai && <AiPanel scope="trends" title="AI 长期趋势归因" />}
|
||||||
|
|
||||||
<div className="segmented-row">
|
<div className="segmented-row">
|
||||||
<span className="segmented-label">范围</span>
|
<span className="segmented-label">范围</span>
|
||||||
|
|||||||
@@ -427,6 +427,27 @@ export interface TrendInsightResponse {
|
|||||||
meta: InsightMeta;
|
meta: InsightMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One screen's AI reading. The same shape for every screen, so one component
|
||||||
|
* renders all of them. */
|
||||||
|
export interface ScopeInsight {
|
||||||
|
headline: string | null;
|
||||||
|
points: Array<{ title: string; detail: string }>;
|
||||||
|
actions: string[];
|
||||||
|
caution: string | null;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeInsightResponse {
|
||||||
|
insight: ScopeInsight | null;
|
||||||
|
context: Record<string, any> | null;
|
||||||
|
meta: InsightMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screens the coach can read. Mirrors SCOPES in backend/services/scopes.py. */
|
||||||
|
export type InsightScope =
|
||||||
|
| 'health' | 'sleep' | 'exercise' | 'trends' | 'daily'
|
||||||
|
| 'body' | 'race' | 'bodyAge' | 'challenges' | 'activity';
|
||||||
|
|
||||||
export interface CopilotTurn {
|
export interface CopilotTurn {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
@@ -777,6 +798,34 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- AI coach ---
|
// --- AI coach ---
|
||||||
|
/**
|
||||||
|
* One screen's AI reading.
|
||||||
|
*
|
||||||
|
* Answers immediately: while the model's version is being generated the
|
||||||
|
* computed highlights come back with `meta.pending`, and opening the screen
|
||||||
|
* puts its job at the front of the coach's queue. Poll to pick up the
|
||||||
|
* finished version.
|
||||||
|
*/
|
||||||
|
async getInsight(
|
||||||
|
scope: InsightScope,
|
||||||
|
opts: { subject?: string; refresh?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const { data } = await this.client.get<ScopeInsightResponse>('/analysis/insight', {
|
||||||
|
params: {
|
||||||
|
scope, subject: opts.subject, ...(opts.refresh ? { refresh: 1 } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much the coach still has to generate — for a progress hint. */
|
||||||
|
async getInsightQueue() {
|
||||||
|
const { data } = await this.client.get<{ pending: number; enabled: boolean }>(
|
||||||
|
'/analysis/insight/queue'
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 晨间简报 for one day, plus the computed context behind it.
|
* 晨间简报 for one day, plus the computed context behind it.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user