"""FastAPI 入口。挂载路由,放行前端跨域,启动时同步接口定义。 启动: uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000 路由分组(对齐 dograh 的 routes/ 结构): /health 健康检查 /api/assistants 助手 CRUD /api/interface-definitions 接口定义 /api/model-resources 模型资源 CRUD /ws/voice WebRTC 输出(浏览器) /ws/stream WS 输出(裸音频流) /api/webrtc/ice-servers WebRTC STUN/TURN 配置 """ import asyncio import os # NLTK 3.9+ blocks dependency imports when .venv lives under cwd (local dev layout). # pipecat -> nltk -> regex hits this false positive; disable before any nltk import. os.environ.setdefault("NLTK_DISABLE_IMPORT_SECURITY", "1") from contextlib import asynccontextmanager import settings import uvicorn from db.session import sync_default_tools, sync_interface_definitions from loguru import logger from services.openai_api import ( PUBLIC_API_NAME, RequestIDMiddleware, openai_error_response, ) from services.knowledge import recover_interrupted_documents from services.openai_responses.state import recover_interrupted_responses from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker from services.test_runs.orchestrator import ( recover_interrupted_test_runs, test_run_orchestrator, ) from fastapi import FastAPI, Request from fastapi.exception_handlers import ( http_exception_handler, request_validation_exception_handler, ) from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException from routes import ( assistants, auth, conversations, health, input_assets, knowledge_bases, mcp_servers, model_registry, node_types, openai_models, openai_realtime, openai_responses, realtime_api_keys, site_settings, test_cases, test_assets, test_runs, tools, voice_webrtc, voice_ws, ) @asynccontextmanager async def lifespan(_app: FastAPI): await sync_interface_definitions() await sync_default_tools() await recover_interrupted_documents() await recover_interrupted_analyses() await recover_interrupted_webhooks() await recover_interrupted_test_runs() await recover_interrupted_responses() analysis_worker = asyncio.create_task( run_analysis_worker(), name="post-call-analysis-worker" ) webhook_worker = asyncio.create_task( run_webhook_worker(), name="analysis-webhook-worker" ) try: yield finally: analysis_worker.cancel() webhook_worker.cancel() await asyncio.gather( analysis_worker, webhook_worker, return_exceptions=True ) await test_run_orchestrator.shutdown() await voice_webrtc.shutdown_active_sessions() app = FastAPI( title="AI Video Assistant 平台 - 后端", description=f"Public protocol: {PUBLIC_API_NAME}.", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Request-ID", "X-Idempotent-Replayed", "Location"], ) app.add_middleware(RequestIDMiddleware) def _validation_param(error: dict) -> str | None: location = list(error.get("loc") or []) if location and location[0] in {"body", "query", "path", "header"}: location.pop(0) if not location or location == ["json"]: return None value = "" for part in location: if isinstance(part, int): value += f"[{part}]" else: value += ("." if value else "") + str(part) return value or None @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError, ): if not request.url.path.startswith("/v1/"): return await request_validation_exception_handler(request, exc) first = (exc.errors() or [{}])[0] return openai_error_response( str(first.get("msg") or "Request does not match the API schema"), code="invalid_request_error", param=_validation_param(first), status_code=422, ) @app.exception_handler(StarletteHTTPException) async def public_http_exception_handler( request: Request, exc: StarletteHTTPException, ): if not request.url.path.startswith("/v1/"): return await http_exception_handler(request, exc) code = "not_found" if exc.status_code == 404 else "http_error" return openai_error_response( str(exc.detail), code=code, status_code=exc.status_code, ) @app.exception_handler(Exception) async def public_unhandled_exception_handler(request: Request, exc: Exception): if not request.url.path.startswith("/v1/"): raise exc logger.exception( f"Unhandled public API error: {type(exc).__name__}" ) return openai_error_response( "The server encountered an internal error", code="internal_error", status_code=500, ) app.include_router(health.router) app.include_router(auth.router) app.include_router(conversations.router) app.include_router(input_assets.router) app.include_router(assistants.router) app.include_router(knowledge_bases.router) app.include_router(mcp_servers.router) app.include_router(model_registry.router) app.include_router(node_types.router) app.include_router(openai_models.router) app.include_router(openai_realtime.router) app.include_router(openai_responses.router) app.include_router(realtime_api_keys.router) app.include_router(site_settings.router) app.include_router(test_cases.router) app.include_router(test_assets.router) app.include_router(test_runs.router) app.include_router(tools.router) app.include_router(voice_webrtc.router) app.include_router(voice_ws.router) if __name__ == "__main__": uvicorn.run("app:app", host=settings.HOST, port=settings.PORT, reload=True)