Commit Graph

4 Commits

Author SHA1 Message Date
ericwyuan
2488956f36 fix(ai): 折叠态只显示一句话,那句话得说清是哪个指标
精简版的卡片正文就是第一条 highlight,标题不渲染。于是趋势页折叠起来是
「395 天内由 4376.0分 到 4905.0分(改善)」——什么的 4376 分?身体成分、
运动详情同样。

每个 builder 的第一条 detail 现在都自带主语,并加了不变量测试:detail 必须
包含它自己的 title(纯汇总性标题除外)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:41:28 +08:00
ericwyuan
6dd070ec9b fix(ai): subject 里塞了行数,每轮询一次就新建一个任务
生产上 trends 队列里积了 36 个任务,subject 是 2026-09-01:1033、:1039、
:1044……一路涨。这台账号当时正在补历史,get_summary 的行数每隔几分钟就变,
而我把 len(rows) 写进了 subject——subject 同时是缓存键和任务队列的键,一变
就是一条全新的任务,轮询几次就刷出十几条。

subject 该回答的是「这条解读是关于什么的」,不是「当时有多少行数据」。
数据变化本来就由 fingerprint 负责。

- trends 的 subject 改成快照日期;sleep 用配置的窗口常量而不是实际夜数
  (缺一晚也不该换键);challenges 用固定键
- 加了不变量测试:补一天历史数据后 subject 不许变;任何 subject 段都不许
  长得像行数

顺带加一层兜底 jobs.supersede():单实例 scope 只该有一个在跑的 subject,
队列里同 kind 的其它 pending 任务是关于已经不存在的快照的,跑完也没人看。
per_item 的 daily / activity 不受影响——它们本来就一天一条、一次运动一条。
兜底不是机制,机制是 subject 稳定;它存在只是因为这次 subject 不稳定,而
36 条任务堆在那里之前没人发现。

顺带按要求把 AiPanel 改成默认精简:只显示标题、来源和一句话结论,点「展开
详细」才出要点/建议/依据,可再收起——和今日晨报卡片一致。这些面板压在本来
就很密的图表页上面,全部默认展开会把真正的数据一次性挤到屏幕外。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:33:49 +08:00
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
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