[阶段4.4] AI 建议结果缓存 - 页面不再阻塞等待 160 秒

网关首选的推理模型一次生成约 160 秒,每次打开建议页都重跑不可用。
结果落库缓存,页面读缓存,用户想要新的再手动触发。

db.py:
- 新增 ai_recommendations 表,每用户一行(重新生成是替换不是累积)
- fingerprint 列记录这条建议是基于哪份数据算出来的

services/analysis.py:
- _fingerprint() 对全部每日指标 + 运动条数取 sha256,任何一次同步
  新增或修正了数值都会让摘要变化,从而使缓存失效
- TTL 默认 24 小时(AI_CACHE_TTL_HOURS 可调)
- 指定 model 参数时绕过缓存:点名某个模型意味着想要那个模型的答案
- 降级到规则引擎的结果不写缓存,避免把兜底答案当成 AI 结果存下来
- 缓存写入失败只打日志,不影响本次请求返回

routes: ?refresh=1 强制重新生成

前端:
- "重新生成" 按钮走 refresh,并提示需要 1-3 分钟、可以离开本页
- meta 栏显示是否为缓存结果及生成时间,以及网关的上游厂商
- axios 该请求超时放宽到 240s(冷生成远超默认超时)

tests/test_ai_cache.py (20 通过):
- 第二次调用不再打模型
- 新增一天数据 / 修正某天数值 / 新增一条运动记录,三种情况都失效
- TTL 边界两侧各一条(刚过期重算、未过期沿用)
- 缓存按用户隔离,A 的结果不会答给 B
- payload 损坏时重新生成而不是抛异常
- 规则兜底结果和无数据用户都不落缓存

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 17:55:12 +08:00
parent 8882bf44a4
commit acc6a2474b
6 changed files with 418 additions and 14 deletions

View File

@@ -17,15 +17,19 @@ function Recommendations() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (model?: string) => {
const [regenerating, setRegenerating] = useState(false);
const load = useCallback(async (model?: string, refresh?: boolean) => {
setLoading(true);
setRegenerating(Boolean(refresh || model));
setError('');
try {
setResult(await apiClient.getAiRecommendations(model || undefined));
setResult(await apiClient.getAiRecommendations(model || undefined, refresh));
} catch (err: any) {
setError(errorMessage(err, '获取建议失败'));
} finally {
setLoading(false);
setRegenerating(false);
}
}, []);
@@ -73,13 +77,20 @@ function Recommendations() {
<button
className="btn btn-primary"
onClick={() => load(selected)}
onClick={() => load(selected, true)}
disabled={loading}
>
{loading ? '生成中…' : '重新生成'}
</button>
</div>
{regenerating && (
<div className="notice">
1-3
</div>
)}
{configured.length === 0 && models.length > 0 && (
<div className="notice">
<code>backend/.env</code>
@@ -94,7 +105,16 @@ function Recommendations() {
<div className={`meta-bar ${meta.source}`}>
{meta.source === 'ai' ? (
<>
<strong>AI </strong> · {meta.model} · {meta.days}
<strong>AI </strong> · {meta.model}
{meta.upstream && `(上游 ${meta.upstream}`}
{' · '} {meta.days}
{meta.cached && (
<span className="fallback">
·
{meta.generatedAt &&
`,生成于 ${new Date(meta.generatedAt + 'Z').toLocaleString('zh-CN')}`}
</span>
)}
{meta.fallbackFrom && meta.fallbackFrom.length > 0 && (
<span className="fallback">
{meta.fallbackFrom.join('、')}