feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -332,6 +332,106 @@ export interface TrendPoint {
|
||||
value: number;
|
||||
}
|
||||
|
||||
// --- AI coach -------------------------------------------------------------
|
||||
/** How far one of today's metrics sits from the user's own recent baseline. */
|
||||
export interface Deviation {
|
||||
metric: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
baselineMean: number | null;
|
||||
sd: number | null;
|
||||
baselineDays?: number;
|
||||
z: number | null;
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
export interface TrendSummary {
|
||||
metric: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
days: number;
|
||||
samples: number;
|
||||
firstMean: number;
|
||||
lastMean: number;
|
||||
delta: number;
|
||||
slopePer30d: number | null;
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
/** The computed features a briefing was derived from — the same numbers the
|
||||
* card quotes, so the UI can show them without a second request. */
|
||||
export interface BriefingContext {
|
||||
snapshotDate: string;
|
||||
userProfile: Record<string, number | string | null>;
|
||||
todayMetrics: {
|
||||
sleep: Record<string, number | number[] | null> | null;
|
||||
autonomicNervous: Record<string, number | null>;
|
||||
recovery: Record<string, number | null>;
|
||||
activityToday: Record<string, number | null>;
|
||||
};
|
||||
deviations: Deviation[];
|
||||
trends: TrendSummary[];
|
||||
activityShift: Record<string, {
|
||||
label: string; recentMean: number; priorMean: number; changePct: number | null;
|
||||
}>;
|
||||
recentActivities: Array<Record<string, string | number | null>>;
|
||||
dataQuality: { totalDays: number; firstDate: string; lastDate: string; staleDays: number };
|
||||
}
|
||||
|
||||
export interface Briefing {
|
||||
status: string;
|
||||
headline: string | null;
|
||||
diagnosis: Array<{ title: string; detail: string }>;
|
||||
shortfall: string | null;
|
||||
prescription: {
|
||||
intensity: string | null;
|
||||
hrZone: string | null;
|
||||
suggestion: string | null;
|
||||
durationMin: number | null;
|
||||
avoid: string | null;
|
||||
};
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
/** `source` says who answered: the model, or the rule engine standing in for
|
||||
* it. `pending` means the card on screen is the placeholder and the model's
|
||||
* version is still being generated — poll again. */
|
||||
export interface InsightMeta {
|
||||
source: 'ai' | 'rules' | 'none';
|
||||
model?: string | null;
|
||||
upstream?: string | null;
|
||||
cached?: boolean;
|
||||
pending?: boolean;
|
||||
generating?: boolean;
|
||||
generatedAt?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface BriefingResponse {
|
||||
briefing: Briefing | null;
|
||||
context: BriefingContext | null;
|
||||
meta: InsightMeta;
|
||||
}
|
||||
|
||||
export interface TrendInsight {
|
||||
summary: string | null;
|
||||
drivers: Array<{ factor: string; detail: string }>;
|
||||
caution: string | null;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface TrendInsightResponse {
|
||||
insight: TrendInsight | null;
|
||||
window: Record<string, any> | null;
|
||||
meta: InsightMeta;
|
||||
}
|
||||
|
||||
export interface CopilotTurn {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
|
||||
* with no offset in the string. JavaScript reads such a value as *local* time,
|
||||
@@ -675,6 +775,139 @@ class ApiClient {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- AI coach ---
|
||||
/**
|
||||
* 晨间简报 for one day, plus the computed context behind it.
|
||||
*
|
||||
* Returns immediately. When no stored model answer matches the current data
|
||||
* the reply is the rule-based briefing with `meta.pending`, and the model's
|
||||
* version is generated in the background — call again to pick it up.
|
||||
*/
|
||||
async getBriefing(opts: { date?: string; refresh?: boolean; model?: string } = {}) {
|
||||
const { data } = await this.client.get<BriefingResponse>('/analysis/briefing', {
|
||||
params: {
|
||||
date: opts.date,
|
||||
model: opts.model,
|
||||
...(opts.refresh ? { refresh: 1 } : {}),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Attribution for one metric over a selected span. Blocking: a cold
|
||||
* generation runs well past axios's default timeout. */
|
||||
async getTrendInsight(
|
||||
metric: string, startDate: string, endDate: string, refresh?: boolean
|
||||
) {
|
||||
const { data } = await this.client.get<TrendInsightResponse>(
|
||||
'/analysis/trend-insight',
|
||||
{
|
||||
params: { metric, startDate, endDate, ...(refresh ? { refresh: 1 } : {}) },
|
||||
timeout: 300_000,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Copilot, streamed.
|
||||
*
|
||||
* `fetch` rather than axios or EventSource: axios buffers the whole body
|
||||
* before resolving, and EventSource cannot send an Authorization header —
|
||||
* which would mean moving the JWT into the query string, where it would be
|
||||
* logged by every proxy in the path.
|
||||
*
|
||||
* `onDelta` is called with each fragment as it arrives. Pass `signal` to
|
||||
* abort; the promise resolves with the full text.
|
||||
*/
|
||||
async streamCopilot(
|
||||
question: string,
|
||||
opts: {
|
||||
history?: CopilotTurn[];
|
||||
date?: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
onDelta?: (text: string) => void;
|
||||
} = {}
|
||||
): Promise<{ text: string; upstream: string | null }> {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const resp = await fetch(`${API_BASE_URL}/analysis/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question,
|
||||
history: opts.history ?? [],
|
||||
date: opts.date,
|
||||
model: opts.model,
|
||||
}),
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
let message = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
message = (await resp.json()).error || message;
|
||||
} catch {
|
||||
// A non-JSON error body (a proxy's HTML 502) leaves the status text.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let text = '';
|
||||
let upstream: string | null = null;
|
||||
let failure: string | null = null;
|
||||
|
||||
// SSE frames are separated by a blank line and can split across chunks,
|
||||
// so the tail of the buffer is kept until its terminator arrives.
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let split = buffer.indexOf('\n\n');
|
||||
while (split !== -1) {
|
||||
const frame = buffer.slice(0, split);
|
||||
buffer = buffer.slice(split + 2);
|
||||
split = buffer.indexOf('\n\n');
|
||||
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (!dataLines.length) continue;
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(dataLines.join('\n'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event === 'delta' && payload.text) {
|
||||
text += payload.text;
|
||||
opts.onDelta?.(payload.text);
|
||||
} else if (event === 'done') {
|
||||
upstream = payload.upstream ?? null;
|
||||
} else if (event === 'error') {
|
||||
// Recorded rather than thrown here: the stream still has to be
|
||||
// drained, and the server closes it right after this frame.
|
||||
failure = payload.message || '生成失败';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) throw new Error(failure);
|
||||
return { text, upstream };
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
Reference in New Issue
Block a user