""" DeepMed-AI Backend — main.py FastAPI application entry point: app setup, lifespan, chat initialization. Module layout: core/ — config, logging services/ — ChatService (HybridRAG), DatabaseService db/ — SQLAlchemy session factory models/ — ORM models schemas/ — Pydantic request/response schemas api/v1/endpoints/ — health, chat, session route handlers api/v1/api.py — router aggregator main.py — FastAPI app + lifespan ← you are here """ import asyncio import os import secrets from contextlib import asynccontextmanager # ── Fix SQLite for ChromaDB (MUST be before any chromadb import) ─────────────── # Python 3.10-slim ships SQLite 3.34, but ChromaDB requires >= 3.35. # pysqlite3-binary provides a newer SQLite; we monkey-patch it in. try: __import__("pysqlite3") import sys sys.modules["sqlite3"] = sys.modules.pop("pysqlite3") except ImportError: pass # Not needed on systems with SQLite >= 3.35 (e.g., Windows, newer Linux) # ── Fix HuggingFace cache path (PHẢI đặt trước mọi import khác) ─────────────── # HF Spaces set HOME=/nonexistent → sentence-transformers/transformers crash. # /tmp luôn writable trong Docker container trên HF Spaces. os.environ.setdefault("HF_HOME", "/tmp/huggingface") os.environ.setdefault("HUGGINGFACE_HUB_CACHE", "/tmp/huggingface/hub") os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", "/tmp/sentence-transformers") os.environ.setdefault("TRANSFORMERS_CACHE", "/tmp/transformers") os.environ.setdefault("XDG_CACHE_HOME", "/tmp/.cache") from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.api.v1.api import api_router from app.core.config import CHAT_DB_PATH, CHROMA_DB_PATH, CHROMA_HF_DATASET, DATA_DIR from app.core.logging_config import logger from app.services.chat_service import chat_service from app.services.database_service import db_service # ── Lifespan ─────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): """Application startup and shutdown lifecycle.""" logger.info("Initializing DeepMed-AI System (TTYT Thanh Ba)...") # 1. Initialize database db_service.init_db() logger.info("Database initialized at %s", CHAT_DB_PATH) # 2. LFS pointer detection (early warning) if os.path.exists(DATA_DIR): lfs_pointers = [] for root, _dirs, files in os.walk(DATA_DIR): for fname in files: if fname.endswith((".pdf", ".docx", ".xlsx")): fp = os.path.join(root, fname) if os.path.getsize(fp) < 500: lfs_pointers.append(fp) if lfs_pointers: logger.error( "LFS POINTER STUBS DETECTED — %d data file(s) are Git LFS pointers, " "not real content. RAG will be EMPTY!\n" "Fix: run `git lfs pull` in the project root, then rebuild the Docker image.\n" "Affected files:\n %s", len(lfs_pointers), "\n ".join(lfs_pointers), ) else: logger.warning("DATA directory not found at %s — RAG will have no data", DATA_DIR) # 3. Download ChromaDB from HuggingFace Dataset if not present locally if not os.path.exists(CHROMA_DB_PATH) or not os.listdir(CHROMA_DB_PATH): logger.info("ChromaDB not found locally, downloading from HF Dataset: %s", CHROMA_HF_DATASET) try: from huggingface_hub import snapshot_download snapshot_download( repo_id=CHROMA_HF_DATASET, repo_type="dataset", local_dir=CHROMA_DB_PATH, local_dir_use_symlinks=False, ) logger.info("ChromaDB downloaded successfully to %s", CHROMA_DB_PATH) except Exception as e: logger.error("Failed to download ChromaDB from HF Dataset: %s", e) else: logger.info("ChromaDB already exists at %s", CHROMA_DB_PATH) # 4. Initialize HybridRAG bot (ChromaDB + BM25 + Reranker) loop = asyncio.get_event_loop() await loop.run_in_executor(None, chat_service.initialize) logger.info("DeepMed-AI System Ready!") yield logger.info("Shutting down DeepMed-AI...") # ── FastAPI App ──────────────────────────────────────────────────────────────── app = FastAPI( title="DeepMed-AI API", description="Trí tuệ nhân tạo của TTYT khu vực Thanh Ba", version="3.0.0", lifespan=lifespan, ) # ── CORS ──────────────────────────────────────────────────────────────────────── _raw_origins = os.getenv( "ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://localhost:7860", ) _allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=_allowed_origins, allow_credentials=True, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization", "X-Requested-With"], ) # ── Session ──────────────────────────────────────────────────────────────────── _session_secret = os.getenv("SESSION_SECRET", secrets.token_hex(32)) app.add_middleware(SessionMiddleware, secret_key=_session_secret) # ── API Routes ───────────────────────────────────────────────────────────────── app.include_router(api_router) # ── Serve React Frontend (SPA) ───────────────────────────────────────────────── STATIC_DIR = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "static") ) if os.path.isdir(STATIC_DIR): assets_dir = os.path.join(STATIC_DIR, "assets") if os.path.isdir(assets_dir): app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") @app.get("/{path:path}") async def serve_static(path: str): # Never intercept API routes — let 404 bubble up naturally if path.startswith("api/"): raise HTTPException(status_code=404, detail="Not found") file_path = os.path.join(STATIC_DIR, path) if os.path.isfile(file_path): return FileResponse(file_path) # SPA fallback: return index.html for client-side routing return FileResponse(os.path.join(STATIC_DIR, "index.html")) else: logger.warning("Static directory not found at %s. Frontend will not be served.", STATIC_DIR) # ── Entry Point ──────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)