Spaces:
Runtime error
Runtime error
feat: synchronize text-to-sql-bot codebase with Hugging Face Space repository, including Docker build configurations
6086e71 | """ | |
| PlainSQL Enterprise β FastAPI Application Factory. | |
| Wires all components: agents, LLM router, RAG, auth, observability, and API routes. | |
| Also serves the frontend at / so everything runs from one URL. | |
| """ | |
| import sys | |
| import os | |
| import time | |
| import uuid | |
| import traceback | |
| # Ensure backend is on path | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from fastapi import FastAPI, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from contextlib import asynccontextmanager | |
| import structlog | |
| from app.config import get_settings | |
| from app.observability.logger import setup_logging | |
| # ββ Conversational fast-path (extracted to app/api/fast_path.py) ββ | |
| from app.api.fast_path import detect_conversational as _detect_conversational | |
| from app.startup import ensure_feedback_table as _ensure_feedback_table | |
| # ββ Global state βββββββββββββββββββββββββββββββββββββββββ | |
| # NOTE: _app_state is written once at startup and read-only during requests. | |
| # Thread-safe for reads under Python GIL. Do NOT mutate during request handling. | |
| _app_state = {} | |
| START_TIME = time.time() | |
| # Path to the frontend β serve the Vite build output (dist/) | |
| _FRONTEND_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| FRONTEND_DIST = os.path.join(_FRONTEND_ROOT, "frontend", "dist") | |
| # Fall back to legacy frontend if dist hasn't been built yet | |
| FRONTEND_DIR = FRONTEND_DIST if os.path.isdir(FRONTEND_DIST) else os.path.join(_FRONTEND_ROOT, "frontend") | |
| async def lifespan(app: FastAPI): | |
| """Application startup and shutdown lifecycle.""" | |
| settings = get_settings() | |
| # ββ Setup Logging ββββββββββββββββββββββββββββββββββββ | |
| setup_logging( | |
| log_level=settings.LOG_LEVEL, | |
| json_output=(settings.ENV == "production"), | |
| ) | |
| logger = structlog.get_logger() | |
| logger.info("startup_begin", app=settings.APP_NAME, version=settings.APP_VERSION, env=settings.ENV) | |
| try: | |
| # ββ Database βββββββββββββββββββββββββββββββββββββ | |
| from app.db.connection import DatabasePool | |
| logger.info("connecting_database") | |
| db_pool = DatabasePool( | |
| settings.DB_URI, | |
| query_timeout=settings.DB_QUERY_TIMEOUT, | |
| pool_size=settings.DB_POOL_SIZE, | |
| max_overflow=settings.DB_MAX_OVERFLOW, | |
| pool_timeout=settings.DB_POOL_TIMEOUT, | |
| ) | |
| _app_state["db_pool"] = db_pool | |
| logger.info("database_connected", tables=len(db_pool.get_tables())) | |
| # ββ LLM Router βββββββββββββββββββββββββββββββββββ | |
| from app.llm.router import ModelRouter | |
| logger.info("initializing_llm_router") | |
| llm_config = { | |
| "default_provider": settings.DEFAULT_LLM_PROVIDER, | |
| "groq_api_key": settings.GROQ_API_KEY, | |
| "groq_model_primary": settings.GROQ_MODEL_PRIMARY, | |
| "groq_model_fast": settings.GROQ_MODEL_FAST, | |
| "groq_base_url": settings.GROQ_BASE_URL, | |
| "huggingface_token": settings.HUGGINGFACEHUB_API_TOKEN, | |
| "huggingface_model": settings.DEFAULT_MODEL, | |
| "openai_api_key": settings.OPENAI_API_KEY, | |
| "anthropic_api_key": settings.ANTHROPIC_API_KEY, | |
| "ollama_base_url": settings.OLLAMA_BASE_URL, | |
| } | |
| llm_router = ModelRouter(llm_config) | |
| _app_state["llm_router"] = llm_router | |
| # ββ RAG Retriever ββββββββββββββββββββββββββββββββ | |
| from app.rag.retriever import HybridRetriever | |
| logger.info("initializing_rag") | |
| rag_retriever = HybridRetriever(db_pool, chroma_persist_dir=settings.CHROMA_PERSIST_DIR) | |
| _app_state["rag_retriever"] = rag_retriever | |
| # ββ Agent Orchestrator βββββββββββββββββββββββββββ | |
| from app.agents.orchestrator import AgentOrchestrator | |
| logger.info("building_agent_graph") | |
| orchestrator = AgentOrchestrator(llm_router, rag_retriever, db_pool) | |
| _app_state["orchestrator"] = orchestrator | |
| # ββ Auto-migrate persistence tables ββββββββββββ | |
| _ensure_feedback_table(db_pool) | |
| from app.db.persistence import ensure_tables, ConversationManager | |
| ensure_tables(db_pool) | |
| conversation_manager = ConversationManager(db_pool) | |
| _app_state["conversation_manager"] = conversation_manager | |
| # ββ Auth Service βββββββββββββββββββββββββββββββββ | |
| from app.auth.jwt_auth import AuthService | |
| auth_service = AuthService( | |
| secret_key=settings.JWT_SECRET_KEY, | |
| algorithm=settings.JWT_ALGORITHM, | |
| expiry_hours=settings.JWT_EXPIRY_HOURS, | |
| ) | |
| _app_state["auth_service"] = auth_service | |
| # ββ Persistent User Store (MySQL-backed) βββββββββ | |
| from app.db.user_repository import UserRepository | |
| user_repo = UserRepository(db_pool) | |
| _app_state["user_repo"] = user_repo | |
| # Seed default users on first startup (idempotent via INSERT IGNORE) | |
| admin_password = os.environ.get("ADMIN_DEFAULT_PASSWORD", "admin123") | |
| analyst_password = os.environ.get("ANALYST_DEFAULT_PASSWORD", "analyst123") | |
| if settings.ENV == "production" and admin_password == "admin123": | |
| raise ValueError( | |
| "ADMIN_DEFAULT_PASSWORD must be changed in production. " | |
| "Set the ADMIN_DEFAULT_PASSWORD environment variable." | |
| ) | |
| user_repo.create( | |
| user_id="user_1", username="admin", email="admin@plainsql.io", | |
| password_hash=auth_service.hash_password(admin_password), | |
| role="admin", tenant_id="default", | |
| ) | |
| user_repo.create( | |
| user_id="user_2", username="analyst", email="analyst@plainsql.io", | |
| password_hash=auth_service.hash_password(analyst_password), | |
| role="analyst", tenant_id="default", | |
| ) | |
| # Backward-compatible dict interface for routes that still use user_store | |
| user_store = {} | |
| for u in user_repo.list_users(): | |
| user_store[u["username"]] = u | |
| _app_state["user_store"] = user_store | |
| # ββ Observability ββββββββββββββββββββββββββββββββ | |
| from app.observability.tracing import QueryTracer | |
| tracer = QueryTracer(langsmith_api_key=settings.LANGSMITH_API_KEY, project=settings.LANGSMITH_PROJECT) | |
| _app_state["tracer"] = tracer | |
| # ββ AI Features ββββββββββββββββββββββββββββββββββ | |
| from app.ai_features.explainer import SQLExplainer | |
| from app.ai_features.insights import InsightsGenerator | |
| from app.ai_features.anomaly import AnomalyDetector | |
| _app_state["explainer"] = SQLExplainer(llm_router) | |
| _app_state["insights_gen"] = InsightsGenerator() | |
| _app_state["anomaly_detector"] = AnomalyDetector() | |
| # ββ Input Validator ββββββββββββββββββββββββββββββ | |
| from app.security.input_validator import InputValidator | |
| _app_state["input_validator"] = InputValidator(max_length=1000) | |
| # ββ Cache & Rate Limiting (Redis with in-memory fallback) ββ | |
| from app.cache.redis_client import create_cache, create_rate_limiter | |
| from app.api.middleware import create_auth_dependency | |
| _app_state["rate_limiter"] = create_rate_limiter( | |
| redis_url=settings.REDIS_URL, rpm=settings.RATE_LIMIT_RPM, | |
| ) | |
| _app_state["cache"] = create_cache( | |
| redis_url=settings.REDIS_URL, ttl_seconds=settings.CACHE_TTL_SECONDS, | |
| ) | |
| _app_state["auth_dep"] = create_auth_dependency(auth_service) | |
| # ββ Register API Routes ββββββββββββββββββββββββββ | |
| from app.api.routes.chat import create_chat_router | |
| from app.api.routes.system import create_system_router | |
| chat_router = create_chat_router( | |
| orchestrator=orchestrator, auth_dep=_app_state["auth_dep"], | |
| cache=_app_state["cache"], rate_limiter=_app_state["rate_limiter"], | |
| tracer=tracer, explainer=_app_state["explainer"], | |
| insights_gen=_app_state["insights_gen"], anomaly_detector=_app_state["anomaly_detector"], | |
| safety_validator=_app_state["input_validator"], | |
| ) | |
| app.include_router(chat_router) | |
| auth_router, schema_router, analytics_router, health_router = create_system_router( | |
| auth_service=auth_service, auth_dep=_app_state["auth_dep"], | |
| db_pool=db_pool, rag_retriever=rag_retriever, llm_router=llm_router, | |
| tracer=tracer, user_repo=user_repo, start_time=START_TIME, | |
| ) | |
| app.include_router(auth_router) | |
| app.include_router(schema_router) | |
| app.include_router(analytics_router) | |
| app.include_router(health_router) | |
| # ββ Monitoring βββββββββββββββββββββββββββββββββββ | |
| from app.api.routes.monitoring import create_monitoring_router, get_metrics_collector | |
| monitoring_router = create_monitoring_router() | |
| app.include_router(monitoring_router) | |
| _app_state["metrics_collector"] = get_metrics_collector() | |
| # ββ Conversations API βββββββββββββββββββββββββββββ | |
| from app.api.routes.conversations import create_conversations_router | |
| conv_router = create_conversations_router(conversation_manager) | |
| app.include_router(conv_router) | |
| # ββ Admin API ββββββββββββββββββββββββββββββββββββ | |
| from app.api.routes.admin import create_admin_router | |
| admin_router = create_admin_router( | |
| rag_retriever=rag_retriever, | |
| cache=_app_state["cache"], | |
| auth_dep=_app_state["auth_dep"], | |
| db_pool=db_pool, | |
| llm_router=llm_router, | |
| orchestrator=orchestrator, | |
| ) | |
| app.include_router(admin_router) | |
| # ββ Request Deduplicator ββββββββββββββββββββββββββ | |
| from app.security.dedup import RequestDeduplicator | |
| _app_state["dedup"] = RequestDeduplicator() | |
| # ββ Legacy /chat endpoint ββββββββββββββββββββββββ | |
| is_production = settings.ENV == "production" | |
| _register_legacy_chat(app, orchestrator, tracer, _app_state["rate_limiter"], _app_state["input_validator"], _app_state["metrics_collector"], conversation_manager, _app_state["dedup"], require_auth=is_production, auth_service=auth_service) | |
| logger.info("startup_complete", | |
| providers=llm_router.list_providers(), | |
| tables=db_pool.get_tables(), | |
| rag_docs=rag_retriever.collection.count(), | |
| ) | |
| # ββ Startup Smoke Test ββββββββββββββββββββββββββββ | |
| from app.startup import run_smoke_test | |
| run_smoke_test(db_pool, rag_retriever, llm_router) | |
| yield | |
| except Exception as e: | |
| logger.error("startup_failed", error=str(e)) | |
| raise | |
| finally: | |
| logger.info("shutdown_complete") | |
| # ββ Startup utilities (extracted to app/startup.py) ββ | |
| def _register_legacy_chat(app: FastAPI, orchestrator, tracer, rate_limiter, input_validator, metrics_collector, conversation_manager=None, dedup=None, require_auth: bool = False, auth_service=None): | |
| """Backward-compatible /chat endpoint for the frontend β now async with metrics.""" | |
| from pydantic import BaseModel, Field | |
| from typing import List, Optional | |
| import json as json_mod | |
| class LegacyChatRequest(BaseModel): | |
| question: str = Field(..., min_length=1, max_length=1000) | |
| history: Optional[List[dict]] = [] | |
| conversation_id: Optional[str] = None | |
| class FeedbackRequest(BaseModel): | |
| message_id: str = Field(..., min_length=1, max_length=64) | |
| user_query: str = Field(..., min_length=1, max_length=1000) | |
| generated_sql: Optional[str] = "" | |
| rating: str = Field(..., pattern="^(up|down)$") | |
| comment: Optional[str] = "" | |
| async def submit_feedback(request: FeedbackRequest): | |
| """Store user feedback on generated SQL for RLHF data collection.""" | |
| try: | |
| db_pool = _app_state.get("db_pool") | |
| if not db_pool: | |
| return JSONResponse(status_code=503, content={"error": "Database unavailable"}) | |
| db_pool._execute_write_internal( | |
| """INSERT INTO query_feedback (message_id, user_query, generated_sql, rating, comment) | |
| VALUES (:p0, :p1, :p2, :p3, :p4)""", | |
| (request.message_id, request.user_query, request.generated_sql or "", request.rating, request.comment or ""), | |
| ) | |
| structlog.get_logger().info( | |
| "feedback_recorded", | |
| message_id=request.message_id, | |
| rating=request.rating, | |
| ) | |
| return {"status": "ok", "message": "Feedback recorded. Thank you!"} | |
| except Exception as e: | |
| structlog.get_logger().error("feedback_failed", error=str(e)) | |
| return JSONResponse(status_code=500, content={"error": "Failed to save feedback"}) | |
| async def legacy_chat(request: LegacyChatRequest, req: Request): | |
| # ββ Authentication ββ | |
| auth_header = req.headers.get("Authorization", "") | |
| has_token = auth_header.startswith("Bearer ") | |
| if has_token: | |
| try: | |
| if auth_service: | |
| auth_service.verify_token(auth_header[7:]) | |
| except Exception: | |
| return JSONResponse( | |
| status_code=401, | |
| content={"error": "Invalid or expired authentication token."}, | |
| ) | |
| elif require_auth: | |
| # In production, reject unauthenticated requests | |
| return JSONResponse( | |
| status_code=401, | |
| content={"error": "Authentication required. Please log in."}, | |
| ) | |
| # Rate limiting by IP | |
| client_ip = req.client.host if req.client else "unknown" | |
| if not rate_limiter.check(f"legacy:{client_ip}"): | |
| return JSONResponse( | |
| status_code=429, | |
| content={"error": "Rate limit exceeded. Please wait a moment."}, | |
| ) | |
| # Input validation | |
| is_safe, rejection_reason, sanitized = input_validator.validate(request.question) | |
| if not is_safe: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"error": f"Query blocked: {rejection_reason}"}, | |
| ) | |
| result = await orchestrator.aprocess_query( | |
| user_query=sanitized, | |
| conversation_history=input_validator.sanitize_history(request.history or []), | |
| ) | |
| tracer.trace_query(result) | |
| # Record metrics | |
| metrics_collector.record_query( | |
| latency_ms=result.get("execution_time_ms", 0), | |
| intent=result.get("intent", "unknown"), | |
| success=not bool(result.get("error")), | |
| error_agent=result.get("error_agent"), | |
| ) | |
| return { | |
| "answer": result.get("query_results", []), | |
| "sql": result.get("sanitized_sql") or result.get("generated_sql", ""), | |
| "explanation": result.get("sql_explanation", ""), | |
| "message": result.get("friendly_message", ""), | |
| "follow_ups": result.get("follow_up_questions", []), | |
| "insights": result.get("insights", []), | |
| "intent": result.get("intent", ""), | |
| "execution_time_ms": result.get("execution_time_ms", 0), | |
| "row_count": result.get("row_count", 0), | |
| "chart_config": result.get("chart_config"), | |
| } | |
| async def legacy_chat_stream(request: LegacyChatRequest, req: Request): | |
| """SSE streaming endpoint for the frontend.""" | |
| # ββ Authentication ββ | |
| auth_header = req.headers.get("Authorization", "") | |
| has_token = auth_header.startswith("Bearer ") | |
| if has_token: | |
| try: | |
| if auth_service: | |
| auth_service.verify_token(auth_header[7:]) | |
| except Exception: | |
| return JSONResponse( | |
| status_code=401, | |
| content={"error": "Invalid or expired authentication token."}, | |
| ) | |
| elif require_auth: | |
| return JSONResponse( | |
| status_code=401, | |
| content={"error": "Authentication required. Please log in."}, | |
| ) | |
| client_ip = req.client.host if req.client else "unknown" | |
| if not rate_limiter.check(f"stream:{client_ip}"): | |
| return JSONResponse( | |
| status_code=429, | |
| content={"error": "Rate limit exceeded."}, | |
| ) | |
| is_safe, rejection_reason, sanitized = input_validator.validate(request.question) | |
| if not is_safe: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"error": f"Query blocked: {rejection_reason}"}, | |
| ) | |
| # ββ Request deduplication βββββββββββββββββββββββββ | |
| is_new_request = True | |
| query_hash = "" | |
| if dedup: | |
| is_new_request, query_hash = dedup.try_acquire(sanitized) | |
| if not is_new_request: | |
| # Another request is already processing this query β wait for it | |
| dedup_result = dedup.wait_for_result(query_hash) | |
| if dedup_result: | |
| async def dedup_generator(): | |
| yield f"data: {json_mod.dumps({'type': 'stage', 'stage': 'dedup', 'message': 'Using result from concurrent request...'})}\n\n" | |
| yield f"data: {json_mod.dumps(dedup_result, default=str)}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'done', 'total_time_ms': 0, 'deduplicated': True})}\n\n" | |
| return StreamingResponse( | |
| dedup_generator(), | |
| media_type="text/event-stream", | |
| headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, | |
| ) | |
| # ββ Redis cache check βββββββββββββββββββββββββββββ | |
| cache = _app_state.get("cache") | |
| cached_result = None | |
| if cache: | |
| try: | |
| cached_result = cache.get(sanitized) | |
| except Exception: | |
| pass | |
| # ββ Sanitize conversation history βββββββββββββββββ | |
| safe_history = input_validator.sanitize_history(request.history or []) | |
| async def event_generator(): | |
| import time as time_mod | |
| start = time_mod.perf_counter() | |
| # ββ Conversational fast-path (server-side) ββββ | |
| fast_response = _detect_conversational(sanitized) | |
| if fast_response: | |
| elapsed_ms = round((time_mod.perf_counter() - start) * 1000, 2) | |
| yield f"data: {json_mod.dumps({'type': 'message', 'message': fast_response, 'insights': [], 'follow_ups': []})}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'done', 'total_time_ms': elapsed_ms, 'chat_mode': True})}\n\n" | |
| structlog.get_logger().info("conversational_fast_path", query=sanitized[:50], elapsed_ms=elapsed_ms) | |
| if dedup and query_hash: | |
| dedup.release(query_hash) | |
| return | |
| # ββ Cache HIT: return immediately βββββββββββββ | |
| if cached_result: | |
| elapsed_ms = round((time_mod.perf_counter() - start) * 1000, 2) | |
| yield f"data: {json_mod.dumps({'type': 'stage', 'stage': 'cache_hit', 'message': 'Retrieved from cache...'})}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'intent', 'intent': cached_result.get('intent', ''), 'complexity': cached_result.get('complexity', '')}, default=str)}\n\n" | |
| sql = cached_result.get('sql', '') | |
| if sql: | |
| yield f"data: {json_mod.dumps({'type': 'sql', 'sql': sql, 'explanation': cached_result.get('explanation', '')}, default=str)}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'results', 'data': cached_result.get('answer', [])[:100], 'row_count': cached_result.get('row_count', 0), 'execution_time_ms': elapsed_ms}, default=str)}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'message', 'message': cached_result.get('message', ''), 'insights': cached_result.get('insights', []), 'follow_ups': cached_result.get('follow_ups', [])}, default=str)}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'done', 'total_time_ms': elapsed_ms, 'cached': True})}\n\n" | |
| structlog.get_logger().info("cache_hit_served", query=sanitized[:50], elapsed_ms=elapsed_ms) | |
| # Release dedup slot | |
| if dedup and query_hash: | |
| dedup.release(query_hash) | |
| return | |
| # ββ Progressive streaming pipeline βββββββββββββ | |
| # Uses aprocess_query_streaming() which yields events as each | |
| # pipeline stage completes, instead of waiting for everything. | |
| sql = "" | |
| last_event = {} | |
| try: | |
| async for event in orchestrator.aprocess_query_streaming( | |
| user_query=sanitized, | |
| conversation_history=safe_history, | |
| ): | |
| last_event = event | |
| event_type = event.get("type", "") | |
| # Track SQL for caching/persistence | |
| if event_type == "sql": | |
| sql = event.get("sql", "") | |
| # Forward every event to the frontend as SSE | |
| yield f"data: {json_mod.dumps(event, default=str)}\n\n" | |
| # ββ Post-pipeline: metrics, cache, persistence ββ | |
| elapsed_ms = last_event.get("total_time_ms", round((time_mod.perf_counter() - start) * 1000, 2)) | |
| has_error = last_event.get("error", False) | |
| metrics_collector.record_query( | |
| latency_ms=elapsed_ms, | |
| intent="unknown", | |
| success=not has_error, | |
| error_agent="pipeline" if has_error else None, | |
| ) | |
| # Write to Redis cache | |
| if cache and sql and not has_error: | |
| try: | |
| cache_payload = { | |
| "sql": sql, "explanation": "", "message": "", | |
| "answer": [], "intent": "", "complexity": "", | |
| "row_count": 0, "insights": [], "follow_ups": [], | |
| } | |
| cache.set(sanitized, cache_payload) | |
| except Exception: | |
| pass | |
| # Persist messages | |
| if conversation_manager and request.conversation_id: | |
| try: | |
| conversation_manager.save_user_message(request.conversation_id, sanitized) | |
| conversation_manager.save_assistant_message( | |
| conversation_id=request.conversation_id, | |
| content="", generated_sql=sql, explanation="", | |
| friendly_message="", intent="", | |
| execution_time_ms=elapsed_ms, row_count=0, result_data=[], | |
| ) | |
| except Exception: | |
| pass | |
| except Exception as pipeline_err: | |
| elapsed_ms = round((time_mod.perf_counter() - start) * 1000, 2) | |
| structlog.get_logger().error( | |
| "sse_pipeline_crash", | |
| error=str(pipeline_err), | |
| query=sanitized[:80], | |
| elapsed_ms=elapsed_ms, | |
| ) | |
| metrics_collector.record_query( | |
| latency_ms=elapsed_ms, intent="unknown", | |
| success=False, error_agent="pipeline", | |
| ) | |
| yield f"data: {json_mod.dumps({'type': 'error', 'error': 'An internal error occurred. Please try again.'})}\n\n" | |
| yield f"data: {json_mod.dumps({'type': 'done', 'total_time_ms': elapsed_ms, 'error': True})}\n\n" | |
| finally: | |
| if dedup and query_hash: | |
| try: | |
| dedup.complete(query_hash, {'type': 'results', 'sql': sql, 'row_count': 0}) | |
| except Exception: | |
| dedup.release(query_hash) | |
| return StreamingResponse( | |
| event_generator(), | |
| media_type="text/event-stream", | |
| headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, | |
| ) | |
| def create_app() -> FastAPI: | |
| settings = get_settings() | |
| is_production = settings.ENV == "production" | |
| app = FastAPI( | |
| title="PlainSQL Enterprise API", | |
| description="Production-grade Text-to-SQL multi-agent system", | |
| version=settings.APP_VERSION, | |
| lifespan=lifespan, | |
| # Disable API docs in production to prevent schema disclosure | |
| docs_url=None if is_production else "/docs", | |
| redoc_url=None if is_production else "/redoc", | |
| ) | |
| # ββ CORS β use configured origins, not wildcard ββββββ | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.CORS_ORIGINS, | |
| allow_origin_regex=r"https://.*\.vercel\.app", | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], | |
| allow_headers=["Authorization", "Content-Type", "X-API-Key", "X-Request-ID"], | |
| ) | |
| # ββ Global Exception Handler βββββββββββββββββββββββββ | |
| async def global_exception_handler(request: Request, exc: Exception): | |
| """ | |
| Catch-all exception handler. Returns structured JSON errors | |
| without leaking stack traces to clients. | |
| """ | |
| request_id = getattr(request.state, "request_id", "unknown") | |
| logger = structlog.get_logger() | |
| logger.error( | |
| "unhandled_exception", | |
| request_id=request_id, | |
| path=request.url.path, | |
| method=request.method, | |
| error_type=type(exc).__name__, | |
| error=str(exc), | |
| traceback=traceback.format_exc(), | |
| ) | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "error": "Internal server error", | |
| "request_id": request_id, | |
| "message": "An unexpected error occurred. Please try again or contact support.", | |
| }, | |
| ) | |
| # ββ Request ID Middleware ββββββββββββββββββββββββββββ | |
| async def request_id_middleware(request: Request, call_next): | |
| """Assign a unique request ID to every request for correlation.""" | |
| request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8]) | |
| request.state.request_id = request_id | |
| # Bind to structlog context for all log entries in this request | |
| structlog.contextvars.clear_contextvars() | |
| structlog.contextvars.bind_contextvars(request_id=request_id) | |
| start_time = time.perf_counter() | |
| response = await call_next(request) | |
| elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2) | |
| response.headers["X-Request-ID"] = request_id | |
| response.headers["X-Response-Time-Ms"] = str(elapsed_ms) | |
| structlog.get_logger().info( | |
| "http_request", | |
| method=request.method, | |
| path=request.url.path, | |
| status=response.status_code, | |
| elapsed_ms=elapsed_ms, | |
| ) | |
| return response | |
| # ββ Serve frontend at root βββββββββββββββββββββββββββ | |
| async def serve_frontend(): | |
| index_path = os.path.join(FRONTEND_DIR, "index.html") | |
| if os.path.exists(index_path): | |
| with open(index_path, "r", encoding="utf-8") as f: | |
| return HTMLResponse(content=f.read()) | |
| return HTMLResponse("<h1>Frontend not found</h1>", status_code=404) | |
| async def serve_styles(): | |
| return FileResponse(os.path.join(FRONTEND_DIR, "styles.css"), media_type="text/css") | |
| async def serve_app_js(): | |
| return FileResponse(os.path.join(FRONTEND_DIR, "app.js"), media_type="application/javascript") | |
| # ββ Serve Vite build assets (JS, CSS chunks, images) β | |
| assets_dir = os.path.join(FRONTEND_DIR, "assets") | |
| if os.path.isdir(assets_dir): | |
| app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") | |
| return app | |
| app = create_app() | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) | |