""" FastAPI 应用入口 挂载 v1 API 路由、CORS 中间件、生命周期管理 """ from __future__ import annotations import json import logging from contextlib import asynccontextmanager from pathlib import Path from typing import AsyncGenerator from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from app.config import settings from app.database import close_db, init_db @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: """ 应用生命周期管理: - 启动时:初始化数据目录、验证数据库连接、预热嵌入服务、初始化 BM25 索引 - 关闭时:释放数据库连接 """ _logger = logging.getLogger(__name__) # ── 启动 ── settings.ensure_dirs() await init_db() # 预热嵌入服务(懒加载,首次调用时初始化) try: from app.services.embedding_service import EmbeddingService EmbeddingService.get_instance() except Exception as exc: _logger.warning("嵌入服务预热失败(将在首次使用时重试): %s", exc) # ── 初始化 BM25 索引 ── try: from app.services.search_engine import BM25Index import app.api.v1.images as images_module _images_bm25 = BM25Index() # 加载数据库中已有的 OCR 记录到索引 from app.database import async_session_factory from app.models.base import OCRImage from sqlalchemy import select async with async_session_factory() as session: result = await session.execute( select(OCRImage).where(OCRImage.status == "completed") ) records = result.scalars().all() for record in records: if record.ocr_text: index_text = record.ocr_text if record.tags: try: tags = json.loads(record.tags) if tags: index_text += "\n" + " ".join(tags) except Exception: pass if record.story_summary: index_text += "\n" + record.story_summary _images_bm25.add_document( doc_id=record.id, text=index_text, metadata={ "file_path": record.file_path, "tags": json.loads(record.tags) if record.tags else [], "story_summary": record.story_summary or "", "confidence": record.confidence, "provider": record.provider, "created_at": str(record.created_at), }, ) images_module.bm25_index = _images_bm25 _logger.info("BM25 索引加载完成: %d 条记录", _images_bm25.size) except Exception as exc: _logger.warning("BM25 索引初始化失败: %s", exc) yield # ── 关闭 ── await close_db() # ──────────────────────────── 创建应用 ──────────────────────────── app = FastAPI( title="EduBrain - 中文直播教育知识库", description="基于向量检索的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、语义搜索等功能。", version="1.2.0", lifespan=lifespan, ) # ──────────────────────────── CORS 中间件 ──────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ──────────────────────────── 全局异常处理 ──────────────────────────── @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: """全局未捕获异常处理""" logger = logging.getLogger(__name__) logger.error("未处理异常: %s", exc, exc_info=True) return JSONResponse( status_code=500, content={"detail": "服务器内部错误", "message": str(exc) if settings.DEBUG else "请稍后重试"}, ) # ──────────────────────────── 注册路由 ──────────────────────────── from app.api.v1.pages import router as pages_router from app.api.v1.search import router as search_router from app.api.v1.import_export import router as import_export_router from app.api.v1.images import router as images_router from app.api.v1.settings import router as settings_router app.include_router(pages_router, prefix="/api/v1/pages", tags=["知识页面"]) app.include_router(search_router, prefix="/api/v1/search", tags=["语义搜索"]) app.include_router(import_export_router, prefix="/api/v1/import", tags=["导入导出"]) app.include_router(images_router, prefix="/api/v1/images", tags=["图片OCR"]) app.include_router(settings_router, prefix="/api/v1/settings", tags=["系统设置"]) # ──────────────────────────── 健康检查 ──────────────────────────── @app.get("/health", tags=["系统"]) async def health_check(): """健康检查接口""" return {"status": "ok", "version": "1.2.0"} # ──────────────────────────── 图片文件访问 ──────────────────────────── @app.get("/data/images/{image_name}", tags=["前端"]) async def serve_image(image_name: str): """访问 images 目录下的图片文件(用于前端预览)""" img_path = settings.images_dir / image_name if not img_path.exists(): return JSONResponse(status_code=404, content={"detail": "图片不存在"}) return FileResponse(str(img_path)) # ──────────────────────────── 前端页面 ──────────────────────────── @app.get("/", tags=["前端"]) async def index(): """返回前端管理页面""" static_dir = Path(__file__).parent.parent / "static" index_path = static_dir / "index.html" if index_path.exists(): return FileResponse(str(index_path)) return RedirectResponse(url="/docs")