"""Main FastAPI application for ChatCal.ai."""
from fastapi import FastAPI, HTTPException, Depends, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from datetime import datetime
import uuid
import json
import logging
from typing import Dict, Any, Optional
from app.config import settings
from app.api.models import (
ChatRequest, ChatResponse, StreamChatResponse, SessionCreate, SessionResponse,
ConversationHistory, HealthResponse, ErrorResponse, AuthRequest, AuthResponse
)
from app.api.chat_widget import router as chat_widget_router
from app.api.simple_chat import router as simple_chat_router
from app.core.session_factory import session_manager
from app.calendar.auth import CalendarAuth
from app.core.exceptions import (
ChatCalException, AuthenticationError, CalendarError,
LLMError, ValidationError, RateLimitError
)
from app.core.llm_anthropic import anthropic_llm
# Create FastAPI app
app = FastAPI(
title="ChatCal.ai",
description="AI-powered calendar assistant for booking appointments",
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Log testing mode status on startup
if settings.testing_mode:
logger.info("๐งช TESTING MODE ENABLED - Peter's email will be treated as regular user email")
else:
logger.info("๐ง Production mode - Peter's email will receive special formatting")
# Calendar auth instance
calendar_auth = CalendarAuth()
# Include routers
app.include_router(chat_widget_router)
app.include_router(simple_chat_router)
# Mount static files
import os
static_path = "/app/static"
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
# Global exception handlers
@app.exception_handler(ChatCalException)
async def chatcal_exception_handler(request: Request, exc: ChatCalException):
"""Handle custom ChatCal exceptions."""
logger.error(f"ChatCal exception: {exc.message}", extra={"details": exc.details})
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"error": exc.__class__.__name__,
"message": exc.message,
"details": exc.details,
"timestamp": datetime.utcnow().isoformat()
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle request validation errors."""
logger.warning(f"Validation error: {exc}")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "ValidationError",
"message": "Invalid request data",
"details": {"validation_errors": exc.errors()},
"timestamp": datetime.utcnow().isoformat()
}
)
@app.exception_handler(500)
async def internal_server_error_handler(request: Request, exc: Exception):
"""Handle internal server errors."""
logger.error(f"Internal server error: {exc}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred. Please try again later.",
"details": {},
"timestamp": datetime.utcnow().isoformat()
}
)
@app.get("/", response_class=HTMLResponse)
async def root():
"""Root endpoint with basic information."""
return """
ChatCal.ai
๐
Schedule Time with Peter Michael Gits
Book consultations, meetings, and advisory sessions with Peter
๐ผ Professional Consultations
Schedule one-on-one business consultations and advisory sessions with Peter Michael Gits
๐ค AI-Powered Scheduling
Our intelligent assistant helps you find the perfect time that works for both you and Peter
๐ง Instant Confirmation
Receive immediate confirmation and calendar invitations for your scheduled meetings
Version 0.1.0 | Built with FastAPI & LlamaIndex
"""
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint."""
services = {}
# Check session backend connection
try:
if hasattr(session_manager, 'redis_client'):
# Redis session manager
session_manager.redis_client.ping()
else:
# JWT session manager - always healthy if initialized
pass
services["session_backend"] = "healthy"
except Exception as e:
logger.error(f"Session backend health check failed: {e}")
services["session_backend"] = "unhealthy"
# Check Groq LLM (via anthropic_llm interface)
try:
from app.core.llm_anthropic import anthropic_llm
if anthropic_llm.test_connection():
services["llm"] = "healthy"
else:
services["llm"] = "unhealthy"
except Exception as e:
logger.error(f"LLM health check failed: {e}")
services["llm"] = "not_configured"
# Add testing mode status
services["testing_mode"] = "enabled" if settings.testing_mode else "disabled"
# Check Calendar service
try:
if calendar_auth.client_id and calendar_auth.client_secret:
services["calendar"] = "ready"
else:
services["calendar"] = "not_configured"
except Exception as e:
logger.error(f"Calendar health check failed: {e}")
services["calendar"] = "unhealthy"
overall_status = "healthy" if all(status in ["healthy", "ready"] for status in services.values()) else "degraded"
return HealthResponse(
status=overall_status,
version="0.1.0",
timestamp=datetime.utcnow(),
services=services
)
@app.post("/sessions", response_model=SessionResponse)
async def create_session(request: SessionCreate):
"""Create a new chat session."""
try:
# Debug logging for session creation
print(f"๐ง Session creation request received with user_data: {request.user_data}")
if request.user_data and 'email' in request.user_data:
print(f"๐ง Email found in request: {request.user_data['email']}")
else:
print(f"โ No email found in session creation request!")
session_id = session_manager.create_session(request.user_data)
if not session_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create session"
)
session_data = session_manager.get_session(session_id)
if not session_data:
raise HTTPException(status_code=500, detail="Failed to create session")
# Debug logging for stored session data
print(f"๐ง Session {session_id} created successfully")
if 'user_data' in session_data and session_data['user_data']:
print(f"๐ง Stored user_data: {session_data['user_data']}")
if 'email' in session_data['user_data']:
print(f"โ
Email successfully stored in session: {session_data['user_data']['email']}")
else:
print(f"โ Email NOT found in stored session data!")
else:
print(f"โ No user_data found in stored session!")
return SessionResponse(
session_id=session_id,
created_at=datetime.fromisoformat(session_data["created_at"]),
last_activity=datetime.fromisoformat(session_data["last_activity"]),
is_active=True
)
except Exception as e:
logger.error(f"Session creation failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create session. Please try again."
)
@app.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str):
"""Get session information."""
session_data = session_manager.get_session(session_id)
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
return SessionResponse(
session_id=session_id,
created_at=datetime.fromisoformat(session_data["created_at"]),
last_activity=datetime.fromisoformat(session_data["last_activity"]),
is_active=True
)
@app.get("/api/session-info")
async def get_current_session_info(request: Request):
"""Get current session information for the frontend."""
try:
# Extract session ID from cookies
session_id = request.cookies.get("session_id")
if not session_id:
return {"error": "No session found"}
session_data = session_manager.get_session(session_id)
if not session_data:
return {"error": "Session not found"}
return {
"session_id": session_id,
"user_data": session_data.get("user_data", {}),
"created_at": session_data.get("created_at"),
"last_activity": session_data.get("last_activity")
}
except Exception as e:
print(f"Error getting session info: {e}")
return {"error": str(e)}
@app.delete("/sessions/{session_id}")
async def delete_session(session_id: str):
"""Delete a session."""
success = session_manager.delete_session(session_id)
if not success:
raise HTTPException(status_code=404, detail="Session not found")
return {"message": "Session deleted successfully"}
@app.get("/sessions/{session_id}/history", response_model=ConversationHistory)
async def get_conversation_history(session_id: str):
"""Get conversation history for a session."""
session_data = session_manager.get_session(session_id)
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
history = session_manager.get_conversation_history(session_id)
return ConversationHistory(
session_id=session_id,
messages=history.get("messages", []) if history else [],
created_at=datetime.fromisoformat(session_data["created_at"])
)
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""Chat with the AI assistant."""
from app.core.logging_config import log_chat_request, log_chat_response, debug_print
# Enhanced logging for chat requests
session_id = request.session_id or "new_session"
log_chat_request(session_id, request.message)
debug_print(f"CHAT START - Session: {session_id[:8]}...", {"message_length": len(request.message)})
try:
# Create session if not provided
if not request.session_id:
debug_print("Creating new session")
request.session_id = session_manager.create_session()
debug_print(f"New session created: {request.session_id}")
# Get or create conversation
debug_print("Getting conversation manager")
conversation = session_manager.get_or_create_conversation(request.session_id)
if not conversation:
debug_print("ERROR: Conversation not found", {"session_id": request.session_id})
raise HTTPException(status_code=404, detail="Session not found")
debug_print("Conversation found, getting response from agent")
# Get response from agent
response = conversation.get_response(request.message)
debug_print("Agent response received", {"response_length": len(response)})
# Store conversation history
debug_print("Storing conversation history")
session_manager.store_conversation_history(request.session_id)
# Log successful response
log_chat_response(request.session_id, response)
debug_print("CHAT SUCCESS - Response ready")
return ChatResponse(
response=response,
session_id=request.session_id,
timestamp=datetime.utcnow(),
tools_used=None # Will implement tool tracking later
)
except Exception as e:
import traceback
error_traceback = traceback.format_exc()
logger.error(f"Chat error: {str(e)}")
logger.error(f"Traceback: {error_traceback}")
raise HTTPException(status_code=500, detail=f"Chat error: {str(e) if str(e) else 'Unknown error'} | Type: {type(e).__name__}")
@app.post("/chat/stream")
async def stream_chat(request: ChatRequest):
"""Stream chat response from the AI assistant."""
try:
# Create session if not provided
if not request.session_id:
request.session_id = session_manager.create_session()
# Get or create conversation
conversation = session_manager.get_or_create_conversation(request.session_id)
if not conversation:
raise HTTPException(status_code=404, detail="Session not found")
async def generate_stream():
"""Generate streaming response."""
try:
for token in conversation.get_streaming_response(request.message):
chunk = StreamChatResponse(
token=token,
session_id=request.session_id,
is_complete=False
)
yield f"data: {chunk.model_dump_json()}\n\n"
# Send completion signal
final_chunk = StreamChatResponse(
token="",
session_id=request.session_id,
is_complete=True
)
yield f"data: {final_chunk.model_dump_json()}\n\n"
# Store conversation history
session_manager.store_conversation_history(request.session_id)
except Exception as e:
error_chunk = {
"error": str(e),
"session_id": request.session_id,
"is_complete": True
}
yield f"data: {json.dumps(error_chunk)}\n\n"
return StreamingResponse(
generate_stream(),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Stream chat error: {str(e)}")
@app.post("/tts/synthesize")
async def tts_synthesize(request_data: Dict[str, Any], request: Request):
"""TTS synthesis proxy to avoid CORS issues."""
try:
from gradio_client import Client
text = request_data.get("text", "")
voice = request_data.get("voice", "expresso/ex03-ex01_happy_001_channel1_334s.wav")
if not text.strip():
raise HTTPException(status_code=400, detail="Text is required")
logger.info(f"๐ต TTS synthesis request: {text[:50]}...")
# Create fresh TTS client connection for each request to avoid interference
logger.info("๐ Creating fresh TTS client connection")
client = Client("https://pgits-kyutai-tts-service-v3.hf.space")
# Use the correct function name from the actual Gradio interface
# The function is defined as synthesize_and_stream in lines 133-137 of app.py
# It takes (text, voice, session_state) and returns (output_text, session_state, status)
session_state = {}
# Time the TTS generation for performance monitoring
import time
start_time = time.time()
result = client.predict(text, voice, session_state, api_name="/synthesize_and_stream")
synthesis_time = time.time() - start_time
logger.info(f"โฑ๏ธ TTS synthesis took {synthesis_time:.2f} seconds")
if not result:
raise HTTPException(status_code=500, detail="TTS generation failed")
logger.info(f"๐ต TTS synthesis successful: {str(result)[:100]}...")
# The function returns (audio_file_path, session_state, status)
# The first element is the actual audio file path that we can use
if isinstance(result, (list, tuple)) and len(result) >= 1:
audio_file_path = result[0]
# Check if we got a valid file path
if isinstance(audio_file_path, str) and (audio_file_path.endswith(".wav") or audio_file_path.endswith(".mp3")):
import os
import uuid
import base64
# Gradio client downloads the file locally, so we need to read it and serve it
if os.path.exists(audio_file_path):
logger.info(f"๐ต Reading audio file: {audio_file_path} ({os.path.getsize(audio_file_path)} bytes)")
# Store the file in memory temporarily with a unique ID
file_id = str(uuid.uuid4())
with open(audio_file_path, 'rb') as f:
audio_content = f.read()
# Store in a simple in-memory cache (you could use Redis here for production)
if not hasattr(app.state, 'audio_cache'):
app.state.audio_cache = {}
app.state.audio_cache[file_id] = audio_content
# Clean up old entries (keep last 10)
if len(app.state.audio_cache) > 10:
oldest_keys = list(app.state.audio_cache.keys())[:-10]
for old_key in oldest_keys:
del app.state.audio_cache[old_key]
# Clean up the local file
try:
os.unlink(audio_file_path)
except:
pass
# Create URL for our audio endpoint
if "hf.space" in str(request.url.netloc):
base_url = f"https://{request.url.netloc}"
else:
base_url = f"{request.url.scheme}://{request.url.netloc}"
audio_url = f"{base_url}/tts/audio/{file_id}"
return {
"success": True,
"audio_url": audio_url,
"text": text,
"file_id": file_id
}
else:
raise HTTPException(status_code=500, detail=f"TTS audio file not found: {audio_file_path}")
else:
# If it's not a file path, treat it as an error message
raise HTTPException(status_code=500, detail=f"TTS error: {audio_file_path}")
else:
raise HTTPException(status_code=500, detail="Unexpected TTS response format")
except Exception as e:
logger.error(f"TTS synthesis error: {e}")
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {str(e)}")
@app.get("/tts/audio/{file_id}")
async def get_tts_audio(file_id: str):
"""Serve TTS audio files from in-memory cache."""
try:
# Check if file exists in cache
if not hasattr(app.state, 'audio_cache') or file_id not in app.state.audio_cache:
logger.warning(f"๐ Audio file not found in cache: {file_id}")
raise HTTPException(status_code=404, detail="Audio file not found or expired")
audio_content = app.state.audio_cache[file_id]
logger.info(f"๐ Serving audio file: {file_id} ({len(audio_content)} bytes)")
from fastapi.responses import Response
return Response(
content=audio_content,
media_type="audio/wav",
headers={
"Content-Disposition": "inline",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
"Accept-Ranges": "bytes"
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Audio serving error: {e}")
raise HTTPException(status_code=500, detail=f"Audio serving failed: {str(e)}")
@app.get("/auth/login", response_model=AuthResponse)
async def google_auth_login(request: Request, state: Optional[str] = None):
"""Initiate Google OAuth login."""
try:
auth_url, oauth_state = calendar_auth.get_authorization_url(state)
return AuthResponse(
auth_url=auth_url,
state=oauth_state
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Auth error: {str(e)}")
@app.get("/auth/callback")
async def google_auth_callback(request: Request, code: str, state: str):
"""Handle Google OAuth callback."""
try:
# Reconstruct the authorization response URL
authorization_response = str(request.url)
# Exchange code for credentials
credentials = calendar_auth.handle_callback(authorization_response, state)
return {
"message": "Authentication successful! Your calendar is now connected.",
"status": "success",
"expires_at": credentials.expiry.isoformat() if credentials.expiry else None
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
@app.get("/auth/status")
async def auth_status():
"""Check authentication status."""
try:
is_authenticated = calendar_auth.is_authenticated()
return {
"authenticated": is_authenticated,
"calendar_id": settings.google_calendar_id if is_authenticated else None,
"message": "Connected to Google Calendar" if is_authenticated else "Not authenticated"
}
except Exception as e:
return {
"authenticated": False,
"error": str(e),
"message": "Authentication check failed"
}
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Custom HTTP exception handler."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": "HTTPException",
"message": exc.detail,
"details": {"status_code": exc.status_code},
"timestamp": datetime.utcnow().isoformat()
}
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""General exception handler."""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": type(exc).__name__,
"message": str(exc),
"details": {"path": str(request.url)},
"timestamp": datetime.utcnow().isoformat()
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.api.main:app",
host=settings.app_host,
port=settings.app_port,
reload=True if settings.app_env == "development" else False
)