AuthorBot Cursor commited on
Commit
ca62fc7
·
1 Parent(s): 7d25cf2

fix: complete HF startup — libmagic, single worker, lazy models

Browse files

Install 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 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
- HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
 
 
 
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 2 --loop uvloop --http httptools"]
 
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 compute_sha256(file_path: str) -> str:
35
- """Compute SHA-256 hash of a file.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  Args:
38
  file_path: Absolute path to the file.
39
 
40
  Returns:
41
- Hex string of the SHA-256 hash (64 characters).
42
  """
43
- sha256 = hashlib.sha256()
44
- with open(file_path, "rb") as f:
45
- for chunk in iter(lambda: f.read(65536), b""):
46
- sha256.update(chunk)
47
- return sha256.hexdigest()
48
 
 
 
 
 
 
 
 
49
 
50
- def detect_mime_type(file_path: str) -> str:
51
- """Detect MIME type by inspecting file magic bytes.
 
52
 
53
  Args:
54
  file_path: Absolute path to the file.
55
 
56
  Returns:
57
- MIME type string (e.g., 'application/pdf').
58
  """
59
- return magic.from_file(file_path, mime=True)
 
 
 
 
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