Files
GarminHealthLab/client/src/services/api.ts
ericwyuan c57c930949 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>
2026-09-01 13:57:35 +08:00

914 lines
25 KiB
TypeScript

import axios, { AxiosInstance } from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const TOKEN_KEY = 'ghl_token';
/** Fired on this tab whenever the session is created or cleared. */
export const AUTH_EVENT = 'ghl:auth';
// The Flask backend returns bare JSON (an array, or the object itself) and
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
// {success, data} envelope, so responses are read as `response.data` directly.
export interface AuthResponse {
id: string;
username: string;
token: string;
}
export interface SleepDetail {
duration: number;
quality: number | null;
deepSeconds: number | null;
lightSeconds: number | null;
remSeconds: number | null;
awakeSeconds: number | null;
}
/** One day, with every metric the sync stores. All are nullable: a device
* that does not record a metric leaves it null rather than zero. */
export interface HealthDay {
date: string;
steps: number | null;
stepGoal: number | null;
distanceMeters: number | null;
caloriesBurned: number | null;
activeCalories: number | null;
bmrCalories: number | null;
floorsAscended: number | null;
floorsDescended: number | null;
intensityMinutes: number | null;
sedentarySeconds: number | null;
activeSeconds: number | null;
heartRate: number | null;
heartRateMax: number | null;
heartRateMin: number | null;
heartRateVariability: number | null;
stress: number | null;
stressMax: number | null;
bodyBatteryHigh: number | null;
bodyBatteryLow: number | null;
bodyBatteryCharged: number | null;
bodyBatteryDrained: number | null;
spo2Avg: number | null;
spo2Min: number | null;
respirationAvg: number | null;
respirationMin: number | null;
respirationMax: number | null;
sleepDuration: number | null;
sleepQuality: number | null;
sleepSpo2Avg: number | null;
sleepRespirationAvg: number | null;
sleepStressAvg: number | null;
trainingReadiness: number | null;
vo2max: number | null;
hillScore: number | null;
hydrationMl: number | null;
hydrationGoalMl: number | null;
sweatLossMl: number | null;
weightKg: number | null;
bodyFatPct: number | null;
bmi: number | null;
enduranceScore: number | null;
sleep: SleepDetail | null;
}
export interface Badge {
id: string;
badge_key: string | null;
name: string | null;
category_id: number | null;
difficulty_id: number | null;
earned_date: string | null;
earned_count: number | null;
points: number | null;
}
export interface PersonalRecord {
id: string;
type_id: number | null;
activity_id: string | null;
activity_name: string | null;
activity_type: string | null;
value: number | null;
achieved_at: string | null;
}
export interface Activity {
id: string;
activity_type: string;
start_time: string;
end_time: string;
duration: number | null;
distance: number | null;
calories: number | null;
heart_rate_average: number | null;
heart_rate_max: number | null;
}
export interface SyncStatus {
status: 'idle' | 'syncing' | 'error' | 'rate_limited';
lastSyncTime: string | null;
recordsSynced: number;
totalDays: number;
lastError: string | null;
/** Days completed / requested. A backfill runs for many minutes. */
progressCurrent: number | null;
progressTotal: number | null;
startedAt: string | null;
/** Which part of the sync is running, e.g. 每日数据 2026-08-01. A bare
* "0 / 730 天" says nothing about what is happening for several minutes. */
stage?: string | null;
}
export interface SyncResult {
status: 'success' | 'error' | 'rate_limited';
recordsSynced: number;
activitiesSynced?: number;
message: string;
/** Set when the account has two-factor auth and no token is stored yet. */
mfaRequired?: boolean;
lastSyncTime: string;
}
export interface Recommendation {
id: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
source?: 'ai';
}
export interface AiRecommendations {
recommendations: Recommendation[];
meta: {
source: 'ai' | 'rules';
model: string | null;
provider?: string;
upstream?: string | null;
days?: number;
fallbackFrom?: string[];
reason?: string;
/** True when served from the stored answer rather than freshly generated. */
cached?: boolean;
generatedAt?: string;
};
}
export type GarminLoginStatus =
| 'starting'
| 'awaiting_code'
| 'finishing'
| 'done'
| 'failed';
export interface ModelInfo {
id: string;
model: string;
provider: string;
contextWindow: number;
configured: boolean;
default: boolean;
}
export interface UserSettings {
heightCm: number | null;
weightKg: number | null;
birthDate: string | null;
sex: 'male' | 'female' | 'other' | null;
units: 'metric' | 'imperial';
autoSync: boolean;
autoSyncMinutes: number;
/** 0 means "everything Garmin has". */
historyDays: number;
age: number | null;
bmi: number | null;
}
export interface SettingsOptions {
sexes: string[];
units: string[];
autoSyncMinutes: number[];
historyDays: number[];
}
export interface BasisStep {
name: string;
detail: string;
source: string;
}
export interface RatingBasis {
fitnessAge: {
title: string;
summary: string;
steps: BasisStep[];
caveat: string;
};
bands: Array<{ metric: string; bands: string; source: string }>;
note: string;
}
export interface FitnessAge {
value: number | null;
chronologicalAge?: number;
delta?: number;
clamped?: boolean;
steps?: Array<{
label: string; input: string; years: number; kind: string;
/** Base step only: the undamped figure, and the factor applied to it. */
raw?: number; damping?: number;
}>;
missing: string[];
basis: RatingBasis['fitnessAge'];
}
export interface DetailSyncStatus {
running: boolean;
/** Which part of the backfill is running: 运动详情 / 每日曲线. */
stage?: string | null;
done: number;
total: number;
error?: string | null;
}
export interface BodyCompositionDay {
date: string;
weightKg: number | null;
bmi: number | null;
bodyFatPct: number | null;
bodyWaterPct: number | null;
boneMassKg: number | null;
muscleMassKg: number | null;
physiqueRating: number | null;
visceralFat: number | null;
metabolicAge: number | null;
}
export interface BloodPressureReading {
measuredAt: string;
systolic: number | null;
diastolic: number | null;
pulse: number | null;
note: string | null;
}
/** Predicted finishing times, in seconds. */
export interface RacePrediction {
date: string;
time5k: number | null;
time10k: number | null;
timeHalf: number | null;
timeMarathon: number | null;
}
/** [timestamp, value] pairs, thinned to at most 240 points per day. */
export type DailySeries = Record<string, Array<[string, number]>>;
export interface Challenge {
uuid: string;
kind: string;
name: string | null;
status: string | null;
startDate: string | null;
endDate: string | null;
payload: Record<string, any>;
}
export interface Device {
deviceId: string;
name: string | null;
model: string | null;
serial: string | null;
softwareVersion: string | null;
lastUsedAt: string | null;
}
export interface AutoSyncStatus {
enabled: boolean;
intervalSeconds: number;
tickSeconds: number;
days: number;
lastRunAt: string | null;
nextRunAt: string | null;
running: boolean;
account?: {
autoSync: boolean;
intervalMinutes: number;
dueAt: string | null;
};
}
/** One activity in full. Shapes mirror Garmin's own payload, which is why the
* summary is left loosely typed — it carries dozens of optional fields that
* differ by sport. */
export interface ActivityDetail {
activityId: string;
activityName: string | null;
activityType: string | null;
summary: Record<string, number | string | null>;
laps: Array<{
index: number;
duration: number | null;
movingDuration: number | null;
distance: number | null;
averageSpeed: number | null;
maxSpeed: number | null;
calories: number | null;
averageHR: number | null;
maxHR: number | null;
elevationGain: number | null;
elevationLoss: number | null;
}>;
hrZones: Array<{ zone: number; seconds: number; lowBoundary: number | null }>;
weather: Record<string, any>;
gear: Array<Record<string, any>>;
exerciseSets: Array<Record<string, any>>;
series: Record<string, Array<number | null>>;
cached: boolean;
}
export interface TrendPoint {
date: string;
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,
* which showed sync times eight hours in the past here.
*/
export function parseUtc(value: string | null | undefined): Date | null {
if (!value) return null;
const normalised = value.replace(' ', 'T');
const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised)
? normalised
: `${normalised}Z`;
const date = new Date(withZone);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Pull a human-readable message out of an axios error. */
export function errorMessage(err: any, fallback = '请求失败'): string {
return err?.response?.data?.error || err?.message || fallback;
}
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
});
// Attach the saved JWT to every request.
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// On 401 drop the session and bounce to login, so an expired token does
// not leave the user staring at empty pages.
this.client.interceptors.response.use(
(resp) => resp,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY);
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
// --- session ---
/* `storage` only fires in *other* tabs, so a same-tab login or logout needs
its own signal for the shell to notice. */
private announce() {
window.dispatchEvent(new Event(AUTH_EVENT));
}
setSession(token: string) {
localStorage.setItem(TOKEN_KEY, token);
this.announce();
}
clearSession() {
localStorage.removeItem(TOKEN_KEY);
this.announce();
}
isAuthenticated(): boolean {
return Boolean(localStorage.getItem(TOKEN_KEY));
}
// --- auth ---
async logout() {
try {
await this.client.post('/auth/logout');
} finally {
this.clearSession();
}
}
// --- auth-hub OAuth2 ---
async authHubStart() {
const { data } = await this.client.post<{
auth_url: string;
code_verifier: string;
state: string;
}>('/auth/auth-hub/start', {});
return data;
}
async authHubCallback(code: string, codeVerifier: string) {
const { data } = await this.client.get<AuthResponse>('/auth/callback', {
params: { code, code_verifier: codeVerifier }
});
return data;
}
// --- garmin ---
/**
* With a stored OAuth token no password is needed. Without one, the
* plaintext password must be supplied because only a hash is kept — and an
* MFA-protected account cannot log in this way at all (see garmin_login.py).
*/
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
async syncGarminData(days?: number, garminPassword?: string) {
const { data } = await this.client.post<{
status: string; days?: number; message?: string; retryAfterSeconds?: number;
}>(
'/garmin/sync',
{
// `days` must survive being 0 — that is "全部历史", not "unset".
// `days ? …` dropped it, so the backend fell back to its 7-day
// default and a full backfill silently pulled a week.
...(days === undefined || days === null ? {} : { days }),
...(garminPassword ? { garminPassword } : {}),
}
);
return data;
}
/** Whether a stored Garmin token exists (then sync needs no password). */
async getGarminAuthStatus() {
const { data } = await this.client.get<{ hasToken: boolean }>(
'/garmin/auth-status'
);
return data.hasToken;
}
/** Drop the stored Garmin OAuth token; the next sync must re-authenticate. */
async disconnectGarmin() {
const { data } = await this.client.post<{ ok: boolean; message?: string }>(
'/garmin/disconnect'
);
return data;
}
/**
* Start an interactive Garmin login. Returns a session id; the login runs
* in the background and parks if Garmin asks for a two-factor code.
*/
async startGarminLogin(garminPassword: string, garminEmail?: string) {
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
garminPassword,
...(garminEmail ? { garminEmail } : {}),
});
return data.session;
}
async getGarminLoginStatus(session: string) {
const { data } = await this.client.get<{
session: string;
status: GarminLoginStatus;
error: string | null;
}>('/garmin/login-status', { params: { session } });
return data;
}
async submitGarminMfa(session: string, code: string) {
const { data } = await this.client.post<{ ok: boolean; message: string }>(
'/garmin/mfa',
{ session, code }
);
return data;
}
async cancelGarminLogin(session: string) {
await this.client.delete('/garmin/login', { params: { session } });
}
async getGarminSyncStatus() {
const { data } = await this.client.get<SyncStatus>('/garmin/status');
return data;
}
// --- health ---
private range(startDate?: string, endDate?: string) {
return { params: { startDate, endDate } };
}
async getHealthSummary(startDate?: string, endDate?: string) {
const { data } = await this.client.get<HealthDay[]>(
'/health/summary',
this.range(startDate, endDate)
);
return data;
}
async getActivities(startDate?: string, endDate?: string) {
const { data } = await this.client.get<Activity[]>(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
// --- settings ---
async getSettings() {
const { data } = await this.client.get<UserSettings>('/settings');
return data;
}
async saveSettings(patch: Partial<UserSettings>) {
const { data } = await this.client.put<UserSettings>('/settings', patch);
return data;
}
async getSettingsOptions() {
const { data } = await this.client.get<SettingsOptions>('/settings/options');
return data;
}
async getRatingBasis() {
const { data } = await this.client.get<RatingBasis>('/settings/rating-basis');
return data;
}
async getFitnessAge() {
const { data } = await this.client.get<FitnessAge>('/health/fitness-age');
return data;
}
async getAutoSyncStatus() {
const { data } = await this.client.get<AutoSyncStatus>('/garmin/auto-sync');
return data;
}
/** Pull the last few days inline — fast enough to await, unlike a backfill. */
async syncLatest(days = 2) {
const { data } = await this.client.post<SyncResult>(
'/garmin/sync-latest', { days }
);
return data;
}
/** A local read: details are stored during the sync, not fetched on tap. */
async getActivityDetail(activityId: string) {
const { data } = await this.client.get<ActivityDetail>(
`/garmin/activities/${activityId}/detail`
);
return data;
}
async startDetailSync(limit?: number) {
const { data } = await this.client.post<DetailSyncStatus>(
'/garmin/sync-details', limit ? { limit } : {}
);
return data;
}
async getDetailSyncStatus() {
const { data } = await this.client.get<DetailSyncStatus>('/garmin/sync-details');
return data;
}
async getBodyComposition(startDate?: string, endDate?: string) {
const { data } = await this.client.get<BodyCompositionDay[]>(
'/health/body-composition', this.range(startDate, endDate)
);
return data;
}
async getBloodPressure() {
const { data } = await this.client.get<BloodPressureReading[]>(
'/health/blood-pressure'
);
return data;
}
async getRacePredictions() {
const { data } = await this.client.get<RacePrediction[]>(
'/health/race-predictions'
);
return data;
}
async getDailySeries(date: string) {
const { data } = await this.client.get<DailySeries>(
'/health/series', { params: { date } }
);
return data;
}
async getChallenges() {
const { data } = await this.client.get<Challenge[]>('/health/challenges');
return data;
}
async getDevices() {
const { data } = await this.client.get<Device[]>('/health/devices');
return data;
}
async getBadges() {
const { data } = await this.client.get<Badge[]>('/health/badges');
return data;
}
async getPersonalRecords() {
const { data } = await this.client.get<PersonalRecord[]>(
'/health/personal-records'
);
return data;
}
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
params: { metricType, startDate, endDate },
});
return data;
}
async getRecommendations() {
const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
return data;
}
async getModels() {
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
return data;
}
/**
* Served from the stored answer unless `refresh` is set or a `model` is
* named. A fresh generation can take minutes, so the caller should show a
* long-running state for those two cases.
*/
async getAiRecommendations(model?: string, refresh?: boolean, days?: number) {
const { data } = await this.client.get<AiRecommendations>(
'/analysis/ai-recommendations',
{
params: { model, days, ...(refresh ? { refresh: 1 } : {}) },
// A cold generation runs well past axios's default timeout.
timeout: 240_000,
}
);
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();