# Python agent plus the optional retrieval extras.
#
# The extras are baked in so hybrid search works in the container with no network
# at run time: this image has no compiler and no route to Hugging Face, so whatever
# is not here now is unreachable later. The extras stay optional on the host --
# `agent/rag` imports LangChain inside the functions that need it, so a checkout
# with nothing installed still runs on BM25 keyword search alone.
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONPATH=/app/src \
    AGENT_HOST=0.0.0.0 \
    AGENT_PORT=1234 \
    AGENT_DATA_DIR=/data \
    AGENT_RAG_INDEX_DIR=/app/.rag-cache \
    AGENT_TILE_CACHE=/app/.tile-cache

WORKDIR /app

# Code first, so a code-only change reuses the cached layer. The package is copied
# to /app/agent with PYTHONPATH=/app/src above, so `python -m agent.web` resolves
# the same way it does on the host.
COPY src/agent/ ./agent/
COPY src/run.py README.md ./

# --- optional retrieval extras -----------------------------------------------
# This layer costs ~276 MB of packages and ~241 MB for the ONNX embedding model,
# about 650 MB of image. That is the price of hybrid search working offline, and it
# was a deliberate trade: without it the container answers everything with BM25
# keyword search and says so in the log, which cannot get from "Bolzen" to
# "M8 hex bolt" across languages.
#
# The pins in requirements-rag.txt are ranges (langchain-core >=1.6,<2). Resolved
# once here and then baked in, so the image is reproducible for a given
# requirements file even though the resolution was not pinned exactly.
COPY requirements-rag.txt ./
RUN pip install --no-cache-dir -r requirements-rag.txt

# Bake the embedding model in rather than letting fastembed fetch it on first use.
# Without this the container starts, reaches for Hugging Face, fails, and silently
# drops to keyword search -- the "starts fine, answers from the wrong place" failure
# this project keeps running into. FASTEMBED_CACHE_PATH is set for a related
# reason: the default is ~/.cache/fastembed, not writable for a non-root uid.
ENV FASTEMBED_CACHE_PATH=/opt/rag-model
RUN python -c "\
from fastembed import TextEmbedding; \
TextEmbedding('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')" \
 && chmod -R a+rX /opt/rag-model

# Mount your own files over this at run time:  -v $PWD/data:/data
# PYTHONDONTWRITEBYTECODE stops bytecode being written at run time (it is set
# above); this removes what the COPY layer left behind, so the image does not ship
# .pyc files built for the build machine's Python version.
#
# /app/.rag-cache holds the vector index, which is derived from data/. Mode 1777
# rather than a chown: docker-compose.yml runs the container as
# "${AGENT_UID:-1000}:${AGENT_GID:-1000}" so the bind-mounted data/ stays owned by
# whoever owns it on the host, and that uid differs per machine. A directory owned
# by any single one of them is unwritable on the next. Chowning it to 10001, this
# image's own `agent` user, was measured failing -- "touch /app/.rag-cache/probe:
# Permission denied" while the server ran as uid 1000. The sticky bit, as in /tmp,
# stops one uid deleting another's index while still letting both write.
#
# It is not a volume: this is derived state, invalidated by the mtime+size signature
# in rag/store.py when data/ changes, and for a corpus this size a rebuild is a
# second or two. Mount a volume here if you index something much larger.
# /app/.tile-cache holds map tiles, fetched at run time by the /api/tile proxy and
# cached on disk. Same reasoning as .rag-cache: mode 1777 so the container's uid can
# write it, and outside the build context via .dockerignore.
RUN mkdir -p /data /app/.rag-cache /app/.tile-cache \
 && chmod 1777 /app/.rag-cache /app/.tile-cache \
 && find /app -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true

# Run unprivileged. The image has no writable app state to protect.
RUN useradd --system --uid 10001 agent && chown -R agent:agent /data
USER agent

EXPOSE 1234

# Healthcheck targets HTTPS when a cert is mounted, and falls back to HTTP when
# not. Unverified context is correct here: the cert is self-signed by design, and
# this only proves the port answers -- it is not asserting a chain of trust.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD python -c "\
import os, ssl, sys, urllib.request; \
url = ('https' if os.environ.get('AGENT_TLS_CERT') else 'http') + '://127.0.0.1:1234/api/health'; \
ctx = ssl._create_unverified_context() if url.startswith('https') else None; \
sys.exit(0 if urllib.request.urlopen(url, timeout=4, context=ctx).status == 200 else 1)"

CMD ["python", "-m", "agent.web"]