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:
@@ -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 && (
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user