diff --git a/backend/app/main.py b/backend/app/main.py index b06d7c5..be39c0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -96,6 +96,7 @@ from app.routers.catalog import router as catalog_router from app.routers.support import router as support_router from app.routers.nutrition import router as nutrition_router from app.routers.meal_plans import router as meal_plans_router +from app.routers.ai_chat import router as ai_chat_router app.include_router(auth_router) app.include_router(onboarding_router) @@ -109,6 +110,7 @@ app.include_router(support_router) app.include_router(programs_router) app.include_router(nutrition_router) app.include_router(meal_plans_router) +app.include_router(ai_chat_router) @app.get("/api/health") diff --git a/backend/app/routers/ai_chat.py b/backend/app/routers/ai_chat.py new file mode 100644 index 0000000..e9b7b24 --- /dev/null +++ b/backend/app/routers/ai_chat.py @@ -0,0 +1,64 @@ +"""AI coaching chat endpoint — SSE streaming responses.""" +from __future__ import annotations + +import json +import logging +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.utils.auth import get_current_user +from app.models.user import User +from app.services import ai_provider + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/ai", tags=["AI Coaching"]) + + +@router.post("/chat") +async def chat_with_ai( + request: Request, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Stream an AI coaching response via SSE. + + Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]} + Response: text/event-stream with data chunks. + """ + body = await request.json() + message = body.get("message", "").strip() + history = body.get("history", []) + + if not message: + return {"error": "Message cannot be empty"} + + # Validate history format + clean_history = [] + for msg in history[-20:]: # Cap at last 20 messages + if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"): + clean_history.append({"role": msg["role"], "content": msg["content"][:4000]}) + + async def event_stream(): + async for chunk in ai_provider.chat(user.id, message, clean_history, db): + yield f"data: {json.dumps({'text': chunk})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.get("/status") +async def ai_status( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Check which AI provider is configured.""" + provider = await ai_provider.get_active_ai_provider(db) + if provider: + return { + "configured": True, + "provider": provider["name"], + "model": provider["model"], + } + return {"configured": False, "provider": None, "model": None} diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py new file mode 100644 index 0000000..fa1ea7a --- /dev/null +++ b/backend/app/services/ai_provider.py @@ -0,0 +1,316 @@ +"""Multi-provider AI coaching service. + +Two code paths: +- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom +- Anthropic: covers Claude (different message format + auth header) +- Gemini: thin adapter for Google's generateContent API + +System prompt is built from AGENT_GUIDE.md + live user context. +Chat history is ephemeral (not persisted). +""" +from __future__ import annotations + +import json +import logging +from typing import AsyncGenerator + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.services.crypto import decrypt_value +from app.models.integration_config import IntegrationConfig + +logger = logging.getLogger(__name__) +settings = get_settings() + +# AI provider presets — base_url and api_format for each named provider +AI_PRESETS = { + "openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"}, + "openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"}, + "huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"}, + "groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"}, + "ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"}, + "claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"}, + "gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"}, + "custom_ai": {"base_url": "", "format": "openai", "default_model": ""}, +} + +# System prompt template — {context} is replaced with live user data +SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform. + +Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses. + +CURRENT USER CONTEXT: +{context} + +RULES: +- Be encouraging but honest. Celebrate progress, address setbacks constructively. +- When the user asks to log a workout or food, confirm what you'll log and do it. +- Reference their program schedule when suggesting workouts. +- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone. +- Keep responses concise — this is a mobile chat, not an essay. +- If the user hasn't earned enough points today, gently encourage activity. +- Never fabricate data — only reference what's in the context.""" + + +async def get_active_ai_provider(db: AsyncSession) -> dict | None: + """Find the first configured AI provider.""" + result = await db.execute( + select(IntegrationConfig).where( + IntegrationConfig.provider.in_(list(AI_PRESETS.keys())) + ) + ) + configs = result.scalars().all() + + for config in configs: + provider_name = config.provider + try: + creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key)) + except Exception: + continue + + preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"]) + api_key = creds.get("api_key", "") + base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"] + model = creds.get("model", preset["default_model"]) or preset["default_model"] + + return { + "name": provider_name, + "format": preset["format"], + "base_url": base_url, + "api_key": api_key, + "model": model, + } + + return None + + +async def build_context(db: AsyncSession, user_id) -> str: + """Build live context string from user's current state.""" + from app.models.user import User + from app.models.profile import UserProfile + + context_parts = [] + + # User profile + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user: + context_parts.append(f"Name: {user.display_name}") + context_parts.append(f"Timezone: {user.timezone}") + + # Profile / motivation + profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none() + if profile: + if profile.ttm_stage: + context_parts.append(f"Stage of change: {profile.ttm_stage}") + if profile.breq_profile: + context_parts.append(f"Motivation type: {profile.breq_profile}") + + # Today's points (best effort) + try: + from app.services.points_engine import get_daily_summary + summary = await get_daily_summary(db, user_id) + context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}") + context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}") + except Exception: + pass + + return "\n".join(context_parts) if context_parts else "No profile data available yet." + + +async def chat( + user_id, + message: str, + history: list[dict], + db: AsyncSession, +) -> AsyncGenerator[str, None]: + """Route chat to the configured AI provider with streaming.""" + provider = await get_active_ai_provider(db) + if not provider: + yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)." + return + + context = await build_context(db, user_id) + system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context) + + try: + if provider["format"] == "openai": + async for chunk in _call_openai_compat( + base_url=provider["base_url"], + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + elif provider["format"] == "anthropic": + async for chunk in _call_anthropic( + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + elif provider["format"] == "gemini": + async for chunk in _call_gemini( + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + except httpx.HTTPStatusError as e: + yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations." + except httpx.ConnectError: + yield f"Cannot reach {provider['name']}. Check your network or endpoint URL." + except Exception as e: + logger.error(f"AI chat error: {e}") + yield "Something went wrong with the AI provider. Check Settings → Integrations." + + +async def _call_openai_compat( + base_url: str, + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call any OpenAI-compatible /v1/chat/completions endpoint with streaming. + + Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom. + """ + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + *history, + {"role": "user", "content": message}, + ], + "stream": True, + "max_tokens": 1024, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + f"{base_url.rstrip('/')}/chat/completions", + json=payload, + headers=headers, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: ") and line.strip() != "data: [DONE]": + try: + chunk = json.loads(line[6:]) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + if content := delta.get("content"): + yield content + except (json.JSONDecodeError, IndexError, KeyError): + continue + + +async def _call_anthropic( + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call Anthropic's /v1/messages endpoint with streaming.""" + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + + # Convert OpenAI-style history to Anthropic format + messages = [] + for msg in history: + messages.append({"role": msg["role"], "content": msg["content"]}) + messages.append({"role": "user", "content": message}) + + payload = { + "model": model, + "system": system_prompt, + "messages": messages, + "max_tokens": 1024, + "stream": True, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + "https://api.anthropic.com/v1/messages", + json=payload, + headers=headers, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + try: + event = json.loads(line[6:]) + if event.get("type") == "content_block_delta": + delta = event.get("delta", {}) + if text := delta.get("text"): + yield text + except (json.JSONDecodeError, KeyError): + continue + + +async def _call_gemini( + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call Google Gemini's generateContent endpoint with streaming.""" + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent" + + # Build Gemini content format + contents = [] + # System instruction goes as first user/model exchange + if system_prompt: + contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]}) + contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]}) + + for msg in history: + role = "model" if msg["role"] == "assistant" else "user" + contents.append({"role": role, "parts": [{"text": msg["content"]}]}) + contents.append({"role": "user", "parts": [{"text": message}]}) + + payload = { + "contents": contents, + "generationConfig": {"maxOutputTokens": 1024}, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + url, + json=payload, + params={"key": api_key, "alt": "sse"}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + try: + chunk = json.loads(line[6:]) + candidates = chunk.get("candidates", []) + if candidates: + parts = candidates[0].get("content", {}).get("parts", []) + for part in parts: + if text := part.get("text"): + yield text + except (json.JSONDecodeError, KeyError): + continue diff --git a/backend/app/services/provider_registry.py b/backend/app/services/provider_registry.py index 99e2b15..0789659 100644 --- a/backend/app/services/provider_registry.py +++ b/backend/app/services/provider_registry.py @@ -2,65 +2,213 @@ from __future__ import annotations PROVIDER_REGISTRY: dict[str, dict] = { + + # ═══ Device Integrations ═══ + "strava": { "name": "Strava", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], "auth_url": "https://www.strava.com/oauth/authorize", "token_url": "https://www.strava.com/oauth/token", "scopes": "read,activity:read_all", "help_url": "https://developers.strava.com/", "help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback", + "warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.", + "has_webhook": False, + "sync_service": "strava_sync", }, "polar": { "name": "Polar", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], "auth_url": "https://flow.polar.com/oauth2/authorization", "token_url": "https://polarremote.com/v2/oauth2/token", "help_url": "https://admin.polaraccesslink.com/", "help_text": "Register at admin.polaraccesslink.com", + "has_webhook": False, + "sync_service": "polar_sync", }, "garmin": { "name": "Garmin Connect", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://connect.garmin.com/oauthConfirm", + "token_url": "https://connectapi.garmin.com/oauth-service/oauth/token", + "scopes": "HEALTH,ACTIVITY", "help_url": "https://developer.garmin.com/gc-developer-program/", - "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks", - }, - "fitbit": { - "name": "Fitbit", - "type": "oauth2", - "fields": ["client_id", "client_secret"], - "auth_url": "https://www.fitbit.com/oauth2/authorize", - "token_url": "https://api.fitbit.com/oauth2/token", - "help_url": "https://dev.fitbit.com/", - "help_text": "Register at dev.fitbit.com", - }, - "withings": { - "name": "Withings", - "type": "oauth2", - "fields": ["client_id", "client_secret"], - "help_url": "https://developer.withings.com/", - "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure", + "help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.", + "has_webhook": True, + "sync_service": "garmin_sync", }, "whoop": { "name": "WHOOP", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth", + "token_url": "https://api.prod.whoop.com/oauth/oauth2/token", + "scopes": "read:workout,read:recovery,read:sleep,read:profile,offline", "help_url": "https://developer.whoop.com/", - "help_text": "Apply at developer.whoop.com — recovery, strain, sleep", + "help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.", + "has_webhook": True, + "sync_service": "whoop_sync", }, "oura": { "name": "Oura Ring", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://cloud.ouraring.com/oauth/authorize", + "token_url": "https://api.ouraring.com/oauth/token", + "scopes": "daily,workout,heartrate,session", "help_url": "https://cloud.ouraring.com/v2/docs", - "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV", + "help_text": "Register at cloud.ouraring.com — self-serve, immediate access.", + "has_webhook": True, + "sync_service": "oura_sync", }, + "coros": { + "name": "COROS (via MCP)", + "type": "mcp_bridge", + "category": "device", + "fields": [], + "help_url": "https://support.coros.com/hc/en-us/articles/17085887816340", + "help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.", + "has_webhook": False, + "sync_service": None, + }, + "fitbit": { + "name": "Fitbit", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "auth_url": "https://www.fitbit.com/oauth2/authorize", + "token_url": "https://api.fitbit.com/oauth2/token", + "help_url": "https://dev.fitbit.com/", + "help_text": "Register at dev.fitbit.com via Google Cloud Console.", + "has_webhook": True, + "sync_service": None, + }, + "withings": { + "name": "Withings", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "help_url": "https://developer.withings.com/", + "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.", + "has_webhook": False, + "sync_service": None, + }, + "suunto": { + "name": "Suunto", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "help_url": "https://apizone.suunto.com/", + "help_text": "Apply at Suunto developer portal — contract required, ~2 week response.", + "has_webhook": True, + "sync_service": None, + }, + + # ═══ AI Coaching Providers ═══ + + "openai": { + "name": "OpenAI", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.openai.com/v1", + "api_format": "openai", + "help_url": "https://platform.openai.com/api-keys", + "help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.", + "default_model": "gpt-4o-mini", + }, + "openrouter": { + "name": "OpenRouter", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://openrouter.ai/api/v1", + "api_format": "openai", + "help_url": "https://openrouter.ai/keys", + "help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.", + "default_model": "openai/gpt-4o-mini", + }, + "huggingface": { + "name": "Hugging Face", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://router.huggingface.co/v1", + "api_format": "openai", + "help_url": "https://huggingface.co/settings/tokens", + "help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.", + "default_model": "meta-llama/Llama-3.3-70B-Instruct", + }, + "groq": { + "name": "Groq", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.groq.com/openai/v1", + "api_format": "openai", + "help_url": "https://console.groq.com/keys", + "help_text": "Ultra-fast inference. Free tier available. Also used for program research.", + "default_model": "llama-3.3-70b-versatile", + }, + "ollama": { + "name": "Ollama (Local)", + "type": "endpoint", + "category": "ai_provider", + "fields": ["endpoint_url"], + "base_url": "http://host.docker.internal:11434/v1", + "api_format": "openai", + "help_url": "https://ollama.com/", + "help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434", + "default_model": "llama3.1", + }, + "claude": { + "name": "Anthropic Claude", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.anthropic.com/v1", + "api_format": "anthropic", + "help_url": "https://console.anthropic.com/", + "help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.", + "default_model": "claude-sonnet-4-6", + }, + "gemini": { + "name": "Google Gemini", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "api_format": "gemini", + "help_url": "https://aistudio.google.com/apikey", + "help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.", + "default_model": "gemini-2.0-flash", + }, + "custom_ai": { + "name": "Custom (OpenAI-compatible)", + "type": "endpoint", + "category": "ai_provider", + "fields": ["endpoint_url", "api_key"], + "api_format": "openai", + "help_url": "", + "help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).", + "default_model": "", + }, + + # ═══ Nutrition & Data ═══ + "usda": { "name": "USDA FoodData Central", "type": "api_key", + "category": "nutrition", "fields": ["api_key"], "help_url": "https://fdc.nal.usda.gov/api-key-signup", "help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.", @@ -68,22 +216,20 @@ PROVIDER_REGISTRY: dict[str, dict] = { "nutritionix": { "name": "Nutritionix", "type": "api_key", + "category": "nutrition", "fields": ["app_id", "api_key"], "help_url": "https://developer.nutritionix.com/", "help_text": "Free tier: 1K calls/month. Natural language food parsing.", }, - "groq": { - "name": "Groq (Program Research)", - "type": "api_key", - "fields": ["api_key"], - "help_url": "https://console.groq.com/keys", - "help_text": "Free API key. Enables AI-powered workout program extraction.", - }, + + # ═══ Notifications ═══ + "telegram": { "name": "Telegram Notifications", "type": "api_key", + "category": "notifications", "fields": ["bot_token", "chat_id"], "help_url": "https://core.telegram.org/bots#botfather", - "help_text": "Create a bot via @BotFather, then get your chat ID", + "help_text": "Create a bot via @BotFather, then get your chat ID.", }, }