Files
GarminHealthLab/client/src/components/TrendInsight.tsx
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

149 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, InsightMeta, TrendInsight as Insight,
} from '../services/api';
import './TrendInsight.css';
/**
* Metric ids the backend's feature engineering knows, keyed by the id this app
* uses. Two names differ (the sleep shares), the rest are identical.
*
* Kept as an explicit list rather than sent optimistically: the endpoint 400s
* on an unknown metric, and a button that reliably fails is worse than no
* button on the metrics this cannot explain.
*/
const BACKEND_METRIC: Record<string, string> = {
steps: 'steps',
intensityMinutes: 'intensityMinutes',
heartRate: 'heartRate',
heartRateVariability: 'heartRateVariability',
stress: 'stress',
bodyBatteryHigh: 'bodyBatteryHigh',
respirationAvg: 'respirationAvg',
sleepDuration: 'sleepDuration',
sleepQuality: 'sleepQuality',
deepShare: 'sleepDeepPct',
remShare: 'sleepRemPct',
trainingReadiness: 'trainingReadiness',
enduranceScore: 'enduranceScore',
};
export function supportsInsight(metricId: string) {
return metricId in BACKEND_METRIC;
}
const CONFIDENCE_LABEL: Record<string, string> = {
high: '证据充分', medium: '证据一般', low: '证据薄弱',
};
interface Props {
/** This app's metric id, e.g. `heartRateVariability`. */
metricId: string;
startDate: string;
endDate: string;
}
/**
* AI attribution for the span currently on the chart.
*
* The product spec asks for this on a brush selection over a desktop chart.
* On a phone the equivalent gesture is the range selector that is already
* there, so the panel explains whatever window the user has selected rather
* than adding a drag interaction that fights the page's own scrolling.
*
* On demand, never on load: one generation takes minutes on the gateway, so
* running it for every metric a user browses past would spend that on nothing.
*/
function TrendInsight({ metricId, startDate, endDate }: Props) {
const [insight, setInsight] = useState<Insight | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const live = useRef(true);
useEffect(() => {
live.current = true;
return () => { live.current = false; };
}, []);
// A new window is a different question; clear the old answer rather than
// leaving it under a chart it no longer describes.
useEffect(() => {
setInsight(null);
setMeta(null);
setError('');
}, [metricId, startDate, endDate]);
const run = async (refresh?: boolean) => {
const backend = BACKEND_METRIC[metricId];
if (!backend || loading) return;
setLoading(true);
setError('');
try {
const data = await apiClient.getTrendInsight(backend, startDate, endDate, refresh);
if (!live.current) return;
setInsight(data.insight);
setMeta(data.meta);
} catch (err: any) {
if (live.current) setError(errorMessage(err, '归因分析失败'));
} finally {
if (live.current) setLoading(false);
}
};
if (!supportsInsight(metricId)) return null;
return (
<section className="ti">
<div className="ti-head">
<h3 className="sec-title">AI </h3>
{insight && !loading && (
<button className="ti-link" onClick={() => run(true)}></button>
)}
</div>
{!insight && !loading && !error && (
<>
<p className="ti-hint">
{startDate} ~ {endDate}
</p>
<button className="ti-run" onClick={() => run()}></button>
</>
)}
{loading && (
<div className="ti-loading">
<span className="ti-spinner" aria-hidden="true" />
25
</div>
)}
{error && <p className="ti-error">{error}</p>}
{insight && (
<div className="ti-body">
{insight.summary && <p className="ti-summary">{insight.summary}</p>}
{insight.drivers.map((d) => (
<div className="ti-driver" key={d.factor + d.detail}>
<span className="ti-factor">{d.factor}</span>
<p>{d.detail}</p>
</div>
))}
{insight.caution && <p className="ti-caution">{insight.caution}</p>}
<div className="ti-foot">
<span>
{meta?.source === 'ai' ? `AI 生成${meta.upstream ? ` · ${meta.upstream}` : ''}` : '规则引擎'}
</span>
<span>{CONFIDENCE_LABEL[insight.confidence]}</span>
</div>
</div>
)}
</section>
);
}
export default TrendInsight;