""" Analysis service: metric trends + a rule-based recommendation engine. Replicates the original Node AnalysisService logic. Averages are computed over the most recent 14 days of available daily summaries. """ import datetime import hashlib import json import os from services import health from services import ai as ai_svc from db import query_all, query_one, execute from config import DB_TYPE METRIC_COLUMNS = { "steps": "steps", "heart_rate": "heart_rate", "sleep_duration": "sleep_duration", "sleep_quality": "sleep_quality", "stress": "stress", "calories_burned": "calories_burned", } def get_trends(metric, user_id, start=None, end=None): column = METRIC_COLUMNS.get(metric, "steps") params = [user_id] sql = "WHERE user_id = ?" if start: sql += " AND date >= ?" params.append(start) if end: sql += " AND date <= ?" params.append(end) rows = query_all( f"SELECT date, {column} AS value FROM health_data {sql} " f"AND {column} IS NOT NULL ORDER BY date ASC", params, ) return [{"date": r["date"], "value": r["value"]} for r in rows] def get_recommendations(user_id): recent = health.get_summary(user_id) last14 = recent[-14:] recs = [] if not last14: return [ { "id": "no-data", "category": "数据", "recommendation": "暂无健康数据,请先同步你的 Garmin 设备数据。", "priority": "low", "basedOn": [], } ] avg = lambda key: sum((r.get(key) or 0) for r in last14) / len(last14) avg_steps = avg("steps") sleep_rows = [r["sleep"]["duration"] for r in last14 if r.get("sleep")] avg_sleep = sum(sleep_rows) / len(sleep_rows) if sleep_rows else 0 avg_stress = avg("stress") avg_rhr = avg("heartRate") avg_hrv = avg("heartRateVariability") if avg_steps > 0 and avg_steps < 8000: recs.append({ "id": "steps", "category": "运动", "recommendation": f"近 {len(last14)} 天日均步数约 {round(avg_steps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。", "priority": "medium", "basedOn": ["steps"], }) if avg_sleep > 0 and avg_sleep < 7: recs.append({ "id": "sleep", "category": "睡眠", "recommendation": f"日均睡眠约 {avg_sleep:.1f} 小时,偏少。建议固定就寝时间,目标 7-8 小时。", "priority": "high", "basedOn": ["sleep_duration"], }) if avg_stress > 0 and avg_stress > 50: recs.append({ "id": "stress", "category": "压力", "recommendation": f"平均压力指数 {round(avg_stress)} 偏高,建议安排放松活动(冥想/散步)。", "priority": "high", "basedOn": ["stress"], }) if avg_rhr > 0 and avg_rhr > 65: recs.append({ "id": "rhr", "category": "心肺", "recommendation": f"静息心率约 {round(avg_rhr)} bpm 偏高,规律有氧运动有助于改善心肺功能。", "priority": "medium", "basedOn": ["heart_rate"], }) if avg_hrv > 0 and avg_hrv < 40: recs.append({ "id": "hrv", "category": "恢复", "recommendation": f"心率变异性(HRV)约 {round(avg_hrv)} ms 偏低,注意恢复与休息,避免过度训练。", "priority": "low", "basedOn": ["heart_rate_variability"], }) if not recs: recs.append({ "id": "good", "category": "状态", "recommendation": "近期各项指标良好,保持当前作息与运动习惯即可。", "priority": "low", "basedOn": [], }) order = {"high": 0, "medium": 1, "low": 2} recs.sort(key=lambda r: order[r["priority"]]) return recs CACHE_TTL_HOURS = int(os.environ.get("AI_CACHE_TTL_HOURS") or 24) def _fingerprint(summary, activities): """Identify the data a cached answer was derived from. Cheap and order-independent: the day count, the newest and oldest dates, and every metric value. Any sync that adds or corrects a value changes the digest, which is what expires the cache. """ parts = [str(len(summary)), str(len(activities))] for row in summary: parts.append( "|".join( str(row.get(k)) for k in ("date", "steps", "heartRate", "heartRateVariability", "stress", "caloriesBurned") ) ) sleep = row.get("sleep") or {} parts.append(f"{sleep.get('duration')}/{sleep.get('quality')}") return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:64] def _read_cache(user_id, fingerprint): row = query_one( "SELECT * FROM ai_recommendations WHERE user_id = ?", [user_id] ) if not row or row["fingerprint"] != fingerprint: return None created = row.get("created_at") if created: try: ts = datetime.datetime.fromisoformat(str(created).replace(" ", "T")) age = datetime.datetime.utcnow() - ts if age > datetime.timedelta(hours=CACHE_TTL_HOURS): return None except ValueError: # An unparseable timestamp should not permanently poison the cache. return None try: recs = json.loads(row["payload"]) except (ValueError, TypeError): return None return { "recommendations": recs, "meta": { "source": "ai", "model": row["model"], "upstream": row["upstream"], "days": row["days"], "cached": True, "generatedAt": created, }, } def _write_cache(user_id, fingerprint, recs, meta): cols = ["user_id", "fingerprint", "model", "upstream", "days", "payload", "created_at"] placeholders = ", ".join(["?"] * len(cols)) if DB_TYPE == "mariadb": updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id") sql = ( f"INSERT INTO ai_recommendations ({', '.join(cols)}) " f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}" ) else: updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id") sql = ( f"INSERT INTO ai_recommendations ({', '.join(cols)}) " f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}" ) execute(sql, [ user_id, fingerprint, meta.get("model"), meta.get("upstream"), meta.get("days"), json.dumps(recs, ensure_ascii=False), datetime.datetime.utcnow().isoformat(timespec="seconds"), ]) def get_ai_recommendations(user_id, model=None, days=None, refresh=False): """LLM recommendations over the user's history, cached. A generation costs minutes against a large reasoning model, so a stored answer is reused until the health data changes (or the TTL lapses). `refresh=True` and an explicit `model` both bypass the cache — asking for a specific model means wanting that model's answer, not a stored one. Falls back to the rule engine when every model fails, so the endpoint always returns something useful; `meta.source` tells the two apart. """ summary = health.get_summary(user_id) if not summary: return { "recommendations": get_recommendations(user_id), "meta": {"model": None, "source": "rules", "reason": "无健康数据"}, } activities = health.get_activities(user_id) fingerprint = _fingerprint(summary, activities) if not refresh and not model: cached = _read_cache(user_id, fingerprint) if cached: return cached budget = days or ai_svc.default_day_budget() try: recs, meta = ai_svc.generate( summary, activities, preferred_model=model, day_budget=budget ) except ai_svc.AIError as e: return { "recommendations": get_recommendations(user_id), "meta": {"model": None, "source": "rules", "reason": str(e)}, } try: _write_cache(user_id, fingerprint, recs, meta) except Exception as e: # noqa: BLE001 - a cache write must never fail the request print(f"[analysis] failed to cache recommendations: {e}") return {"recommendations": recs, "meta": {**meta, "source": "ai", "cached": False}} def clear_ai_cache(user_id): execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])