原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 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>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""
|
|
Flask application factory for Garmin Health Lab.
|
|
|
|
Run directly (`python app.py`) for development, or serve with Gunicorn:
|
|
gunicorn wsgi:app -b 0.0.0.0:5000
|
|
"""
|
|
import os
|
|
|
|
from flask import Flask, jsonify, send_from_directory
|
|
from flask_cors import CORS
|
|
|
|
import db
|
|
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
|
from routes import auth, garmin, health, analysis, settings
|
|
from services import scheduler
|
|
from services import jobs as ai_jobs
|
|
from services import garmin as garmin_svc
|
|
|
|
|
|
def create_app():
|
|
app = Flask(__name__)
|
|
CORS(app, resources={r"/api/*": {"origins": CORS_ORIGINS}}, supports_credentials=True)
|
|
|
|
# Create tables once at startup (idempotent).
|
|
db.init_db()
|
|
|
|
# A sync that was running when the previous process stopped left its
|
|
# status behind; clear it before anything reads it.
|
|
garmin_svc.reset_stale_syncs()
|
|
|
|
# Keeps the database current without the user pressing anything. Safe to
|
|
# call in every worker: the job is claimed through the database, so only
|
|
# one of them actually runs a given tick.
|
|
scheduler.start()
|
|
|
|
# The AI coach's consumer. Same story: the queue is in the database, so
|
|
# every worker can run one and a given job is still generated once.
|
|
# Claims held by the previous process are released first — otherwise they
|
|
# sit in `running` until they time out, which to the screen waiting on one
|
|
# is indistinguishable from a generation that never finishes.
|
|
ai_jobs.reset_stale_claims()
|
|
ai_jobs.start()
|
|
|
|
# In production the built React app is served by this same process, so the
|
|
# deployment is a single port with no reverse proxy to configure. In
|
|
# development STATIC_DIR does not exist and the CRA dev server serves the
|
|
# UI instead — hence the guard rather than an unconditional route.
|
|
has_ui = bool(STATIC_DIR) and os.path.isfile(os.path.join(STATIC_DIR, "index.html"))
|
|
|
|
@app.route("/")
|
|
def index():
|
|
if has_ui:
|
|
return send_from_directory(STATIC_DIR, "index.html")
|
|
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
|
|
|
|
@app.route("/api/health/status")
|
|
def health_status():
|
|
return jsonify({"status": "ok", "db": db.DB_TYPE})
|
|
|
|
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
|
app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
|
|
app.register_blueprint(health.bp, url_prefix="/api/health")
|
|
app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
|
|
app.register_blueprint(settings.bp, url_prefix="/api/settings")
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(_e):
|
|
# API paths always answer in JSON. Everything else falls through to the
|
|
# SPA so client-side routes (/settings, /recommendations, ...) survive a
|
|
# page reload instead of 404-ing.
|
|
from flask import request
|
|
|
|
if has_ui and not request.path.startswith("/api/"):
|
|
asset = request.path.lstrip("/")
|
|
if asset and os.path.isfile(os.path.join(STATIC_DIR, asset)):
|
|
return send_from_directory(STATIC_DIR, asset)
|
|
return send_from_directory(STATIC_DIR, "index.html")
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
@app.errorhandler(500)
|
|
def server_error(_e):
|
|
return jsonify({"error": "internal error"}), 500
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=PORT, debug=False)
|