Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
ca62fc7
1
Parent(s): 7d25cf2
fix: complete HF startup — libmagic, single worker, lazy models
Browse filesInstall libmagic in the Docker image, add MIME fallback detection, use one Uvicorn worker for HF memory limits, and defer ML model loading until first request.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +8 -3
- backend/app/main.py +0 -9
- backend/app/utils/file_utils.py +53 -13
Dockerfile
CHANGED
|
@@ -6,6 +6,7 @@ FROM python:3.11-slim AS builder
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
build-essential \
|
| 8 |
libpq-dev \
|
|
|
|
| 9 |
curl \
|
| 10 |
git \
|
| 11 |
&& rm -rf /var/lib/apt/lists/*
|
|
@@ -22,6 +23,7 @@ FROM python:3.11-slim
|
|
| 22 |
|
| 23 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 24 |
libpq5 \
|
|
|
|
| 25 |
curl \
|
| 26 |
redis-server \
|
| 27 |
&& rm -rf /var/lib/apt/lists/*
|
|
@@ -48,13 +50,16 @@ RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
|
|
| 48 |
chown -R appuser:appuser /app /data
|
| 49 |
USER appuser
|
| 50 |
|
| 51 |
-
# Pre-download models
|
| 52 |
RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" || true
|
| 53 |
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')" || true
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
CMD curl -f http://localhost:8080/health || exit 1
|
| 57 |
|
| 58 |
EXPOSE 8080
|
| 59 |
|
| 60 |
-
CMD ["sh", "-c", "mkdir -p /data/chroma /data/uploads /data/redis && redis-server /app/redis-hf.conf && sleep 1 && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers
|
|
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
build-essential \
|
| 8 |
libpq-dev \
|
| 9 |
+
libmagic1 \
|
| 10 |
curl \
|
| 11 |
git \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
| 23 |
|
| 24 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 25 |
libpq5 \
|
| 26 |
+
libmagic1 \
|
| 27 |
curl \
|
| 28 |
redis-server \
|
| 29 |
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
| 50 |
chown -R appuser:appuser /app /data
|
| 51 |
USER appuser
|
| 52 |
|
| 53 |
+
# Pre-download models at build time (avoids cold-start delays)
|
| 54 |
RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" || true
|
| 55 |
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')" || true
|
| 56 |
|
| 57 |
+
# Verify the app imports cleanly before deployment
|
| 58 |
+
RUN python -c "from app.main import app; print('App import OK:', app.title)"
|
| 59 |
+
|
| 60 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=5 \
|
| 61 |
CMD curl -f http://localhost:8080/health || exit 1
|
| 62 |
|
| 63 |
EXPOSE 8080
|
| 64 |
|
| 65 |
+
CMD ["sh", "-c", "mkdir -p /data/chroma /data/uploads /data/redis && redis-server /app/redis-hf.conf && sleep 1 && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 1 --loop uvloop --http httptools"]
|
backend/app/main.py
CHANGED
|
@@ -30,16 +30,7 @@ logger = structlog.get_logger(__name__)
|
|
| 30 |
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 31 |
"""Handle application startup and shutdown lifecycle."""
|
| 32 |
logger.info("Starting AuthorBot API", env=get_settings().APP_ENV)
|
| 33 |
-
# Startup: warm up local ML models (lazy-loaded via their modules)
|
| 34 |
-
from app.core.rag.intent import get_intent_classifier
|
| 35 |
-
from app.core.rag.reranker import get_reranker
|
| 36 |
-
from app.core.rag.guardrails import get_nli_model
|
| 37 |
-
await get_intent_classifier()
|
| 38 |
-
await get_reranker()
|
| 39 |
-
await get_nli_model()
|
| 40 |
-
logger.info("Local ML models warmed up successfully")
|
| 41 |
yield
|
| 42 |
-
# Shutdown: close any open connections
|
| 43 |
logger.info("AuthorBot API shutting down")
|
| 44 |
|
| 45 |
|
|
|
|
| 30 |
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 31 |
"""Handle application startup and shutdown lifecycle."""
|
| 32 |
logger.info("Starting AuthorBot API", env=get_settings().APP_ENV)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
yield
|
|
|
|
| 34 |
logger.info("AuthorBot API shutting down")
|
| 35 |
|
| 36 |
|
backend/app/utils/file_utils.py
CHANGED
|
@@ -6,9 +6,9 @@ RULE: Always validate by magic bytes, NEVER by file extension alone.
|
|
| 6 |
|
| 7 |
import hashlib
|
| 8 |
import os
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
-
import magic
|
| 12 |
import structlog
|
| 13 |
|
| 14 |
from app.config import get_settings
|
|
@@ -31,32 +31,70 @@ _SUPPORTED_MIME_TYPES: dict[str, str] = {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
-
def
|
| 35 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
Args:
|
| 38 |
file_path: Absolute path to the file.
|
| 39 |
|
| 40 |
Returns:
|
| 41 |
-
|
| 42 |
"""
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
for chunk in iter(lambda: f.read(65536), b""):
|
| 46 |
-
sha256.update(chunk)
|
| 47 |
-
return sha256.hexdigest()
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
|
| 53 |
Args:
|
| 54 |
file_path: Absolute path to the file.
|
| 55 |
|
| 56 |
Returns:
|
| 57 |
-
|
| 58 |
"""
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def get_file_extension_from_mime(mime_type: str) -> str | None:
|
|
@@ -68,6 +106,8 @@ def get_file_extension_from_mime(mime_type: str) -> str | None:
|
|
| 68 |
Returns:
|
| 69 |
Extension label ('pdf', 'epub', 'docx', 'txt') or None if unsupported.
|
| 70 |
"""
|
|
|
|
|
|
|
| 71 |
return _SUPPORTED_MIME_TYPES.get(mime_type)
|
| 72 |
|
| 73 |
|
|
|
|
| 6 |
|
| 7 |
import hashlib
|
| 8 |
import os
|
| 9 |
+
import zipfile
|
| 10 |
from pathlib import Path
|
| 11 |
|
|
|
|
| 12 |
import structlog
|
| 13 |
|
| 14 |
from app.config import get_settings
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
+
def _detect_mime_from_signature(file_path: str) -> str | None:
|
| 35 |
+
"""Detect MIME type from file signatures when libmagic is unavailable."""
|
| 36 |
+
with open(file_path, "rb") as handle:
|
| 37 |
+
header = handle.read(8)
|
| 38 |
+
|
| 39 |
+
if header.startswith(b"%PDF-"):
|
| 40 |
+
return "application/pdf"
|
| 41 |
+
|
| 42 |
+
if zipfile.is_zipfile(file_path):
|
| 43 |
+
with zipfile.ZipFile(file_path) as archive:
|
| 44 |
+
names = archive.namelist()
|
| 45 |
+
if any(name.startswith("word/") for name in names):
|
| 46 |
+
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 47 |
+
if "mimetype" in names:
|
| 48 |
+
with archive.open("mimetype") as mime_file:
|
| 49 |
+
if b"epub" in mime_file.read().lower():
|
| 50 |
+
return "application/epub+zip"
|
| 51 |
+
if any("META-INF/container.xml" in name for name in names):
|
| 52 |
+
return "application/epub+zip"
|
| 53 |
+
|
| 54 |
+
with open(file_path, "rb") as handle:
|
| 55 |
+
sample = handle.read(8192)
|
| 56 |
+
try:
|
| 57 |
+
sample.decode("utf-8")
|
| 58 |
+
return "text/plain"
|
| 59 |
+
except UnicodeDecodeError:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def detect_mime_type(file_path: str) -> str:
|
| 64 |
+
"""Detect MIME type by inspecting file magic bytes.
|
| 65 |
|
| 66 |
Args:
|
| 67 |
file_path: Absolute path to the file.
|
| 68 |
|
| 69 |
Returns:
|
| 70 |
+
MIME type string (e.g., 'application/pdf').
|
| 71 |
"""
|
| 72 |
+
try:
|
| 73 |
+
import magic
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
return magic.from_file(file_path, mime=True)
|
| 76 |
+
except (ImportError, OSError) as exc:
|
| 77 |
+
logger.warning("libmagic unavailable, using signature fallback", error=str(exc))
|
| 78 |
+
mime_type = _detect_mime_from_signature(file_path)
|
| 79 |
+
if mime_type is None:
|
| 80 |
+
raise UnsupportedFormatError(extension=Path(file_path).suffix.lstrip(".")) from exc
|
| 81 |
+
return mime_type
|
| 82 |
|
| 83 |
+
|
| 84 |
+
def compute_sha256(file_path: str) -> str:
|
| 85 |
+
"""Compute SHA-256 hash of a file.
|
| 86 |
|
| 87 |
Args:
|
| 88 |
file_path: Absolute path to the file.
|
| 89 |
|
| 90 |
Returns:
|
| 91 |
+
Hex string of the SHA-256 hash (64 characters).
|
| 92 |
"""
|
| 93 |
+
sha256 = hashlib.sha256()
|
| 94 |
+
with open(file_path, "rb") as f:
|
| 95 |
+
for chunk in iter(lambda: f.read(65536), b""):
|
| 96 |
+
sha256.update(chunk)
|
| 97 |
+
return sha256.hexdigest()
|
| 98 |
|
| 99 |
|
| 100 |
def get_file_extension_from_mime(mime_type: str) -> str | None:
|
|
|
|
| 106 |
Returns:
|
| 107 |
Extension label ('pdf', 'epub', 'docx', 'txt') or None if unsupported.
|
| 108 |
"""
|
| 109 |
+
if mime_type == "application/zip":
|
| 110 |
+
return None
|
| 111 |
return _SUPPORTED_MIME_TYPES.get(mime_type)
|
| 112 |
|
| 113 |
|