File size: 7,790 Bytes
8eaa451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3d50d7
8eaa451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3d50d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8eaa451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""
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)