Files
GarminHealthLab/client/src/pages/HealthPage.tsx
ericwyuan 241ae0d6a3 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>
2026-09-01 15:17:17 +08:00

200 lines
7.0 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, useState } from 'react';
import { Link, f7 } from 'framework7-react';
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
import { METRICS, metricHref } from '../lib/metrics';
import MetricCard from '../components/charts/MetricCard';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
import './Health.css';
import './Settings.css';
const WINDOW_DAYS = 30;
/* Sections name metric ids; the labels, units and accessors come from the
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
const SECTIONS: Array<{ title: string; items: string[] }> = [
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
];
/* Screens that are not a single metric, so they get their own entries. */
const LINKS: Array<[string, string, string]> = [
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
];
function BodyAge({ data }: { data: FitnessAge | null }) {
if (!data) return null;
const go = () => f7.views.current.router.navigate('/body-age/');
if (data.value == null) {
return (
<section className="sec">
<h3 className="sec-title"></h3>
<button className="bodyage bodyage-empty" onClick={go} type="button">
<span className="bodyage-missing">
{data.missing.join('、')}
</span>
<span className="mcard-chevron" aria-hidden="true"></span>
</button>
</section>
);
}
const delta = data.delta ?? 0;
return (
<section className="sec">
<h3 className="sec-title"></h3>
<button className="bodyage" onClick={go} type="button">
<div className="bodyage-main">
<span className="bodyage-value">{data.value}</span>
<span className="bodyage-unit"></span>
</div>
<div className="bodyage-side">
<span className={`bodyage-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
{delta === 0
? '与实际年龄相当'
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)}`}
</span>
<span className="bodyage-note">
{data.chronologicalAge} ·
</span>
</div>
<span className="mcard-chevron" aria-hidden="true"></span>
</button>
</section>
);
}
function HealthPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [bodyAge, setBodyAge] = useState<FitnessAge | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setDays(
await apiClient.getHealthSummary(
daysAgo(WINDOW_DAYS - 1), todayIso()
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
// Body age is a separate, optional read: an empty profile must not stop
// the rest of the page from rendering.
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
}, []);
if (loading) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<Skeleton count={8} />
</Screen>
);
}
if (error) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<div className="screen-error">{error}</div>
</Screen>
);
}
if (days.length === 0) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
Garmin
</Link>
</div>
</Screen>
);
}
// The most recent day that actually recorded a given metric — showing "—"
// because today's sleep has not synced yet would hide data that exists.
const latest = (pick: (d: HealthDay) => number | null) => {
for (let i = days.length - 1; i >= 0; i--) {
const v = pick(days[i]);
if (v != null) return { value: v, date: days[i].date };
}
return { value: null as number | null, date: null as string | null };
};
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<BodyAge data={bodyAge} />
{FEATURES.ai && <AiPanel scope="health" title="AI 健康解读" />}
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>
<div className="mcard-grid">
{section.items.map((id) => {
const def = METRICS[id];
if (!def) return null;
const { value, date } = latest(def.pick);
const stale = date != null && date !== days[days.length - 1].date;
return (
<MetricCard
key={id}
metric={def.range}
label={def.label}
value={value}
unit={def.unit}
decimals={def.decimals}
trend={days.map(def.pick)}
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
onClick={() => f7.views.current.router.navigate(metricHref(id))}
/>
);
})}
</div>
</section>
))}
<section className="sec">
<h3 className="sec-title"></h3>
<div className="set-rows">
{LINKS.map(([href, label, sub]) => (
<Link href={href} className="set-row" key={href}>
<span className="set-label">
{label}
<span className="set-sub">{sub}</span>
</span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
))}
</div>
</section>
<p className="screen-disclaimer">
</p>
</Screen>
);
}
export default HealthPage;