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:
ericwyuan
2026-09-01 13:57:35 +08:00
parent a746327560
commit c57c930949
21 changed files with 3677 additions and 22 deletions

View File

@@ -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();