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:
@@ -427,6 +427,27 @@ export interface TrendInsightResponse {
|
||||
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 {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
@@ -777,6 +798,34 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user