diligence/backend/app/routers/ai_chat.py
Claude 9a8e6ce1eb Stream C: Multi-provider AI coaching backend + 20-provider registry
AI coaching service (backend/app/services/ai_provider.py):
- Two code paths: OpenAI-compatible (6 providers) + Anthropic (Claude)
- Streaming SSE responses via httpx async
- System prompt built from AGENT_GUIDE + live user context
- Graceful error handling for all provider failures
- Gemini adapter for Google's generateContent API

Chat endpoint (backend/app/routers/ai_chat.py):
- POST /api/ai/chat — SSE streaming response
- GET /api/ai/status — check configured provider
- History capped at 20 messages, content at 4K chars

Provider registry expanded to 20 providers:
- 9 device integrations (strava, polar, garmin, whoop, oura, coros, fitbit, withings, suunto)
- 8 AI providers (openai, openrouter, huggingface, groq, ollama, claude, gemini, custom_ai)
- 3 other (usda, nutritionix, telegram)
- Categorized: device, ai_provider, nutrition, notifications
- COROS: MCP bridge approach (no API integration needed)
- Strava: AI/ML warning per their ToS

Frontend ChatCoach.jsx to follow in next commit.
2026-06-21 23:16:17 +00:00

64 lines
2.1 KiB
Python

"""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}