File size: 6,062 Bytes
31cee57
3c31a2a
 
 
 
dc1b199
3c31a2a
dc1b199
 
 
 
 
31cee57
 
 
 
 
3c31a2a
dc1b199
31cee57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
31cee57
732b14f
3c31a2a
 
 
 
 
 
 
 
 
 
31cee57
 
 
 
 
 
3c31a2a
 
 
 
31cee57
 
 
 
 
3c31a2a
 
dc1b199
31cee57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
3c31a2a
dc1b199
31cee57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
31cee57
 
 
 
 
 
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
# syntax=docker/dockerfile:1.6
# ── Stage 1: builder ─────────────────────────────────────────────────────────
# Installs all Python dependencies (including native extensions) into a venv
# that is copied to the lean runtime image, keeping the final layer small.
FROM python:3.11-slim AS builder

# Build dependencies required by native extensions (pymupdf, faiss-cpu)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libgomp1 \
    && rm -rf /var/lib/apt/lists/*

# Quiet pip and prevent root-action hand-wringing in build logs.
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore \
    PIP_NO_CACHE_DIR=1

WORKDIR /build

# Hatchling resolves `readme = "README.md"` and `packages = ["app"]` from
# pyproject.toml during metadata generation, so all three must be present
# *before* `pip install` runs. The build-cache key for this RUN step
# therefore changes whenever pyproject.toml, README.md, or app/ source
# changes β€” accepted, to keep the install step correct.
COPY pyproject.toml README.md ./
COPY app/ ./app/

# Production image installs the runtime project only β€” NO `[dev]` extras.
# Dev tools (pytest, mypy, black, ruff, httpx) are ~150 MB of code paths
# that production never executes; shipping them widens the attack surface
# and slows cold pulls on free hosting tiers.
#
# Non-editable install builds a wheel and drops it into site-packages, so the
# `.pth` editable link doesn't dangle when the venv is copied into stage 2.
RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --upgrade pip && \
    /opt/venv/bin/pip install ".[temporal]"


# ── Stage 2: production runtime ───────────────────────────────────────────────
FROM python:3.11-slim AS production

RUN apt-get update && apt-get install -y --no-install-recommends \
    libgomp1 \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Hugging Face Spaces requires the container to run as a user with UID 1000.
# Local docker / docker-compose works fine with this UID too, so we standardise
# on it here. Drop `-r` (system flag) β€” UID 1000 is a regular user, not a
# system account, and combining `-r` with `-u 1000` is contradictory.
RUN groupadd -g 1000 appgroup && \
    useradd -u 1000 -g appgroup -d /home/user -m -s /sbin/nologin appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    HOME=/home/user \
    PIP_DISABLE_PIP_VERSION_CHECK=1

COPY --chown=appuser:appgroup . .

# Pre-create EVERY writable runtime path the application uses by default,
# including the legacy paths that docker-compose mounts named volumes onto
# (/mnt/data/uploads, /data, /data/faiss_index). When Docker mounts a named
# volume to a path that did NOT exist in the image, the resulting volume is
# owned by root β€” and the appuser (UID 1000) cannot write to it, so the very
# first POST /upload fails with PermissionError. Pre-creating the dirs as
# appuser-owned makes Docker initialise each new volume with the right
# ownership, fixing the regression.
RUN mkdir -p /home/user/.report_genius/uploads \
             /home/user/.report_genius/faiss_index \
             /home/user/.report_genius/cache \
             /mnt/data/uploads \
             /data/faiss_index \
             /tmp/section_cache && \
    chown -R appuser:appgroup /home/user /mnt/data /data /tmp/section_cache

USER appuser

# Defaults tuned for a public free-tier deployment. Anything here is overridable
# at runtime via the platform's secret/env-var manager.
#
# - DEV_MODE=false: production logging (no SQLAlchemy echo) and triggers the
#   strict-secret startup guard in app/main.py.
# - KNOWLEDGE_BASE_ENABLED=false: source PDFs (Behrang RICS Documents,
#   RAW Context) are gitignored; the seeder would only log "no files found".
#   Skipping it removes a noisy startup task and the app surfaces a WARNING
#   to make the trade-off visible in deploy logs.
# - DATABASE/index/upload paths land under $HOME so the container works out
#   of the box on Hugging Face Spaces and on local docker without any extra
#   mount config. docker-compose overrides them via env vars.
# - PORT=7860 matches the Hugging Face Spaces convention; docker-compose
#   pins it to 8000 to preserve existing local URLs.
# - UVICORN_WORKERS=1 is correct for the 1-vCPU HF Spaces free tier; bigger
#   hosts can override at runtime to (2 * cores) + 1.
ENV PORT=7860 \
    UVICORN_WORKERS=1 \
    DEV_MODE=false \
    KNOWLEDGE_BASE_ENABLED=false \
    DATABASE_URL=sqlite+aiosqlite:////home/user/.report_genius/dev.db \
    FAISS_INDEX_PATH=/home/user/.report_genius/faiss_index \
    UPLOAD_DIR=/home/user/.report_genius/uploads \
    CACHE_DIR=/home/user/.report_genius/cache

# Documenting both ports β€” 7860 (HF Spaces default) and 8000 (docker-compose
# override). EXPOSE is informational only; it doesn't bind.
EXPOSE 7860 8000

# Image-level healthcheck so any orchestrator (HF Spaces, Kubernetes, docker
# Swarm, plain `docker run`) sees readiness without needing a separate
# compose file. start-period=45s gives the lifespan hook time to pre-warm the
# embedding model and FAISS store on cold boot.
HEALTHCHECK --interval=30s --timeout=10s --start-period=45s --retries=3 \
    CMD curl -fsS "http://127.0.0.1:${PORT:-7860}/health" || exit 1

# JSON exec form with an inner `exec` so uvicorn replaces /bin/sh as PID 1.
# Without this, SIGTERM (sent by Docker / HF Spaces on every redeploy) goes
# to /bin/sh, which doesn't forward it β€” uvicorn never gets the chance to
# drain in-flight requests and gets SIGKILL after the 10s grace window,
# corrupting FAISS writes and dropping open generations.
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${UVICORN_WORKERS:-1} --loop uvloop --access-log"]