原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 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>
100 lines
3.1 KiB
TypeScript
100 lines
3.1 KiB
TypeScript
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;
|