services:
  # Reranker (§10.8), OPTIONNEL : désactivé dans la configuration recommandée.
  # Seule image NON figée par empreinte : profil facultatif, non qualifié en 2.21.
  # Démarrage : docker compose --profile reranker up -d reranker, puis RERANKER_URL=http://reranker:80
  reranker:
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
    container_name: reranker
    profiles: ["reranker"]
    command: ["--model-id", "Alibaba-NLP/gte-multilingual-reranker-base", "--auto-truncate", "--max-batch-tokens", "4096", "--max-concurrent-requests", "64"]
    mem_limit: 4g
    environment:
      - HF_HUB_OFFLINE=${RERANKER_OFFLINE:-0}
    volumes:
      - ./reranker_data:/data
    restart: unless-stopped


  qdrant:
    # Versions figées, validées en lab (§3.4). Mettre à jour = revalider (§16.8).
    # Chaque image est désignée par sa version ET par son empreinte (@sha256) :
    # l'empreinte garantit que l'image téléchargée est exactement celle validée,
    # même si l'éditeur republie une version sous le même nom. Empreintes
    # relevées sur la VM de test le 17 octobre 2026 (docker image inspect) ;
    # Qdrant 1.19.2 le 24 octobre 2026 (2.22.5).
    image: qdrant/qdrant:v1.19.2@sha256:b7b0444c4c351c970b98e90a6f89c2ee4287c65b44e52b4cb503fa5b2aa927ad
    container_name: qdrant
    ports:
      # Publié sur la boucle locale uniquement. Docker bypass UFW via iptables :
      # le binding 127.0.0.1 est la seule protection fiable contre
      # un accès LAN non authentifié au corpus Qdrant.
      - "127.0.0.1:6333:6333"
    volumes:
      - ./qdrant_data:/qdrant/storage
    environment:
      # Clé d'API obligatoire pour tout accès à Qdrant (section 15)
      - QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY}
      # Snapshots dans le volume (§3.7) : sans ce réglage, Qdrant les écrit dans
      # /qdrant/snapshots, À L'INTÉRIEUR du conteneur. Ils disparaissent alors à
      # la recréation du conteneur, et le chemin de restauration ne les trouve pas.
      - QDRANT__STORAGE__SNAPSHOTS_PATH=/qdrant/storage/snapshots
    restart: unless-stopped

  n8n:
    image: n8nio/n8n:2.36.9@sha256:a9e2e3c8006ed453238266669ea1274be7136f515abe290a2f75a0ab9044c93d
    container_name: n8n
    # Aucun port publié : n8n est joint par Nginx, https://<NOM-STACK>:5678 (§9.8)
    environment:
      # Pas de N8N_BASIC_AUTH_* : sans effet depuis n8n 1.0. L'accès est protégé
      # par les comptes de n8n : compte propriétaire créé à la première ouverture.
      # Adresse publique de n8n, derrière Nginx (TLS terminé par Nginx, §9.8)
      - N8N_HOST=${RAG_FQDN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_PROXY_HOPS=1
      # N8N_WEBHOOK_URL (n8n 2.30 et suivantes) : WEBHOOK_URL est dépréciée depuis la 2.35
      - N8N_WEBHOOK_URL=https://${RAG_FQDN}:5678/
      # Cookie de session réservé à HTTPS
      - N8N_SECURE_COOKIE=true
      # Aucun paquet communautaire non vérifié (valeur par défaut appelée à changer)
      - N8N_UNVERIFIED_PACKAGES_ENABLED=false
      # Fuseau horaire des déclencheurs planifiés et des dates affichées.
      # Sans lui, n8n compte en America/New_York.
      - GENERIC_TIMEZONE=Europe/Zurich
      - TZ=Europe/Zurich
    volumes:
      - ./n8n_data:/home/node/.n8n
    restart: unless-stopped

  rag-api:
    build: ./api
    container_name: rag-api
    # Port 8080 non publié sur le LAN : joignable uniquement depuis le réseau
    # Compose interne (open-webui via http://rag-api:8080, n8n idem).
    # L'en-tête X-OpenWebUI-User-Email ne peut plus être forgé depuis le LAN.
    environment:
      - QDRANT_HOST=${QDRANT_HOST}
      - QDRANT_API_KEY=${QDRANT_API_KEY}
      - LOG_HMAC_KEY=${LOG_HMAC_KEY}
      - CAS_CLOISONNEMENT=${CAS_CLOISONNEMENT}
      - QDRANT_COLLECTION=${QDRANT_COLLECTION}
      # LLM_BASE_URL : génération (Ollama : port 11434 ; vLLM : port 8000, §2)
      # EMBED_BASE_URL : embeddings (Ollama : port 11434 ; vLLM : port 8001).
      # Le modèle d'embedding doit être celui qui a servi à indexer, sinon les
      # vecteurs des questions ne correspondent plus à ceux de l'index.
      - LLM_BASE_URL=${LLM_BASE_URL}
      - EMBED_BASE_URL=${EMBED_BASE_URL}
      - EMBED_MODEL=${EMBED_MODEL}
      - LLM_MODEL=${LLM_MODEL}
      - JUDGE_MODEL=${JUDGE_MODEL}
      # Protocole des serveurs de modèles (§2) : « ollama » (défaut) ou « openai »
      # (vLLM). JUDGE_BASE_URL vide = LLM_BASE_URL. VLLM_API_KEY : clé --api-key de vLLM.
      - LLM_API=${LLM_API:-ollama}
      - EMBED_API=${EMBED_API:-ollama}
      - JUDGE_BASE_URL=${JUDGE_BASE_URL:-}
      - VLLM_API_KEY=${VLLM_API_KEY:-}
      - ORG_NAME=${ORG_NAME}
      - API_TOKEN=${API_TOKEN}
      - ADMIN_TOKEN=${ADMIN_TOKEN}
      # Secret de vérification de l'identité signée par Open WebUI (point 44)
      - OWUI_JWT_SECRET=${OWUI_JWT_SECRET}
      - LOG_FILE=${LOG_FILE}
      - REPORT_DIR=${REPORT_DIR}
      - TOP_K=${TOP_K}
      - CONTEXT_THRESHOLD=${CONTEXT_THRESHOLD}
      # Passage des droits incomplet : 1 = suspendre l'origine concernée (§16.5.2)
      - ACL_INCERTAIN_FERME=${ACL_INCERTAIN_FERME:-1}
      # Âge maximal des droits, en heures ; 0 désactive ce contrôle (§16.5.2)
      - ACL_AGE_MAX_HEURES=${ACL_AGE_MAX_HEURES:-3}
      # Groupes intégrés (S-1-5-32-...), « Tout le monde » et « Utilisateurs
      # authentifiés » pris en compte : 0 par défaut (§16.5.3)
      - ACL_SID_ETENDUS=${ACL_SID_ETENDUS:-0}
      - MAX_CONTEXT_CHUNKS=${MAX_CONTEXT_CHUNKS}
      - CANDIDATS=${CANDIDATS:-30}
      - PRINCIPAL_MAX=${PRINCIPAL_MAX:-9}
      - CONTEXT_OTHER_DOCS=${CONTEXT_OTHER_DOCS:-6}
      - EXTRAITS_PAR_COMPLEMENT=${EXTRAITS_PAR_COMPLEMENT:-4}
      - LLM_SEED=${LLM_SEED:-}
      - JUDGE_NUM_GPU=${JUDGE_NUM_GPU:-}
      - EMBED_NUM_GPU=${EMBED_NUM_GPU:-}
      - PROMPT_REGLES_V2=${PROMPT_REGLES_V2:-1}
      - COMPLEMENT_VOISINS=${COMPLEMENT_VOISINS:-1}
      - DOC_COMPLET_MAX=${DOC_COMPLET_MAX:-15}
      - RERANKER_URL=${RERANKER_URL:-}
      - RERANK_CANDIDATS=${RERANK_CANDIDATS:-20}
      - RERANK_MODE=${RERANK_MODE:-fusion}
      - RERANK_RRF_K=${RERANK_RRF_K:-10}
      - RERANK_TIMEOUT=${RERANK_TIMEOUT:-20}
      - SUMMARY_KEEP_ALIVE=${SUMMARY_KEEP_ALIVE:-2m}
      - CHUNK_SIZE=${CHUNK_SIZE}
      - CHUNK_OVERLAP=${CHUNK_OVERLAP}
      - MIN_CHUNK_WORDS=${MIN_CHUNK_WORDS}
      # Deux collections Qdrant : corpus entreprise (documents) et documentation technique.
      # Les dossiers racine listés dans DOCUMENTATION_PATHS vont dans DOCUMENTATION_COLLECTION.
      - DOCUMENTATION_COLLECTION=${DOCUMENTATION_COLLECTION}
      - DOCUMENTATION_PATHS=${DOCUMENTATION_PATHS}
      # JUDGE_KEEP_ALIVE : durée de rétention du juge en mémoire Ollama
      # après chaque appel. Format : "5m", "2h", "-1" (indéfiniment).
      # Défaut dans main.py : 2h. Augmenter si le juge se décharge entre
      # deux requêtes. Passer à -1 une fois le GPU installé.
      - JUDGE_KEEP_ALIVE=${JUDGE_KEEP_ALIVE}
      - LDAP_HOST=${LDAP_HOST}
      - LDAP_PORT=${LDAP_PORT}
      - LDAP_USE_TLS=${LDAP_USE_TLS}
      - LDAP_BASE_DN=${LDAP_BASE_DN}
      - LDAP_BIND_DN=${LDAP_BIND_DN}
      - LDAP_BIND_PWD=${LDAP_BIND_PWD}
      - LDAP_DOMAIN=${LDAP_DOMAIN}
      - LDAP_CA_CERT=${LDAP_CA_CERT}
      # Durée du cache des groupes AD en secondes (auth.py, défaut 300)
      - GROUPS_CACHE_TTL=${GROUPS_CACHE_TTL}
      # Extension Entra ID (Partie 3) : résolution des groupes cloud via Graph.
      # Désactivée par défaut (ENTRA_ENABLED=false) : comportement identique
      # à la stack SMB seule.
      - ENTRA_ENABLED=${ENTRA_ENABLED}
      - ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
      - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
      - ENTRA_CERT_PATH=${ENTRA_CERT_PATH}
      - ENTRA_KEY_PATH=${ENTRA_KEY_PATH}
      - ENTRA_CERT_THUMBPRINT=${ENTRA_CERT_THUMBPRINT}
      # Synthèse des réunions Teams (Partie 3) : application RAG-Teams-Reader
      - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID}
      - TEAMS_CERT_PATH=${TEAMS_CERT_PATH}
      - TEAMS_KEY_PATH=${TEAMS_KEY_PATH}
      - TEAMS_CERT_THUMBPRINT=${TEAMS_CERT_THUMBPRINT}
      - TEAMS_GROUP_ID=${TEAMS_GROUP_ID}
      - TEAMS_LOOKBACK_HOURS=${TEAMS_LOOKBACK_HOURS}
      - TEAMS_MAX_PAR_SYNC=${TEAMS_MAX_PAR_SYNC}
      - TEAMS_STATE_FILE=${TEAMS_STATE_FILE}
      # Indexation SharePoint Online (Partie 3, §13) : application RAG-SharePoint-Indexer
      - SP_CLIENT_ID=${SP_CLIENT_ID}
      - SP_CERT_THUMBPRINT=${SP_CERT_THUMBPRINT}
      - SP_KEY_PATH=${SP_KEY_PATH}
      - SP_SITES=${SP_SITES}
      - SP_EXCLUDE_DRIVES=${SP_EXCLUDE_DRIVES}
      - SP_MAX_FILE_MB=${SP_MAX_FILE_MB}
      - SYNC_TIMEOUT_SHAREPOINT=${SYNC_TIMEOUT_SHAREPOINT}
      # Fenêtres de contexte demandées à Ollama (génération RAG et juge)
      - LLM_NUM_CTX=${LLM_NUM_CTX}
      - JUDGE_NUM_CTX=${JUDGE_NUM_CTX}
      # Documents protégés par Purview (§14) : inactif tant que MIP_URL est vide
      - MIP_URL=${MIP_URL}
      - MIP_TOKEN=${MIP_TOKEN}
      - MIP_CACHE_TTL=${MIP_CACHE_TTL}
      - SYNC_SCRIPTS_DIR=${SYNC_SCRIPTS_DIR}
      - SYNC_PYTHON=${SYNC_PYTHON}
      - SYNC_TIMEOUT_INDEXER=${SYNC_TIMEOUT_INDEXER}
      - SYNC_TIMEOUT_ACL=${SYNC_TIMEOUT_ACL}
      - SMB_SHARE=${SMB_SHARE}
      - SMB_MOUNT=${SMB_MOUNT}
      - SMB_USER=${SMB_USER}
      - SMB_PASSWORD=${SMB_PASSWORD}
      - SMB_DOMAIN=${SMB_DOMAIN}
    volumes:
      # Scripts indexeur et résolveur ACL, montés en LECTURE SEULE.
      # Le conteneur les exécute via /admin/sync mais ne peut pas les modifier.
      # Les rapports JSON sont écrits dans /var/log/rag (monté en écriture).
      - /root/rag-pipeline:/rag-pipeline:ro
      # Partage SMB monté sur l'hôte (accès aux fichiers pour l'indexeur)
      - /mnt/corpus-root:/mnt/corpus-root:ro
      # Certificat CA du contrôleur de domaine pour la validation TLS LDAP
      - /etc/ssl/certs/ad-chain.pem:/etc/ssl/certs/ad-chain.pem:ro
      # Logs nLPD persistants sur l'hôte (audit, rétention 90 jours)
      - /var/log/rag:/var/log/rag
      # Certificat et clé de l'App Registration Entra (lecture seule)
      - /etc/rag-certs:/etc/rag-certs:ro
    depends_on:
      - qdrant
    # État de santé visible dans docker ps : « Up (healthy) ». Qdrant et n8n
    # n'en ont pas : leurs images ne garantissent pas d'outil pour l'appeler.
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=5)"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    restart: unless-stopped

  # Service de déchiffrement Purview (§14). Ne démarre qu'avec le profil « purview » :
  # COMPOSE_PROFILES=purview dans le .env, ou docker compose --profile purview up -d
  mip-service:
    build: ./mip-service
    container_name: mip-service
    profiles: ["purview"]
    environment:
      - ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
      - MIP_TENANT_DOMAINE=${MIP_TENANT_DOMAINE}
      - MIP_CLIENT_ID=${MIP_CLIENT_ID}
      - MIP_CERT_CRT=/certs/rag-purview.crt
      - MIP_CERT_KEY=/certs/rag-purview.key
      - MIP_TOKEN=${MIP_TOKEN}
      - MIP_ETIQUETTES_EXCLUES=${MIP_ETIQUETTES_EXCLUES}
    volumes:
      # La clé doit appartenir à l'utilisateur 10001 du conteneur (chmod 600)
      - /etc/rag-certs/rag-purview.crt:/certs/rag-purview.crt:ro
      - /etc/rag-certs/rag-purview.key:/certs/rag-purview.key:ro
    tmpfs:
      - /tmp
    read_only: true
    security_opt:
      - no-new-privileges:true
    restart: unless-stopped

  open-webui:
    image: ghcr.io/open-webui/open-webui:v0.11.3@sha256:41daa0cf2561a5d4c8d1ff31ee2a98d93ab4d3ac2605cac69366ff6a3374a933
    container_name: open-webui
    depends_on:
      - rag-api
    # Aucun port publié : Open WebUI est joint par Nginx, https://<NOM-STACK> (§9.8)
    environment:
      - OLLAMA_BASE_URL=http://<IP-HOTE-OLLAMA>:11434
      # Les utilisateurs ne doivent voir que la RAG API (§16.7). Lue au premier
      # démarrage seulement : ensuite, le réglage se fait dans l'interface.
      - ENABLE_OLLAMA_API=false
      - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
      - ENABLE_FORWARD_USER_INFO_HEADERS=true
      # Identité signée : un jeton JWT (X-OpenWebUI-User-Jwt) au lieu des en-têtes
      # simples, que la RAG API refuse. Même secret que OWUI_JWT_SECRET de rag-api.
      - FORWARD_USER_INFO_HEADER_JWT_SECRET=${OWUI_JWT_SECRET}
      # Tâches d'arrière-plan (titre, étiquettes, questions de suivi) : chacune adresse un
      # appel complet à rag-api (recherche, Purview, génération, juge) et une entrée au
      # journal nLPD. Désactivées ici. Comme ENABLE_OLLAMA_API, ces variables ne fixent que
      # l'état initial : une base Open WebUI existante garde ses réglages (Admin > Interface).
      - ENABLE_TITLE_GENERATION=false
      - ENABLE_TAGS_GENERATION=false
      - ENABLE_FOLLOW_UP_GENERATION=false
      - ENABLE_AUTOCOMPLETE_GENERATION=false
      # Pas de vérification de version sur Internet : la veille se fait à la main (§16.8)
      - ENABLE_VERSION_UPDATE_CHECK=false
      - JWT_EXPIRES_IN=${JWT_EXPIRES_IN}
    volumes:
      - ./openwebui_data:/app/backend/data
      - /etc/ssl/certs/ad-chain.pem:/etc/ssl/certs/ad-chain.pem:ro
    restart: unless-stopped

  # Frontal TLS (§9.8) : seul service qui publie des ports sur la VM.
  # Certificat dans /etc/rag-tls (rag.crt, rag.key) : autorité interne, ou
  # auto-signé pour un essai. Configuration : ./nginx/rag.conf, où <NOM-STACK>
  # est remplacé par le nom de la stack (RAG_FQDN).
  nginx:
    # Version figée. Mettre à jour = revalider (§16.8).
    image: nginx:1.30.6-alpine@sha256:970364228b1e4f932abc22d321932816782cca8a20b85a77552a7cfe9acfb3b5
    container_name: nginx
    depends_on:
      - open-webui
      - n8n
    ports:
      - "80:80"
      - "443:443"
      - "5678:5678"
    volumes:
      - ./nginx/rag.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/rag-tls:/etc/nginx/tls:ro
    security_opt:
      - no-new-privileges:true
    restart: unless-stopped
