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:
ericwyuan
2026-09-01 15:17:17 +08:00
parent 984a06a098
commit 241ae0d6a3
26 changed files with 1948 additions and 182 deletions

View File

@@ -1,17 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import {
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
apiClient, Briefing, BriefingContext, InsightMeta,
} from '../services/api';
import { usePolledInsight } from '../lib/insight';
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 {
/** Day to brief on. Omit for the newest day on record. */
date?: string;
@@ -79,65 +72,17 @@ function Deviations({ context }: { context: BriefingContext }) {
}
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);
/* 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) => {
try {
const data = await apiClient.getBriefing({ date, refresh });
setBriefing(data.briefing);
setContext(data.context);
setMeta(data.meta);
setError('');
return data.meta;
} catch (err: any) {
setError(errorMessage(err, '获取简报失败'));
return null;
} finally {
setLoading(false);
}
const resp = await apiClient.getBriefing({ date, refresh });
return { data: resp.briefing, meta: resp.meta, extra: resp.context };
}, [date]);
const cancelled = useRef(false);
/* One poll loop, shared by the first load and by 重新生成. Each tick asks
without `refresh` — only the first request may bypass the cache, or every
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]);
const {
data: briefing, meta, extra, loading, error, refresh,
} = usePolledInsight<Briefing>(load, '获取简报失败');
const context = extra as BriefingContext | null;
if (loading && !briefing) {
return <div className="brief-skeleton" aria-label="正在生成简报" />;
@@ -197,11 +142,7 @@ function AiBriefing({ date }: Props) {
{context && <Deviations context={context} />}
<div className="brief-foot">
<button
className="brief-link"
onClick={() => poll(true)}
disabled={loading}
>
<button className="brief-link" onClick={refresh} disabled={loading}>
{loading ? '重新生成中…' : '重新生成'}
</button>
{meta.generatedAt && (

View 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; }

View 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;