# CanLex MCP server -- remote (streamable-HTTP) image. # # Builds anywhere: Hugging Face Spaces, Google Cloud Run, Fly.io, plain Docker. # Retrieval is fully local and key-free; the optional CanLII case citator reads # its key from the CANLII_API_KEY environment variable (supplied as a host # secret -- the key is never copied into the image). FROM python:3.12-slim # libgomp1 is the OpenMP runtime that onnxruntime (the reranker) links against. RUN apt-get update \ && apt-get install -y --no-install-recommends libgomp1 \ && rm -rf /var/lib/apt/lists/* # Run as a non-root user (UID 1000) -- required by Hugging Face Spaces. RUN useradd --create-home --home-dir /app --uid 1000 app WORKDIR /app # Python dependencies first, so this layer caches across code changes. The # image installs requirements.lock: the whole set, transitive dependencies # included, each pinned with its sha256 hashes -- `uv pip compile` of # requirements.txt for Linux / Python 3.12 (RUNBOOK.md, "Audit follow-up # implementation (2026-09-21): group C"). Hash-checking mode refuses any # distribution but the locked one, so every build installs the same set (until # the 2026-09-21 audit only the 8 direct pins were fixed and starlette, anyio # and the other transitives were re-resolved at each build). COPY requirements.lock . RUN pip install --no-cache-dir --require-hashes -r requirements.lock # Application code. COPY --chown=app:app canlex/ ./canlex/ # Pre-fetch the embedder and cross-encoder models so the cache is baked into the # image and the first request needs no network. The corpus is NOT re-embedded # here -- the committed embeddings.npz is used as-is; only the bge query model # (for embedding live queries) and the reranker are downloaded. # # Layer order (the code audit of 2026-09-21, optimization 9): this step reads # canlex/ and the network only -- Embedder() and Reranker() read nothing under # data/ at init -- so it runs BEFORE the corpus and curated COPYs below. Docker # rebuilds every layer after the first one whose input changed; after those # COPYs, every corpus refresh, curated change and composed release (which # rewrites embeddings.npz) re-ran this step and re-downloaded the 331 MB of model # files. Here only a change under canlex/ or to requirements.lock does. (Whether # the Hugging Face builder keeps layers between builds is outside our control.) # The user and environment it needs come first; the image's final user, # environment and files are what they were before the move. USER app # CANLEX_LOG_DIR turns on the local call log (canlex/calllog.py: one JSON line # per tool call, arguments included; loganalyze.py mines it). On the Space this # path is ephemeral -- lost on every restart or rebuild. The durable log is the # private Hugging Face dataset the server pushes to when the Space secrets # CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN are set (RUNBOOK.md, "Call log"); the # HF_HUB_OFFLINE=1 below still refuses every other Hub request, model files # included -- calllog lets only that dataset's requests through. ENV HOME=/app \ HF_HOME=/app/.hf_cache \ CANLEX_HTTP=1 \ CANLEX_LOG_DIR=/tmp/canlex-logs \ PORT=7860 \ PYTHONUNBUFFERED=1 RUN python -c "from canlex.embed import Embedder; Embedder()" \ && python -c "from canlex.rerank import Reranker; Reranker()" # The processed corpus (section-chunk JSON) and the prebuilt semantic # embeddings (embeddings.npz, committed via git-LFS). Shipping the embeddings # as an artifact keeps the build fast: embedding ~20k chunks on the build host # took ~33 min and was tripping Hugging Face's job timeout as the corpus grew. # Regenerate with 'python -m canlex.embed' after any corpus change and commit # the result. COPY --chown=app:app data/processed/*.json ./data/processed/ COPY --chown=app:app data/processed/embeddings.npz ./data/processed/ # Curated commentary datasets (the canlex_us_disposition tool reads the # structured source file directly, not just its processed chunks). COPY --chown=app:app data/curated/ ./data/curated/ # From here on, model files are served from the baked cache, never fetched. ENV HF_HUB_OFFLINE=1 EXPOSE 7860 CMD ["python", "-m", "canlex.server"]