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.
This commit is contained in:
parent
6e082ce58f
commit
9a8e6ce1eb
4 changed files with 555 additions and 27 deletions
64
backend/app/routers/ai_chat.py
Normal file
64
backend/app/routers/ai_chat.py
Normal file
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue