""" Multi-provider LLM layer for health recommendations. Design goals ------------ * **Switchable models** — every model lives in a catalog keyed by a short id ("gemini-flash", "llama-70b", ...). Callers pass an id; nothing else in the codebase knows which vendor is behind it. * **Large context** — daily metrics are serialised as compact CSV rather than JSON, so a year of data costs a few thousand tokens instead of tens of thousands. Each model declares its own window and the payload is trimmed to fit the smallest of (model window, configured day budget). * **Fallback** — if the preferred model errors or times out, the next healthy model in the chain is tried before giving up. This mirrors the behaviour the NAS deployment already relies on (Gemini primary, NVIDIA secondary). Only text-in/text-out models are supported; no vision models are registered. API keys are read from the environment — never hardcode them. """ import json import os import re import requests DEFAULT_TIMEOUT = float(os.environ.get("AI_TIMEOUT_SECONDS") or 45) # How many days of history to put in the prompt at most. Kept well below the # model windows so the response always has room. DEFAULT_DAY_BUDGET = int(os.environ.get("AI_DAY_BUDGET") or 365) SYSTEM_PROMPT = ( "你是一名严谨的健康数据分析助手,负责解读用户的可穿戴设备(Garmin)数据。\n" "要求:\n" "1. 只依据给出的数据得出结论,数据不足时明确说明,不要编造数值。\n" "2. 指出趋势、异常和相互关联(例如睡眠不足与静息心率升高的关系)。\n" "3. 给出具体、可执行的建议,而不是泛泛而谈。\n" "4. 你不是医生,不做诊断;发现明显异常时建议用户咨询专业医师。\n" "5. 用简体中文回答。\n\n" "输出严格为 JSON 数组,每个元素形如:\n" '{"category": "睡眠", "recommendation": "……", "priority": "high|medium|low", ' '"basedOn": ["sleep_duration"]}\n' "不要输出 JSON 以外的任何文字,不要用 markdown 代码块包裹。" ) class AIError(Exception): """Raised when a provider cannot produce a completion.""" # --- providers -------------------------------------------------------------- class Provider: """Base class. Subclasses turn a prompt into text.""" name = "base" def __init__(self, model_id, context_window, api_key_env): self.model_id = model_id self.context_window = context_window self.api_key_env = api_key_env @property def api_key(self): return os.environ.get(self.api_key_env) or "" def is_configured(self): return bool(self.api_key) def generate(self, prompt, timeout=DEFAULT_TIMEOUT): raise NotImplementedError class GeminiProvider(Provider): """Google AI Studio (generativelanguage.googleapis.com).""" name = "gemini" BASE = "https://generativelanguage.googleapis.com/v1beta/models" def generate(self, prompt, timeout=DEFAULT_TIMEOUT): if not self.is_configured(): raise AIError(f"{self.api_key_env} 未配置") url = f"{self.BASE}/{self.model_id}:generateContent" payload = { "contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.4}, } try: resp = requests.post( url, headers={ "Content-Type": "application/json", "X-goog-api-key": self.api_key, }, json=payload, timeout=timeout, ) except requests.RequestException as e: raise AIError(f"gemini 请求失败: {e}") from e if resp.status_code != 200: raise AIError(f"gemini HTTP {resp.status_code}: {resp.text[:200]}") try: body = resp.json() parts = body["candidates"][0]["content"]["parts"] return "".join(p.get("text", "") for p in parts) except (ValueError, KeyError, IndexError) as e: raise AIError(f"gemini 响应格式异常: {e}") from e class OpenAICompatProvider(Provider): """Any endpoint speaking the OpenAI chat-completions schema (NVIDIA NIM, Ollama, vLLM, ...).""" name = "openai-compat" def __init__(self, model_id, context_window, api_key_env, base_url_env, default_base_url): super().__init__(model_id, context_window, api_key_env) self.base_url = os.environ.get(base_url_env) or default_base_url def generate(self, prompt, timeout=DEFAULT_TIMEOUT): if not self.is_configured(): raise AIError(f"{self.api_key_env} 未配置") url = f"{self.base_url.rstrip('/')}/chat/completions" payload = { "model": self.model_id, "messages": [{"role": "user", "content": prompt}], "temperature": 0.4, "max_tokens": 2048, } try: resp = requests.post( url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", }, json=payload, timeout=timeout, ) except requests.RequestException as e: raise AIError(f"{self.model_id} 请求失败: {e}") from e if resp.status_code != 200: raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}") try: return resp.json()["choices"][0]["message"]["content"] except (ValueError, KeyError, IndexError) as e: raise AIError(f"{self.model_id} 响应格式异常: {e}") from e # --- catalog ---------------------------------------------------------------- def _build_catalog(): """Model id -> Provider. Text-only models with large context windows.""" return { "gemini-flash": GeminiProvider( model_id="gemini-flash-latest", context_window=1_000_000, api_key_env="GEMINI_API_KEY", ), "llama-70b": OpenAICompatProvider( model_id="meta/llama-3.3-70b-instruct", context_window=128_000, api_key_env="NVIDIA_API_KEY", base_url_env="NVIDIA_BASE_URL", default_base_url="https://integrate.api.nvidia.com/v1", ), "qwen-72b": OpenAICompatProvider( model_id="qwen/qwen2.5-72b-instruct", context_window=128_000, api_key_env="NVIDIA_API_KEY", base_url_env="NVIDIA_BASE_URL", default_base_url="https://integrate.api.nvidia.com/v1", ), "deepseek-r1": OpenAICompatProvider( model_id="deepseek-ai/deepseek-r1", context_window=128_000, api_key_env="NVIDIA_API_KEY", base_url_env="NVIDIA_BASE_URL", default_base_url="https://integrate.api.nvidia.com/v1", ), } CATALOG = _build_catalog() # Preference order used when no model is requested, and for fallback. DEFAULT_CHAIN = [ m.strip() for m in (os.environ.get("AI_MODEL_CHAIN") or "gemini-flash,llama-70b,qwen-72b").split(",") if m.strip() ] def list_models(): """Catalog entries plus whether each one currently has credentials.""" return [ { "id": mid, "model": p.model_id, "provider": p.name, "contextWindow": p.context_window, "configured": p.is_configured(), "default": mid == DEFAULT_CHAIN[0] if DEFAULT_CHAIN else False, } for mid, p in CATALOG.items() ] def resolve_chain(preferred=None): """Ordered list of model ids to attempt, configured ones only.""" chain = [] if preferred: if preferred not in CATALOG: raise AIError(f"未知模型: {preferred}") chain.append(preferred) for mid in DEFAULT_CHAIN: if mid in CATALOG and mid not in chain: chain.append(mid) configured = [m for m in chain if CATALOG[m].is_configured()] if not configured: raise AIError( "没有可用的模型:请在 backend/.env 中配置 GEMINI_API_KEY 或 NVIDIA_API_KEY" ) return configured # --- prompt construction ---------------------------------------------------- _CSV_COLUMNS = [ ("date", "date"), ("steps", "steps"), ("heartRate", "rest_hr"), ("heartRateVariability", "hrv"), ("stress", "stress"), ("caloriesBurned", "kcal"), ] def build_prompt(summary, activities=None, day_budget=DEFAULT_DAY_BUDGET): """Render health history as a compact CSV prompt. CSV rather than JSON: roughly 4x fewer tokens for the same numbers, which is what makes a full year of history practical to send. """ rows = summary[-day_budget:] if day_budget else summary header = ",".join(label for _, label in _CSV_COLUMNS) + ",sleep_h,sleep_q" lines = [header] for r in rows: cells = [] for key, _ in _CSV_COLUMNS: value = r.get(key) cells.append("" if value is None else str(value)) sleep = r.get("sleep") or {} cells.append("" if sleep.get("duration") is None else str(sleep["duration"])) cells.append("" if sleep.get("quality") is None else str(sleep["quality"])) lines.append(",".join(cells)) sections = [ SYSTEM_PROMPT, f"\n## 每日健康数据(共 {len(rows)} 天,CSV)\n" + "\n".join(lines), ] if activities: act_lines = ["type,start,duration_s,distance_km,kcal,avg_hr,max_hr"] for a in activities[:200]: act_lines.append( ",".join( str(a.get(k) if a.get(k) is not None else "") for k in ( "activity_type", "start_time", "duration", "distance", "calories", "heart_rate_average", "heart_rate_max", ) ) ) sections.append( f"\n## 运动记录(共 {min(len(activities), 200)} 条,CSV)\n" + "\n".join(act_lines) ) return "\n".join(sections) # --- response parsing ------------------------------------------------------- _VALID_PRIORITIES = {"high", "medium", "low"} _FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE) def parse_recommendations(text): """Coerce a model reply into the same shape the rule engine returns. Models routinely wrap JSON in markdown fences or add a sentence before it, despite instructions, so both are tolerated here. """ if not text or not text.strip(): raise AIError("模型返回空响应") cleaned = _FENCE.sub("", text).strip() try: data = json.loads(cleaned) except ValueError: start, end = cleaned.find("["), cleaned.rfind("]") if start == -1 or end <= start: raise AIError(f"模型未返回 JSON 数组: {text[:200]}") try: data = json.loads(cleaned[start : end + 1]) except ValueError as e: raise AIError(f"模型返回的 JSON 无法解析: {e}") from e if isinstance(data, dict): data = [data] if not isinstance(data, list): raise AIError("模型返回的不是 JSON 数组") recs = [] for i, item in enumerate(data): if not isinstance(item, dict): continue text_value = (item.get("recommendation") or "").strip() if not text_value: continue priority = str(item.get("priority", "medium")).lower() if priority not in _VALID_PRIORITIES: priority = "medium" based_on = item.get("basedOn") if not isinstance(based_on, list): based_on = [] recs.append( { "id": f"ai-{i}", "category": (item.get("category") or "综合").strip(), "recommendation": text_value, "priority": priority, "basedOn": [str(b) for b in based_on], "source": "ai", } ) if not recs: raise AIError("模型未返回任何有效建议") order = {"high": 0, "medium": 1, "low": 2} recs.sort(key=lambda r: order[r["priority"]]) return recs # --- entry point ------------------------------------------------------------ def generate(summary, activities=None, preferred_model=None, day_budget=DEFAULT_DAY_BUDGET): """Ask the first healthy model in the chain for recommendations. Returns (recommendations, meta). `meta` records which model answered and which ones failed, so the UI can show what actually happened. """ chain = resolve_chain(preferred_model) prompt = build_prompt(summary, activities, day_budget) errors = [] for model_id in chain: provider = CATALOG[model_id] try: raw = provider.generate(prompt) recs = parse_recommendations(raw) return recs, { "model": model_id, "provider": provider.name, "days": min(len(summary), day_budget) if day_budget else len(summary), "fallbackFrom": [e["model"] for e in errors], } except AIError as e: errors.append({"model": model_id, "error": str(e)}) detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors) raise AIError(f"所有模型均失败 -> {detail}")