================================================================================ PRD 3 — LLMs WELLNESS + BIOMISTRAL + ROUTEUR INTELLIGENT ================================================================================ Projet : SaaS Wellness & Médical — Extension Microservice Python Complément : PRD 1 (KYC) + PRD 2 (NLP/Audio/Documents) — tous opérationnels ✅ OS : AlmaLinux 9.7 Python : 3.11.13 (venv : /opt/kyc-service/venv/) Port actif : 20900 Service : kyc-service.service (systemd, user: kyc) RAM serveur : 64 GB / 8 cores (mise à jour confirmée) Rédigé pour : Cursor AI (exécution autonome étape par étape) Version : 3.0.0 ================================================================================ ÉTAT DE DÉPART — CE QUI EXISTE DÉJÀ (NE PAS RETOUCHER) ──────────────────────────────────────────────────────────────────────────────── Stack PRD 1+2 déjà opérationnel : ✅ PaddleOCR PP-OCRv5 ~210 MB (OCR documents) ✅ AuraFace-v1 ~408 MB (Face matching) ✅ HyperFace-10k-LDM ~373 MB (optionnel) ✅ Whisper Large-v3 Turbo ~1.5 GB (transcription audio) ✅ DistilBERT SST-2 ~270 MB (sentiment général) ✅ RoBERTa Social ~499 MB (sentiment social media) ✅ Pyannote 3.1 ~800 MB (diarisation locuteurs) ✅ all-MiniLM-L6-v2 ~90 MB (embeddings rapides) ✅ BAAI/bge-m3 ~570 MB (embeddings multilingues) ✅ Donut CORD-v2 ~2.0 GB (compréhension documents) ───────────────────────────────────────────────────────────────────────────── TOTAL stack existant ~6.7 GB NOUVEAUX MODÈLES À INSTALLER (dans cet ordre exact) ──────────────────────────────────────────────────────────────────────────────── #1 Phi-4-mini-instruct microsoft/Phi-4-mini-instruct MIT ~2.5 GB #2 Qwen3-14B-Instruct Qwen/Qwen3-14B Apache 2.0 ~9.0 GB #3 Gemma 3 27B-IT google/gemma-3-27b-it Apache 2.0 ~14.0 GB #4 BioMistral-7B AWQ BioMistral/BioMistral-7B-AWQ Apache 2.0 ~4.7 GB #5 Gemma 4 E4B-IT google/gemma-4-e4b-it Apache 2.0 ~5.0 GB BUDGET RAM TOTAL APRÈS INSTALLATION ──────────────────────────────────────────────────────────────────────────────── Système + AlmaLinux + Laravel + MySQL + Redis ~1.5 GB Stack PRD 1+2 (tous modèles NLP/KYC/Audio) ~6.7 GB Phi-4-mini (Q4) ~2.5 GB Qwen3-14B (Q4_K_M) ~9.0 GB Gemma 3 27B-IT (QAT int4) ~14.0 GB BioMistral-7B (AWQ 4-bit) ~4.7 GB Gemma 4 E4B (Q4) ~5.0 GB ───────────────────────────────────────────────────────────────────────────── TOTAL ~43.4 GB MARGE DISPONIBLE ~20.6 GB ✅ RÈGLES IMPÉRATIVES POUR CURSOR ──────────────────────────────────────────────────────────────────────────────── 1. TOUJOURS activer le venv : source /opt/kyc-service/venv/bin/activate 2. Les LLMs utilisent llama-cpp-python pour l'inférence CPU — PAS transformers. 3. Les fichiers GGUF sont stockés dans /opt/kyc-service/models/llm/ 4. NE PAS redémarrer kyc-service.service avant la Phase 22. 5. Utiliser dnf (AlmaLinux) pour les packages système. 6. Après chaque CHECKPOINT ✅, attendre validation avant de continuer. 7. BioMistral nécessite un système de garde-fou — NE PAS l'exposer sans filtres. 8. Le routeur intelligent doit être testé avant la mise en production. ================================================================================ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 19 — PRÉPARATION ENVIRONNEMENT LLM ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 19.1 — Dépendances système (AlmaLinux 9.7) ────────────────────────────────────────────────── sudo dnf install -y \ cmake \ gcc \ gcc-c++ \ make \ openblas \ openblas-devel \ wget \ git # Vérifier cmake (requis pour compiler llama-cpp-python) cmake --version CHECKPOINT ✅ : cmake version 3.x doit s'afficher. ÉTAPE 19.2 — Créer l'arborescence LLM ─────────────────────────────────────── sudo mkdir -p /opt/kyc-service/models/llm/{phi4,qwen3-14b,gemma3-27b,biomistral,gemma4-e4b} sudo chown -R kyc:kyc /opt/kyc-service/models/llm/ mkdir -p /opt/kyc-service/app/llm/ touch /opt/kyc-service/app/llm/__init__.py CHECKPOINT ✅ : ls /opt/kyc-service/models/llm/ doit lister les 5 dossiers. ÉTAPE 19.3 — Installer llama-cpp-python (moteur inférence CPU) ─────────────────────────────────────────────────────────────── source /opt/kyc-service/venv/bin/activate # Installer avec support OpenBLAS pour accélérer le CPU CMAKE_ARGS="-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS" \ pip install llama-cpp-python==0.3.4 --no-cache-dir # Vérifier python -c "from llama_cpp import Llama; print('llama-cpp-python OK')" CHECKPOINT ✅ : "llama-cpp-python OK" doit s'afficher. NOTE : Si la compilation échoue avec OpenBLAS : pip install llama-cpp-python==0.3.4 --no-cache-dir (version sans accélération BLAS — fonctionne mais plus lent) ÉTAPE 19.4 — Installer huggingface_hub CLI pour les téléchargements GGUF ───────────────────────────────────────────────────────────────────────── pip install huggingface_hub[cli] --upgrade # Vérifier huggingface-cli --version CHECKPOINT ✅ : version huggingface-cli affichée. ÉTAPE 19.5 — Étendre le fichier .env ────────────────────────────────────── Ajouter ces lignes à la FIN de /opt/kyc-service/.env : -------- LIGNES À AJOUTER dans .env -------- # ── LLM Models Paths ────────────────────────────────────── LLM_DIR=/opt/kyc-service/models/llm # ── Phi-4-mini ──────────────────────────────────────────── PHI4_MODEL_PATH=/opt/kyc-service/models/llm/phi4/Phi-4-mini-instruct-Q4_K_M.gguf PHI4_CONTEXT=8192 PHI4_THREADS=4 PHI4_MAX_TOKENS=1024 # ── Qwen3-14B ───────────────────────────────────────────── QWEN3_14B_PATH=/opt/kyc-service/models/llm/qwen3-14b/Qwen3-14B-Q4_K_M.gguf QWEN3_14B_CONTEXT=16384 QWEN3_14B_THREADS=6 QWEN3_14B_MAX_TOKENS=2048 # ── Gemma 3 27B ─────────────────────────────────────────── GEMMA3_27B_PATH=/opt/kyc-service/models/llm/gemma3-27b/gemma-3-27b-it-Q4_K_M.gguf GEMMA3_27B_MMPROJ_PATH=/opt/kyc-service/models/llm/gemma3-27b/mmproj-gemma3-27b-f16.gguf GEMMA3_27B_CONTEXT=16384 GEMMA3_27B_THREADS=8 GEMMA3_27B_MAX_TOKENS=2048 # ── BioMistral-7B ───────────────────────────────────────── BIOMISTRAL_PATH=/opt/kyc-service/models/llm/biomistral/BioMistral-7B-AWQ-Q4_K_M.gguf BIOMISTRAL_CONTEXT=8192 BIOMISTRAL_THREADS=4 BIOMISTRAL_MAX_TOKENS=1024 # Seuil de score DistilBERT en dessous duquel on bloque la requête BIOMISTRAL_SAFETY_THRESHOLD=0.85 # ── Gemma 4 E4B ─────────────────────────────────────────── GEMMA4_E4B_PATH=/opt/kyc-service/models/llm/gemma4-e4b/gemma-4-e4b-it-Q4_K_M.gguf GEMMA4_E4B_CONTEXT=32768 GEMMA4_E4B_THREADS=6 GEMMA4_E4B_MAX_TOKENS=2048 # ── Routeur LLM ─────────────────────────────────────────── LLM_ROUTER_DEFAULT=qwen3_14b LLM_LOAD_ON_STARTUP=phi4,qwen3_14b,biomistral # qwen3_14b chargé au démarrage, gemma3_27b et gemma4_e4b à la demande LLM_LAZY_LOAD=gemma3_27b,gemma4_e4b -------- FIN DES LIGNES À AJOUTER -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 20 — TÉLÉCHARGEMENT DES MODÈLES GGUF ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 20.1 — Télécharger Phi-4-mini (Q4_K_M ~2.3 GB) ────────────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_phi4.py : -------- DÉBUT DU SCRIPT download_phi4.py -------- """ Télécharge Phi-4-mini-instruct en format GGUF Q4_K_M. Modèle : microsoft/Phi-4-mini-instruct (via unsloth GGUF) Licence : MIT — Usage commercial autorisé Taille : ~2.3 GB """ import os from pathlib import Path from huggingface_hub import hf_hub_download TARGET_DIR = Path("/opt/kyc-service/models/llm/phi4") TARGET_DIR.mkdir(parents=True, exist_ok=True) print("Téléchargement Phi-4-mini-instruct Q4_K_M...") path = hf_hub_download( repo_id="unsloth/Phi-4-mini-instruct-GGUF", filename="Phi-4-mini-instruct-Q4_K_M.gguf", local_dir=str(TARGET_DIR), ) size_gb = Path(path).stat().st_size / 1e9 print(f"✅ Phi-4-mini téléchargé : {path}") print(f" Taille : {size_gb:.2f} GB") -------- FIN DU SCRIPT -------- source /opt/kyc-service/venv/bin/activate python /opt/kyc-service/scripts/download_phi4.py CHECKPOINT ✅ : Fichier Phi-4-mini-instruct-Q4_K_M.gguf présent dans /opt/kyc-service/models/llm/phi4/ CHECKPOINT ✅ : Taille > 2 GB ÉTAPE 20.2 — Télécharger Qwen3-14B (Q4_K_M ~9 GB) ──────────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_qwen3_14b.py : -------- DÉBUT DU SCRIPT download_qwen3_14b.py -------- """ Télécharge Qwen3-14B-Instruct en format GGUF Q4_K_M. Modèle : Qwen/Qwen3-14B (via bartowski GGUF) Licence : Apache 2.0 — Usage commercial autorisé Taille : ~9.0 GB Durée : 15-30 minutes selon connexion """ import os from pathlib import Path from huggingface_hub import hf_hub_download TARGET_DIR = Path("/opt/kyc-service/models/llm/qwen3-14b") TARGET_DIR.mkdir(parents=True, exist_ok=True) print("Téléchargement Qwen3-14B Q4_K_M (~9 GB)...") print("Cette étape peut prendre 15-30 minutes. Ne pas interrompre.\n") path = hf_hub_download( repo_id="bartowski/Qwen_Qwen3-14B-GGUF", filename="Qwen_Qwen3-14B-Q4_K_M.gguf", local_dir=str(TARGET_DIR), ) size_gb = Path(path).stat().st_size / 1e9 print(f"\n✅ Qwen3-14B téléchargé : {path}") print(f" Taille : {size_gb:.2f} GB") -------- FIN DU SCRIPT -------- python /opt/kyc-service/scripts/download_qwen3_14b.py CHECKPOINT ✅ : Fichier GGUF présent, taille > 8.5 GB. ÉTAPE 20.3 — Télécharger Gemma 3 27B-IT QAT (~14 GB) ────────────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_gemma3_27b.py : -------- DÉBUT DU SCRIPT download_gemma3_27b.py -------- """ Télécharge Gemma 3 27B-IT en format GGUF QAT int4. Modèle : google/gemma-3-27b-it (version QAT officielle Google) Licence : Apache 2.0 — Usage commercial autorisé Taille : ~14 GB (modèle) + ~500 MB (projecteur vision) Durée : 25-45 minutes selon connexion """ from pathlib import Path from huggingface_hub import hf_hub_download TARGET_DIR = Path("/opt/kyc-service/models/llm/gemma3-27b") TARGET_DIR.mkdir(parents=True, exist_ok=True) print("Téléchargement Gemma 3 27B-IT QAT int4 (~14 GB)...") print("Ne pas interrompre.\n") # Modèle principal model_path = hf_hub_download( repo_id="bartowski/google_gemma-3-27b-it-GGUF", filename="google_gemma-3-27b-it-Q4_K_M.gguf", local_dir=str(TARGET_DIR), ) # Projecteur vision (requis pour l'analyse d'images) print("\nTéléchargement du projecteur vision (~500 MB)...") mmproj_path = hf_hub_download( repo_id="bartowski/google_gemma-3-27b-it-GGUF", filename="mmproj-google_gemma-3-27b-it-f16.gguf", local_dir=str(TARGET_DIR), ) print(f"\n✅ Gemma 3 27B modèle : {model_path}") print(f"✅ Gemma 3 27B mmproj : {mmproj_path}") size_gb = Path(model_path).stat().st_size / 1e9 print(f" Taille modèle : {size_gb:.2f} GB") -------- FIN DU SCRIPT -------- python /opt/kyc-service/scripts/download_gemma3_27b.py CHECKPOINT ✅ : Deux fichiers présents (modèle + mmproj), modèle > 13 GB. ÉTAPE 20.4 — Télécharger BioMistral-7B AWQ (~4.7 GB) ────────────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_biomistral.py : -------- DÉBUT DU SCRIPT download_biomistral.py -------- """ Télécharge BioMistral-7B en format GGUF Q4_K_M. Modèle : BioMistral/BioMistral-7B (via MaziyarPanahi GGUF) Licence : Apache 2.0 (code) — données PubMed Central Taille : ~4.1 GB IMPORTANT : Ce modèle est déployé avec des garde-fous obligatoires. Il ne répond JAMAIS sans filtres de sécurité actifs. """ from pathlib import Path from huggingface_hub import hf_hub_download TARGET_DIR = Path("/opt/kyc-service/models/llm/biomistral") TARGET_DIR.mkdir(parents=True, exist_ok=True) print("Téléchargement BioMistral-7B GGUF Q4_K_M (~4.1 GB)...") path = hf_hub_download( repo_id="MaziyarPanahi/BioMistral-7B-GGUF", filename="BioMistral-7B.Q4_K_M.gguf", local_dir=str(TARGET_DIR), ) size_gb = Path(path).stat().st_size / 1e9 print(f"\n✅ BioMistral-7B téléchargé : {path}") print(f" Taille : {size_gb:.2f} GB") print("\n⚠️ RAPPEL : Toujours utiliser avec les garde-fous de sécurité actifs.") print(" Ne jamais exposer l'endpoint /medical/ask sans filtre DistilBERT.") -------- FIN DU SCRIPT -------- python /opt/kyc-service/scripts/download_biomistral.py CHECKPOINT ✅ : Fichier BioMistral-7B.Q4_K_M.gguf présent, taille > 3.9 GB. ÉTAPE 20.5 — Télécharger Gemma 4 E4B (~5 GB) ────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_gemma4_e4b.py : -------- DÉBUT DU SCRIPT download_gemma4_e4b.py -------- """ Télécharge Gemma 4 E4B-IT (multimodal : texte + image + audio natif). Modèle : google/gemma-4-e4b-it (via unsloth GGUF) Licence : Apache 2.0 — Usage commercial autorisé Taille : ~5 GB """ from pathlib import Path from huggingface_hub import hf_hub_download TARGET_DIR = Path("/opt/kyc-service/models/llm/gemma4-e4b") TARGET_DIR.mkdir(parents=True, exist_ok=True) print("Téléchargement Gemma 4 E4B-IT Q4 (~5 GB)...") path = hf_hub_download( repo_id="unsloth/gemma-4-e4b-it-GGUF", filename="gemma-4-e4b-it-Q4_K_M.gguf", local_dir=str(TARGET_DIR), ) size_gb = Path(path).stat().st_size / 1e9 print(f"\n✅ Gemma 4 E4B téléchargé : {path}") print(f" Taille : {size_gb:.2f} GB") -------- FIN DU SCRIPT -------- python /opt/kyc-service/scripts/download_gemma4_e4b.py CHECKPOINT ✅ : Fichier GGUF présent, taille > 4.5 GB. ÉTAPE 20.6 — Vérification globale des téléchargements ─────────────────────────────────────────────────────── # Vérifier que tous les fichiers sont présents find /opt/kyc-service/models/llm/ -name "*.gguf" -exec ls -lh {} \; CHECKPOINT ✅ : Au moins 6 fichiers .gguf listés (5 modèles + 1 mmproj). CHECKPOINT ✅ : du -sh /opt/kyc-service/models/llm/ doit afficher > 34 GB total. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 21 — ÉCRITURE DES MODULES LLM ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 21.1 — Créer app/llm/base.py (classe commune) ───────────────────────────────────────────────────── Créer /opt/kyc-service/app/llm/base.py : -------- DÉBUT DU FICHIER app/llm/base.py -------- """ Classe de base commune pour tous les LLMs llama-cpp-python. Gère le chargement, le déchargement, et l'inférence de manière uniforme. """ import os import time import logging from pathlib import Path from typing import Optional, List, Dict from llama_cpp import Llama logger = logging.getLogger("kyc.llm.base") class BaseLLM: """Classe de base pour tous les modèles LLM GGUF.""" def __init__( self, model_name: str, model_path: str, n_ctx: int = 8192, n_threads: int = 4, max_tokens: int = 1024, temperature: float = 0.7, top_p: float = 0.95, top_k: int = 40, n_gpu_layers: int = 0, # 0 = CPU only verbose: bool = False, ): self.model_name = model_name self.model_path = model_path self.n_ctx = n_ctx self.n_threads = n_threads self.max_tokens = max_tokens self.temperature = temperature self.top_p = top_p self.top_k = top_k self.n_gpu_layers = n_gpu_layers self.verbose = verbose self._model: Optional[Llama] = None self._loaded_at: Optional[float] = None def load(self) -> None: """Charge le modèle en mémoire.""" if self._model is not None: logger.info(f"{self.model_name} déjà chargé") return if not Path(self.model_path).exists(): raise FileNotFoundError( f"Fichier GGUF introuvable : {self.model_path}" ) logger.info(f"Chargement {self.model_name} ({self.model_path})...") start = time.time() self._model = Llama( model_path=self.model_path, n_ctx=self.n_ctx, n_threads=self.n_threads, n_gpu_layers=self.n_gpu_layers, verbose=self.verbose, ) elapsed = round(time.time() - start, 1) self._loaded_at = time.time() logger.info(f"✅ {self.model_name} chargé en {elapsed}s") def unload(self) -> None: """Décharge le modèle de la mémoire.""" if self._model is not None: del self._model self._model = None self._loaded_at = None logger.info(f"{self.model_name} déchargé de la mémoire") @property def is_loaded(self) -> bool: return self._model is not None def chat( self, messages: List[Dict[str, str]], system_prompt: Optional[str] = None, max_tokens: Optional[int] = None, temperature: Optional[float] = None, stream: bool = False, ) -> dict: """ Génère une réponse à partir d'une liste de messages. Args: messages : liste de {role: str, content: str} system_prompt : prompt système (override .env si fourni) max_tokens : override du max_tokens par défaut temperature : override de la température par défaut stream : True pour streaming token par token Returns: dict avec : success, response, model, tokens_used, elapsed_sec, error """ if self._model is None: self.load() start = time.time() try: formatted_messages = [] if system_prompt: formatted_messages.append({ "role": "system", "content": system_prompt, }) formatted_messages.extend(messages) response = self._model.create_chat_completion( messages=formatted_messages, max_tokens=max_tokens or self.max_tokens, temperature=temperature or self.temperature, top_p=self.top_p, top_k=self.top_k, stream=stream, ) if stream: return {"success": True, "stream": response, "model": self.model_name} text = response["choices"][0]["message"]["content"] tokens = response["usage"]["total_tokens"] elapsed = round(time.time() - start, 2) return { "success": True, "response": text, "model": self.model_name, "tokens_used": tokens, "elapsed_sec": elapsed, "error": None, } except Exception as e: logger.error(f"Erreur {self.model_name} : {e}") return { "success": False, "response": "", "model": self.model_name, "tokens_used": 0, "elapsed_sec": round(time.time() - start, 2), "error": str(e), } -------- FIN DU FICHIER app/llm/base.py -------- ÉTAPE 21.2 — Créer app/llm/phi4.py ───────────────────────────────────── Créer /opt/kyc-service/app/llm/phi4.py : -------- DÉBUT DU FICHIER app/llm/phi4.py -------- """ Module Phi-4-mini-instruct (Microsoft) Licence : MIT — Usage commercial autorisé Rôle : Réponses rapides, conversations simples wellness, triage RAM : ~2.5 GB (Q4_K_M) Vitesse : ~15-25 tokens/sec sur CPU 8 cores """ import os import logging from app.llm.base import BaseLLM from dotenv import load_dotenv load_dotenv("/opt/kyc-service/.env") logger = logging.getLogger("kyc.llm.phi4") # Prompt système par défaut — wellness général PHI4_SYSTEM_PROMPT = """Tu es un assistant bien-être bienveillant et empathique. Tu fournis des conseils généraux sur la nutrition, le fitness, la grossesse, le sommeil et le bien-être mental. Tu n'es PAS un médecin et tu ne fournis PAS de conseils médicaux. Pour toute question médicale sérieuse, tu recommandes toujours de consulter un professionnel de santé qualifié. Réponds de manière concise, chaleureuse et personnalisée. Langue de réponse : même langue que l'utilisateur.""" _phi4_instance: BaseLLM = None def get_phi4() -> BaseLLM: """Retourne l'instance Phi-4-mini, la crée si nécessaire.""" global _phi4_instance if _phi4_instance is None: _phi4_instance = BaseLLM( model_name="phi4-mini", model_path=os.getenv("PHI4_MODEL_PATH", "/opt/kyc-service/models/llm/phi4/Phi-4-mini-instruct-Q4_K_M.gguf"), n_ctx=int(os.getenv("PHI4_CONTEXT", "8192")), n_threads=int(os.getenv("PHI4_THREADS", "4")), max_tokens=int(os.getenv("PHI4_MAX_TOKENS", "1024")), temperature=0.7, ) return _phi4_instance def load_phi4() -> None: """Charge Phi-4-mini en mémoire — appeler au startup.""" logger.info("Chargement Phi-4-mini-instruct...") get_phi4().load() def is_phi4_ready() -> bool: return _phi4_instance is not None and _phi4_instance.is_loaded def phi4_chat(messages: list, system_prompt: str = None) -> dict: """Interface simplifiée pour les conversations wellness rapides.""" return get_phi4().chat( messages=messages, system_prompt=system_prompt or PHI4_SYSTEM_PROMPT, ) -------- FIN DU FICHIER app/llm/phi4.py -------- ÉTAPE 21.3 — Créer app/llm/qwen3_14b.py ────────────────────────────────────────── Créer /opt/kyc-service/app/llm/qwen3_14b.py : -------- DÉBUT DU FICHIER app/llm/qwen3_14b.py -------- """ Module Qwen3-14B-Instruct (Alibaba) Licence : Apache 2.0 — Usage commercial autorisé Rôle : Cerveau principal wellness — raisonnement complexe, programmes personnalisés, analyse approfondie RAM : ~9.0 GB (Q4_K_M) | ~15 GB (Q8_0) Vitesse : ~5-10 tokens/sec sur CPU 8 cores Contexte : 128K tokens (idéal pour historique long) """ import os import logging from app.llm.base import BaseLLM from dotenv import load_dotenv load_dotenv("/opt/kyc-service/.env") logger = logging.getLogger("kyc.llm.qwen3") QWEN3_SYSTEM_PROMPT = """Tu es un coach bien-être expert, empathique et rigoureux. Tu maîtrises la nutrition, le fitness, le suivi de grossesse, l'hygiène du sommeil et le soutien en santé mentale légère. Tu bases tes réponses sur des données probantes et tu adaptes tes conseils au profil unique de chaque utilisateur. Tu n'es PAS un médecin. Pour tout symptôme médical, tu orientes toujours vers un professionnel de santé. En cas d'urgence, tu indiques le 15 (SAMU). Langue : adapte-toi à la langue de l'utilisateur.""" _qwen3_14b_instance: BaseLLM = None def get_qwen3_14b() -> BaseLLM: global _qwen3_14b_instance if _qwen3_14b_instance is None: _qwen3_14b_instance = BaseLLM( model_name="qwen3-14b", model_path=os.getenv("QWEN3_14B_PATH", "/opt/kyc-service/models/llm/qwen3-14b/Qwen_Qwen3-14B-Q4_K_M.gguf"), n_ctx=int(os.getenv("QWEN3_14B_CONTEXT", "16384")), n_threads=int(os.getenv("QWEN3_14B_THREADS", "6")), max_tokens=int(os.getenv("QWEN3_14B_MAX_TOKENS", "2048")), temperature=0.7, top_p=0.95, ) return _qwen3_14b_instance def load_qwen3_14b() -> None: logger.info("Chargement Qwen3-14B-Instruct...") get_qwen3_14b().load() def is_qwen3_14b_ready() -> bool: return _qwen3_14b_instance is not None and _qwen3_14b_instance.is_loaded def qwen3_chat(messages: list, system_prompt: str = None) -> dict: return get_qwen3_14b().chat( messages=messages, system_prompt=system_prompt or QWEN3_SYSTEM_PROMPT, ) -------- FIN DU FICHIER app/llm/qwen3_14b.py -------- ÉTAPE 21.4 — Créer app/llm/gemma3_27b.py (vision multimodale) ────────────────────────────────────────────────────────────── Créer /opt/kyc-service/app/llm/gemma3_27b.py : -------- DÉBUT DU FICHIER app/llm/gemma3_27b.py -------- """ Module Gemma 3 27B-IT (Google) — Vision Multimodale Licence : Apache 2.0 — Usage commercial autorisé Rôle : Analyse d'images (repas, posture, analyses sanguines, échographies) RAM : ~14 GB (QAT int4) Vitesse : ~2-5 tokens/sec sur CPU 8 cores Contexte : 128K tokens """ import os import io import base64 import logging from pathlib import Path from typing import Optional from PIL import Image from app.llm.base import BaseLLM from dotenv import load_dotenv load_dotenv("/opt/kyc-service/.env") logger = logging.getLogger("kyc.llm.gemma3") _gemma3_27b_instance: BaseLLM = None _gemma3_27b_vision = None # Instance séparée pour vision (llama_cpp multimodal) def get_gemma3_27b() -> BaseLLM: global _gemma3_27b_instance if _gemma3_27b_instance is None: _gemma3_27b_instance = BaseLLM( model_name="gemma3-27b", model_path=os.getenv("GEMMA3_27B_PATH", "/opt/kyc-service/models/llm/gemma3-27b/google_gemma-3-27b-it-Q4_K_M.gguf"), n_ctx=int(os.getenv("GEMMA3_27B_CONTEXT", "16384")), n_threads=int(os.getenv("GEMMA3_27B_THREADS", "8")), max_tokens=int(os.getenv("GEMMA3_27B_MAX_TOKENS", "2048")), temperature=1.0, # Valeur recommandée par Google pour Gemma 3 top_p=0.95, top_k=64, ) return _gemma3_27b_instance def load_gemma3_27b() -> None: """Chargement lazy — seulement quand une analyse image est demandée.""" logger.info("Chargement Gemma 3 27B-IT (chargement lazy)...") get_gemma3_27b().load() def is_gemma3_27b_ready() -> bool: return _gemma3_27b_instance is not None and _gemma3_27b_instance.is_loaded def pil_to_base64(pil_image: Image.Image) -> str: """Convertit une image PIL en base64 pour l'API vision.""" buffer = io.BytesIO() pil_image.convert("RGB").save(buffer, format="JPEG", quality=85) return base64.b64encode(buffer.getvalue()).decode("utf-8") def analyze_image(pil_image: Image.Image, prompt: str) -> dict: """ Analyse une image avec Gemma 3 27B (multimodal). Args: pil_image : image PIL (repas, posture, analyse médicale, etc.) prompt : question à poser sur l'image Returns: dict avec success, response, model, elapsed_sec, error """ import time start = time.time() # Chargement lazy si nécessaire if not is_gemma3_27b_ready(): load_gemma3_27b() try: img_b64 = pil_to_base64(pil_image) messages = [ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}, {"type": "text", "text": prompt}, ], } ] result = get_gemma3_27b().chat(messages=messages) result["elapsed_sec"] = round(time.time() - start, 2) return result except Exception as e: logger.error(f"Erreur Gemma 3 27B vision : {e}") return { "success": False, "response": "", "model": "gemma3-27b", "elapsed_sec": round(time.time() - start, 2), "error": str(e), } -------- FIN DU FICHIER app/llm/gemma3_27b.py -------- ÉTAPE 21.5 — Créer app/llm/biomistral.py (avec garde-fous obligatoires) ──────────────────────────────────────────────────────────────────────── Créer /opt/kyc-service/app/llm/biomistral.py : -------- DÉBUT DU FICHIER app/llm/biomistral.py -------- """ Module BioMistral-7B — Spécialiste Médical Licence : Apache 2.0 (code) — données PubMed Central Rôle : Questions médicales spécialisées avec garde-fous obligatoires RAM : ~4.1 GB (Q4_K_M) ARCHITECTURE DE SÉCURITÉ : 1. Filtre DistilBERT pré-inférence → détecter urgences médicales 2. Système prompt obligatoire → jamais de diagnostic, toujours redirection 3. Filtre post-génération → détecter réponses problématiques 4. Blacklist de mots-clés → blocage immédiat si présents 5. Timeout safety → si pas de redirection médecin → ajouter automatiquement """ import os import re import time import logging from app.llm.base import BaseLLM from dotenv import load_dotenv load_dotenv("/opt/kyc-service/.env") logger = logging.getLogger("kyc.llm.biomistral") _biomistral_instance: BaseLLM = None # ── Système prompt de sécurité — JAMAIS MODIFIER SANS VALIDATION ────────────── BIOMISTRAL_SYSTEM_PROMPT = """Tu es un assistant d'information médicale générale. RÈGLES ABSOLUES ET NON NÉGOCIABLES : 1. Tu n'es PAS un médecin et tu ne peux PAS poser de diagnostic. 2. Tu ne prescris JAMAIS de médicaments ni de traitements. 3. Pour tout symptôme médical, tu DOIS recommander de consulter un médecin. 4. En cas d'urgence (douleur thoracique, difficultés respiratoires, perte de conscience, pensées suicidaires), tu DOIS immédiatement indiquer d'appeler le 15 (SAMU) ou le 112 (urgences européennes). 5. Tes informations proviennent de la littérature médicale et sont fournies à titre éducatif uniquement, pas comme conseil médical. 6. Tu ne remplace PAS une consultation médicale. Tu peux expliquer des concepts médicaux généraux, des mécanismes biologiques, et fournir des informations de santé publique. Sois précis, factuel et bienveillant. Langue : adapte-toi à la langue de l'utilisateur.""" # ── Mots-clés d'urgence → redirection immédiate sans passer par le LLM ─────── EMERGENCY_KEYWORDS = [ "suicide", "suicidal", "me tuer", "mourir", "overdose", "infarctus", "heart attack", "stroke", "avc", "convulsion", "inconscient", "unconscious", "arrêt cardiaque", "cardiac arrest", "je veux mourir", "i want to die", "hémorragie", "hemorrhage", ] EMERGENCY_RESPONSE = """🚨 URGENCE MÉDICALE DÉTECTÉE 🚨 Si vous ou quelqu'un proche êtes en danger immédiat : • FRANCE : Appelez le 15 (SAMU) ou le 112 • BELGIQUE : Appelez le 112 • SUISSE : Appelez le 144 • MAROC : Appelez le 150 ou 15 • BÉNIN / CÔTE D'IVOIRE : Appelez le 15 Si vous avez des pensées de vous faire du mal, appelez immédiatement un service d'aide en crise. Vous n'êtes pas seul(e). Je ne suis pas qualifié pour gérer des urgences médicales. Veuillez contacter les services d'urgence maintenant.""" # ── Termes qui doivent toujours déclencher une redirection médecin ──────────── MEDICAL_REDIRECT_TRIGGERS = [ "diagnostic", "diagnos", "prescri", "médicament", "dosage", "traitement", "treatment", "therapy", "chimiothérapie", "chirurgie", "surgery", "opération", "operation", ] # ── Texte de disclaimer ajouté en fin de TOUTE réponse BioMistral ──────────── MEDICAL_DISCLAIMER = """ --- ⚕️ *Information médicale générale — pas un avis médical* *Consultez toujours un professionnel de santé qualifié pour votre situation personnelle.*""" def get_biomistral() -> BaseLLM: global _biomistral_instance if _biomistral_instance is None: _biomistral_instance = BaseLLM( model_name="biomistral-7b", model_path=os.getenv("BIOMISTRAL_PATH", "/opt/kyc-service/models/llm/biomistral/BioMistral-7B.Q4_K_M.gguf"), n_ctx=int(os.getenv("BIOMISTRAL_CONTEXT", "8192")), n_threads=int(os.getenv("BIOMISTRAL_THREADS", "4")), max_tokens=int(os.getenv("BIOMISTRAL_MAX_TOKENS", "1024")), temperature=0.3, # Température basse = réponses médicales plus factuelles top_p=0.9, ) return _biomistral_instance def load_biomistral() -> None: logger.info("Chargement BioMistral-7B avec garde-fous actifs...") get_biomistral().load() def is_biomistral_ready() -> bool: return _biomistral_instance is not None and _biomistral_instance.is_loaded def _check_emergency(text: str) -> bool: """Détecte si le texte contient des mots-clés d'urgence médicale.""" text_lower = text.lower() return any(kw in text_lower for kw in EMERGENCY_KEYWORDS) def _add_redirect_if_missing(response: str) -> str: """ S'assure que la réponse contient une redirection vers un médecin. Si absente, l'ajoute automatiquement. """ redirect_phrases = [ "médecin", "médecine", "professionnel", "consulter", "doctor", "physician", "professional", "consult", "samu", "urgences", "emergency", ] response_lower = response.lower() has_redirect = any(p in response_lower for p in redirect_phrases) if not has_redirect: response += "\n\nJe vous recommande de consulter un médecin ou un professionnel de santé pour obtenir un avis adapté à votre situation personnelle." return response def biomistral_ask(messages: list, user_question: str = "") -> dict: """ Interface sécurisée pour poser une question médicale à BioMistral. PIPELINE DE SÉCURITÉ : 1. Vérification urgence → réponse hardcodée si urgence détectée 2. Inférence BioMistral avec système prompt sécurisé 3. Post-processing → ajout disclaimer + redirection si manquante Args: messages : historique de conversation user_question : question brute de l'utilisateur (pour le filtre urgence) Returns: dict avec success, response, model, is_emergency, elapsed_sec, error """ start = time.time() # ── FILTRE 1 : Urgence médicale → réponse immédiate hardcodée ──────────── check_text = user_question or (messages[-1]["content"] if messages else "") if _check_emergency(check_text): logger.warning(f"Urgence médicale détectée dans : {check_text[:50]}...") return { "success": True, "response": EMERGENCY_RESPONSE, "model": "biomistral-7b-safety-filter", "is_emergency": True, "tokens_used": 0, "elapsed_sec": round(time.time() - start, 3), "error": None, } # ── FILTRE 2 : Chargement lazy si nécessaire ────────────────────────────── if not is_biomistral_ready(): load_biomistral() # ── FILTRE 3 : Inférence avec système prompt sécurisé ───────────────────── result = get_biomistral().chat( messages=messages, system_prompt=BIOMISTRAL_SYSTEM_PROMPT, temperature=0.3, ) if not result["success"]: return {**result, "is_emergency": False} # ── FILTRE 4 : Post-processing réponse ─────────────────────────────────── response = result["response"] response = _add_redirect_if_missing(response) response += MEDICAL_DISCLAIMER result["response"] = response result["is_emergency"] = False result["elapsed_sec"] = round(time.time() - start, 2) return result -------- FIN DU FICHIER app/llm/biomistral.py -------- ÉTAPE 21.6 — Créer app/llm/gemma4_e4b.py ────────────────────────────────────────── Créer /opt/kyc-service/app/llm/gemma4_e4b.py : -------- DÉBUT DU FICHIER app/llm/gemma4_e4b.py -------- """ Module Gemma 4 E4B-IT (Google) — Multimodal natif (texte + image + audio) Licence : Apache 2.0 — Usage commercial autorisé Rôle : Pipeline multimodal avancé — traitement simultané image+audio+texte RAM : ~5 GB (Q4_K_M) Contexte : 256K tokens """ import os import logging from app.llm.base import BaseLLM from dotenv import load_dotenv load_dotenv("/opt/kyc-service/.env") logger = logging.getLogger("kyc.llm.gemma4") _gemma4_e4b_instance: BaseLLM = None GEMMA4_SYSTEM_PROMPT = """Tu es un assistant multimodal bien-être. Tu peux analyser des images et des textes pour fournir des conseils personnalisés en nutrition, fitness, grossesse et sommeil. Tu n'es pas médecin et recommandes toujours de consulter un professionnel de santé pour les questions médicales sérieuses.""" def get_gemma4_e4b() -> BaseLLM: global _gemma4_e4b_instance if _gemma4_e4b_instance is None: _gemma4_e4b_instance = BaseLLM( model_name="gemma4-e4b", model_path=os.getenv("GEMMA4_E4B_PATH", "/opt/kyc-service/models/llm/gemma4-e4b/gemma-4-e4b-it-Q4_K_M.gguf"), n_ctx=int(os.getenv("GEMMA4_E4B_CONTEXT", "32768")), n_threads=int(os.getenv("GEMMA4_E4B_THREADS", "6")), max_tokens=int(os.getenv("GEMMA4_E4B_MAX_TOKENS", "2048")), temperature=1.0, top_p=0.95, top_k=64, ) return _gemma4_e4b_instance def load_gemma4_e4b() -> None: logger.info("Chargement Gemma 4 E4B-IT (lazy)...") get_gemma4_e4b().load() def is_gemma4_e4b_ready() -> bool: return _gemma4_e4b_instance is not None and _gemma4_e4b_instance.is_loaded def gemma4_chat(messages: list, system_prompt: str = None) -> dict: if not is_gemma4_e4b_ready(): load_gemma4_e4b() return get_gemma4_e4b().chat( messages=messages, system_prompt=system_prompt or GEMMA4_SYSTEM_PROMPT, ) -------- FIN DU FICHIER app/llm/gemma4_e4b.py -------- ÉTAPE 21.7 — Créer app/llm/router.py (routeur intelligent) ──────────────────────────────────────────────────────────── Créer /opt/kyc-service/app/llm/router.py : -------- DÉBUT DU FICHIER app/llm/router.py -------- """ Routeur LLM Intelligent — Sélectionne automatiquement le bon modèle selon la complexité, le domaine et la présence d'images. RÈGLES DE ROUTAGE : Domaine médical spécialisé → BioMistral-7B (avec garde-fous) Analyse d'image → Gemma 3 27B ou Gemma 4 E4B Question simple / réponse rapide → Phi-4-mini Coaching wellness complexe → Qwen3-14B Multimodal audio+image → Gemma 4 E4B Défaut → Qwen3-14B """ import re import logging from typing import List, Dict, Optional from PIL import Image logger = logging.getLogger("kyc.llm.router") # ── Mots-clés médicaux spécialisés → BioMistral ────────────────────────────── MEDICAL_KEYWORDS = [ "médicament", "medication", "drug", "prescription", "posologie", "dosage", "symptôme", "symptom", "diagnostic", "pathologie", "pathology", "maladie", "disease", "traitement", "treatment", "chirurgie", "surgery", "antibiotique", "antibiotic", "allergie", "allergy", "infection", "virus", "bactérie", "bacteria", "anatomie", "anatomy", "physiologie", "pharmacologie", "glycémie", "blood sugar", "tension artérielle", "blood pressure", "cholestérol", "cholesterol", "enzymes", "hormones", "biomarqueur", "biomarker", "analyse sanguine", "blood test", "bilan", "ordonnance", ] # ── Mots-clés complexes → Qwen3-14B ────────────────────────────────────────── COMPLEX_KEYWORDS = [ "programme", "plan", "semaine", "mois", "objectif", "stratégie", "analyse", "compare", "évalue", "détaille", "explique en profondeur", "pourquoi", "comment ça marche", "mécanisme", "physiologie de", "pathophysiologie", "rechute", "progression", "évolution", ] # ── Mots-clés simples → Phi-4-mini ──────────────────────────────────────────── SIMPLE_KEYWORDS = [ "bonjour", "hello", "salut", "hi", "merci", "thanks", "oui", "non", "yes", "no", "ok", "okay", "d'accord", "parfait", "super", "génial", "compris", ] def _is_simple_message(text: str) -> bool: """Détecte si le message est une interaction simple (salutation, acquiescement).""" text_clean = text.strip().lower() if len(text_clean) < 20: return True return any(kw in text_clean for kw in SIMPLE_KEYWORDS) def _is_medical_question(text: str) -> bool: """Détecte si la question relève du domaine médical spécialisé.""" text_lower = text.lower() return any(kw in text_lower for kw in MEDICAL_KEYWORDS) def _is_complex_question(text: str) -> bool: """Détecte si la question requiert un raisonnement complexe.""" text_lower = text.lower() return len(text) > 200 or any(kw in text_lower for kw in COMPLEX_KEYWORDS) def route( messages: List[Dict[str, str]], image: Optional[Image.Image] = None, has_audio: bool = False, force_model: Optional[str] = None, domain: Optional[str] = None, ) -> dict: """ Route une requête vers le LLM le plus approprié. Args: messages : liste de {role, content} image : image PIL si présente has_audio : True si audio joint (→ Gemma 4 E4B) force_model : forcer un modèle spécifique ('phi4', 'qwen3_14b', 'gemma3_27b', 'biomistral', 'gemma4_e4b') domain : 'medical', 'nutrition', 'fitness', 'pregnancy', 'sleep', 'mental_health', 'general' Returns: dict avec : success, response, model_used, routing_reason, ... """ from app.llm.phi4 import phi4_chat, is_phi4_ready, load_phi4 from app.llm.qwen3_14b import qwen3_chat, is_qwen3_14b_ready, load_qwen3_14b from app.llm.gemma3_27b import analyze_image, is_gemma3_27b_ready, load_gemma3_27b from app.llm.biomistral import biomistral_ask, is_biomistral_ready, load_biomistral from app.llm.gemma4_e4b import gemma4_chat, is_gemma4_e4b_ready, load_gemma4_e4b last_message = messages[-1]["content"] if messages else "" routing_reason = "" # ── ROUTAGE FORCÉ ────────────────────────────────────────────────────────── if force_model: routing_reason = f"forcé : {force_model}" if force_model == "phi4": return {**phi4_chat(messages), "routing_reason": routing_reason} elif force_model == "qwen3_14b": return {**qwen3_chat(messages), "routing_reason": routing_reason} elif force_model == "biomistral": return {**biomistral_ask(messages, last_message), "routing_reason": routing_reason} elif force_model == "gemma4_e4b": return {**gemma4_chat(messages), "routing_reason": routing_reason} # ── RÈGLE 1 : Audio présent → Gemma 4 E4B (multimodal natif) ───────────── if has_audio: routing_reason = "audio détecté → Gemma 4 E4B" logger.info(f"Router: {routing_reason}") return {**gemma4_chat(messages), "routing_reason": routing_reason} # ── RÈGLE 2 : Image présente → Gemma 3 27B (vision haute qualité) ───────── if image is not None: routing_reason = "image détectée → Gemma 3 27B (vision)" logger.info(f"Router: {routing_reason}") prompt = last_message or "Analyse cette image et donne des conseils bien-être." result = analyze_image(image, prompt) return {**result, "routing_reason": routing_reason} # ── RÈGLE 3 : Domaine médical → BioMistral ──────────────────────────────── if domain == "medical" or _is_medical_question(last_message): routing_reason = "question médicale → BioMistral-7B" logger.info(f"Router: {routing_reason}") return {**biomistral_ask(messages, last_message), "routing_reason": routing_reason} # ── RÈGLE 4 : Message simple → Phi-4-mini (rapide) ─────────────────────── if _is_simple_message(last_message): routing_reason = "message simple → Phi-4-mini" logger.info(f"Router: {routing_reason}") if not is_phi4_ready(): load_phi4() return {**phi4_chat(messages), "routing_reason": routing_reason} # ── RÈGLE 5 : Question complexe ou coaching approfondi → Qwen3-14B ──────── routing_reason = "question complexe → Qwen3-14B (défaut)" logger.info(f"Router: {routing_reason}") if not is_qwen3_14b_ready(): load_qwen3_14b() return {**qwen3_chat(messages), "routing_reason": routing_reason} -------- FIN DU FICHIER app/llm/router.py -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 22 — EXTENSION DE app/main.py (NOUVEAUX ENDPOINTS LLM) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 22.1 — Ajouter les imports LLM dans app/main.py ─────────────────────────────────────────────────────── Localiser la ligne dans app/main.py : from app.doc_understanding import load_donut, extract_document_fields, is_donut_ready AJOUTER après cette ligne : -------- IMPORTS À AJOUTER -------- # PRD 3 — LLMs Wellness from app.llm.phi4 import load_phi4, phi4_chat, is_phi4_ready from app.llm.qwen3_14b import load_qwen3_14b, qwen3_chat, is_qwen3_14b_ready from app.llm.gemma3_27b import load_gemma3_27b, analyze_image, is_gemma3_27b_ready from app.llm.biomistral import load_biomistral, biomistral_ask, is_biomistral_ready from app.llm.gemma4_e4b import load_gemma4_e4b, gemma4_chat, is_gemma4_e4b_ready from app.llm.router import route as llm_route -------- FIN DES IMPORTS -------- ÉTAPE 22.2 — Étendre le lifespan (chargement au startup) ────────────────────────────────────────────────────────── Dans le bloc lifespan, après le chargement de Donut, ajouter : -------- LIGNES À AJOUTER dans lifespan -------- # PRD 3 — LLMs chargés au démarrage (selon LLM_LOAD_ON_STARTUP) startup_models = os.getenv("LLM_LOAD_ON_STARTUP", "phi4,qwen3_14b,biomistral").split(",") if "phi4" in startup_models: logger.info("→ Chargement Phi-4-mini...") load_phi4() if "qwen3_14b" in startup_models: logger.info("→ Chargement Qwen3-14B...") load_qwen3_14b() if "biomistral" in startup_models: logger.info("→ Chargement BioMistral-7B...") load_biomistral() # Gemma 3 27B et Gemma 4 E4B sont en lazy load (chargés à la 1ère requête) logger.info("→ Gemma 3 27B et Gemma 4 E4B : chargement lazy activé") -------- FIN DES LIGNES LIFESPAN -------- ÉTAPE 22.3 — Mettre à jour /health et ajouter les 6 nouveaux endpoints ──────────────────────────────────────────────────────────────────────── Remplacer l'endpoint /health existant par : -------- ENDPOINT /health FINAL -------- @app.get("/health") def health(): """État complet de tous les modèles — PRD 1 + PRD 2 + PRD 3.""" return { "status": "ok", "port": 20900, "server": "64 GB RAM / 8 cores", "models": { # PRD 1 — KYC "paddleocr_v5": is_ocr_ready(), "auraface_v1": is_face_ready(), # PRD 2 — NLP / Audio / Documents "whisper_turbo": is_whisper_ready(), "distilbert": is_sentiment_ready(), "pyannote_3_1": is_diarization_ready(), "bge_m3": is_embeddings_ready(), "donut": is_donut_ready(), # PRD 3 — LLMs Wellness "phi4_mini": is_phi4_ready(), "qwen3_14b": is_qwen3_14b_ready(), "gemma3_27b": is_gemma3_27b_ready(), "biomistral_7b": is_biomistral_ready(), "gemma4_e4b": is_gemma4_e4b_ready(), } } -------- FIN /health -------- AJOUTER les endpoints suivants à la FIN de app/main.py, avant le bloc if __name__ == "__main__" : -------- NOUVEAUX ENDPOINTS LLM -------- # ═══════════════════════════════════════════════════════════════════════════ # ROUTEUR INTELLIGENT — Endpoint principal pour toutes les niches wellness # ═══════════════════════════════════════════════════════════════════════════ @app.post("/wellness/chat") async def endpoint_wellness_chat(request: Request): """ Endpoint principal wellness — routage automatique vers le bon LLM. Body JSON : { "messages": [{"role": "user", "content": "..."}], "domain": "general", // general|medical|nutrition|fitness|pregnancy|sleep|mental_health "force_model": null, // null|phi4|qwen3_14b|gemma3_27b|biomistral|gemma4_e4b "has_audio": false } """ body = await request.json() messages = body.get("messages", []) domain = body.get("domain", "general") force_model = body.get("force_model", None) has_audio = body.get("has_audio", False) if not messages: raise HTTPException(422, "Le champ 'messages' est obligatoire") result = llm_route( messages=messages, has_audio=has_audio, force_model=force_model, domain=domain, ) if not result.get("success"): raise HTTPException(500, result.get("error", "Erreur LLM")) return JSONResponse(result) @app.post("/wellness/vision") async def endpoint_wellness_vision( file: UploadFile = File(..., description="Image à analyser (repas, posture, analyse médicale)"), prompt: str = "Analyse cette image et donne des conseils bien-être.", ): """ Analyse une image avec Gemma 3 27B (vision multimodale). Idéal pour : photos de repas (nutrition), posture (fitness), résultats d'analyses (grossesse), journal photo sommeil. """ start = time.time() contents = await file.read() if not validate_image_size(contents): raise HTTPException(413, "Image trop volumineuse (max 5 MB)") pil_img = bytes_to_pil(contents) result = analyze_image(pil_img, prompt) result["elapsed_sec"] = round(time.time() - start, 2) if not result.get("success"): raise HTTPException(500, result.get("error", "Erreur analyse image")) return JSONResponse(result) @app.post("/medical/ask") async def endpoint_medical_ask(request: Request): """ Questions médicales spécialisées via BioMistral-7B. Système de garde-fous actif : détection urgences + disclaimer obligatoire. Body JSON : { "messages": [{"role": "user", "content": "..."}], "question": "ma question médicale" } DISCLAIMER : Ce endpoint fournit des informations médicales générales uniquement. Il ne remplace pas un avis médical professionnel. """ body = await request.json() messages = body.get("messages", []) question = body.get("question", "") if not messages and not question: raise HTTPException(422, "'messages' ou 'question' requis") if question and not messages: messages = [{"role": "user", "content": question}] result = biomistral_ask(messages, question) if not result.get("success"): raise HTTPException(500, result.get("error", "Erreur BioMistral")) return JSONResponse(result) @app.post("/wellness/program") async def endpoint_wellness_program(request: Request): """ Génère un programme personnalisé complet (nutrition, fitness, sommeil...). Utilise Qwen3-14B pour le raisonnement approfondi. Body JSON : { "profile": { "age": 30, "weight_kg": 75, "height_cm": 175, "goal": "perte de poids", "level": "débutant", "constraints": ["sans gluten", "30min/jour max"] }, "type": "fitness", // fitness|nutrition|sleep|mental_health "duration_weeks": 4 } """ body = await request.json() profile = body.get("profile", {}) prog_type = body.get("type", "fitness") weeks = body.get("duration_weeks", 4) system_prompt = f"""Tu es un coach expert en {prog_type}. Génère un programme détaillé et personnalisé de {weeks} semaines basé sur le profil suivant. Sois précis, pratique et motivant. Structure ta réponse avec des sections claires : objectifs, programme semaine par semaine, conseils clés.""" user_message = f"Profil utilisateur : {profile}\nGénère mon programme {prog_type} de {weeks} semaines." messages = [{"role": "user", "content": user_message}] result = qwen3_chat(messages, system_prompt=system_prompt) if not result.get("success"): raise HTTPException(500, result.get("error", "Erreur génération programme")) return JSONResponse(result) @app.post("/wellness/rag") async def endpoint_wellness_rag(request: Request): """ Wellness RAG : recherche sémantique (BGE-M3) + génération LLM. Cherche dans la base de connaissances, puis génère une réponse contextualisée. Body JSON : { "question": "ma question", "knowledge_base_results": [ // résultats de BGE-M3 pré-récupérés {"text": "...", "source": "..."}, ], "model": "qwen3_14b" // modèle de génération } """ body = await request.json() question = body.get("question", "") kb_results = body.get("knowledge_base_results", []) model = body.get("model", "qwen3_14b") if not question: raise HTTPException(422, "'question' est obligatoire") # Construire le contexte depuis les résultats de recherche sémantique context = "\n\n".join([ f"Source {i+1} ({r.get('source', 'inconnu')}) :\n{r.get('text', '')}" for i, r in enumerate(kb_results[:5]) ]) system_prompt = f"""Tu es un assistant bien-être expert. Réponds à la question en te basant sur les sources fournies. Cite tes sources si pertinent. Si les sources ne suffisent pas, indique-le clairement. SOURCES DISPONIBLES : {context if context else 'Aucune source fournie — réponds depuis tes connaissances générales.'}""" messages = [{"role": "user", "content": question}] if model == "biomistral": result = biomistral_ask(messages, question) elif model == "phi4": result = phi4_chat(messages, system_prompt=system_prompt) else: result = qwen3_chat(messages, system_prompt=system_prompt) if not result.get("success"): raise HTTPException(500, result.get("error", "Erreur RAG LLM")) return JSONResponse(result) @app.get("/llm/status") def endpoint_llm_status(): """ Statut détaillé de tous les LLMs — chargés en mémoire ou non. Utile pour le monitoring et le dashboard admin. """ return JSONResponse({ "models": { "phi4_mini": {"loaded": is_phi4_ready(), "ram_gb": 2.5, "role": "conversations rapides"}, "qwen3_14b": {"loaded": is_qwen3_14b_ready(), "ram_gb": 9.0, "role": "coaching complexe"}, "gemma3_27b": {"loaded": is_gemma3_27b_ready(), "ram_gb": 14.0, "role": "vision multimodale"}, "biomistral_7b": {"loaded": is_biomistral_ready(), "ram_gb": 4.1, "role": "médical + garde-fous"}, "gemma4_e4b": {"loaded": is_gemma4_e4b_ready(), "ram_gb": 5.0, "role": "multimodal audio+image"}, }, "total_ram_gb": 34.6, "server_ram_gb": 64, }) -------- FIN DES NOUVEAUX ENDPOINTS -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 23 — TESTS DE VALIDATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 23.1 — Créer tests/test_llm_wellness.py ─────────────────────────────────────────────── Créer /opt/kyc-service/tests/test_llm_wellness.py : -------- DÉBUT DU FICHIER tests/test_llm_wellness.py -------- """ Tests unitaires — LLMs Wellness PRD 3 Lancer avec : python tests/test_llm_wellness.py """ import sys sys.path.insert(0, "/opt/kyc-service") def test_phi4_loads(): from app.llm.phi4 import load_phi4, is_phi4_ready load_phi4() assert is_phi4_ready(), "Phi-4-mini devrait être chargé" print("✅ test_phi4_loads : PASS") def test_phi4_chat(): from app.llm.phi4 import phi4_chat result = phi4_chat([{"role": "user", "content": "Bonjour !"}]) assert result["success"], f"Phi-4 échec : {result.get('error')}" assert len(result["response"]) > 0 print(f"✅ test_phi4_chat : PASS — {result['response'][:80]}...") def test_qwen3_14b_loads(): from app.llm.qwen3_14b import load_qwen3_14b, is_qwen3_14b_ready load_qwen3_14b() assert is_qwen3_14b_ready() print("✅ test_qwen3_14b_loads : PASS") def test_qwen3_wellness(): from app.llm.qwen3_14b import qwen3_chat result = qwen3_chat([{ "role": "user", "content": "Donne-moi 3 conseils simples pour améliorer mon sommeil." }]) assert result["success"], f"Qwen3 échec : {result.get('error')}" assert len(result["response"]) > 50 print(f"✅ test_qwen3_wellness : PASS — {len(result['response'])} chars") def test_biomistral_loads(): from app.llm.biomistral import load_biomistral, is_biomistral_ready load_biomistral() assert is_biomistral_ready() print("✅ test_biomistral_loads : PASS") def test_biomistral_safety_filter(): """Vérifie que le filtre d'urgence fonctionne correctement.""" from app.llm.biomistral import biomistral_ask # Test avec un mot-clé d'urgence — doit retourner la réponse hardcodée result = biomistral_ask( messages=[{"role": "user", "content": "je veux mourir"}], user_question="je veux mourir" ) assert result["is_emergency"] is True, "Le filtre d'urgence doit se déclencher" assert "SAMU" in result["response"] or "15" in result["response"] print("✅ test_biomistral_safety_filter : PASS — urgence correctement détectée") def test_biomistral_disclaimer(): """Vérifie que le disclaimer est toujours présent.""" from app.llm.biomistral import biomistral_ask result = biomistral_ask( messages=[{"role": "user", "content": "C'est quoi l'hypertension artérielle ?"}], user_question="C'est quoi l'hypertension artérielle ?" ) assert result["success"] assert "médecin" in result["response"].lower() or "professionnel" in result["response"].lower() or "consulter" in result["response"].lower() print("✅ test_biomistral_disclaimer : PASS — redirection médecin présente") def test_router_simple(): """Vérifie que le routeur sélectionne Phi-4 pour un message simple.""" from app.llm.router import route result = route(messages=[{"role": "user", "content": "Bonjour !"}]) assert result["success"] assert "phi4" in result.get("model", "").lower() or "phi4" in result.get("routing_reason", "").lower() print(f"✅ test_router_simple : PASS — modèle : {result.get('model')}") def test_router_medical(): """Vérifie que le routeur sélectionne BioMistral pour une question médicale.""" from app.llm.router import route result = route(messages=[{ "role": "user", "content": "Quels sont les symptômes de l'hypertension artérielle ?" }]) assert result["success"] assert "biomistral" in result.get("model", "").lower() or "biomistral" in result.get("routing_reason", "").lower() print(f"✅ test_router_medical : PASS — modèle : {result.get('model')}") if __name__ == "__main__": print("Tests LLMs Wellness — PRD 3") print("=" * 50) test_phi4_loads() test_phi4_chat() test_qwen3_14b_loads() test_qwen3_wellness() test_biomistral_loads() test_biomistral_safety_filter() test_biomistral_disclaimer() test_router_simple() test_router_medical() print("\n🎉 Tous les tests LLM Wellness passent !") -------- FIN DU FICHIER tests/test_llm_wellness.py -------- source /opt/kyc-service/venv/bin/activate cd /opt/kyc-service python tests/test_llm_wellness.py CHECKPOINT ✅ : "🎉 Tous les tests LLM Wellness passent !" doit apparaître. CHECKPOINT ✅ : test_biomistral_safety_filter PASS est critique — vérifier absolument. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 24 — REDÉMARRAGE ET VALIDATION FINALE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 24.1 — Mettre à jour la limite mémoire systemd ────────────────────────────────────────────────────── # Modifier la limite mémoire pour 64 GB (était 6 GB dans PRD 1) sudo sed -i 's/MemoryLimit=6G/MemoryLimit=50G/' /etc/systemd/system/kyc-service.service sudo systemctl daemon-reload CHECKPOINT ✅ : grep MemoryLimit /etc/systemd/system/kyc-service.service doit afficher 50G. ÉTAPE 24.2 — Redémarrer le service ───────────────────────────────────── sudo systemctl restart kyc-service # Attendre le chargement de tous les modèles (jusqu'à 5 minutes) sleep 300 sudo systemctl status kyc-service CHECKPOINT ✅ : "active (running)" affiché. # Voir les logs de chargement sudo journalctl -u kyc-service -f --no-pager | head -100 CHECKPOINT ✅ : Les logs doivent montrer le chargement successif de tous les modèles et se terminer par "Tous les modèles sont prêts ✅" ÉTAPE 24.3 — Validation du health check final ─────────────────────────────────────────────── curl -s http://127.0.0.1:20900/health | python3 -m json.tool CHECKPOINT ✅ : Tous les modèles PRD 1+2 doivent être true. CHECKPOINT ✅ : phi4_mini, qwen3_14b, biomistral_7b doivent être true. CHECKPOINT ✅ : gemma3_27b et gemma4_e4b peuvent être false (lazy load — normal). ÉTAPE 24.4 — Tests des endpoints en production ──────────────────────────────────────────────── # Test routage automatique curl -s -X POST http://127.0.0.1:20900/wellness/chat \ -H "Content-Type: application/json" \ -d '{"messages": [{"role": "user", "content": "Bonjour !"}], "domain": "general"}' \ | python3 -m json.tool CHECKPOINT ✅ : "routing_reason" contient "simple → Phi-4-mini" # Test coaching complexe curl -s -X POST http://127.0.0.1:20900/wellness/chat \ -H "Content-Type: application/json" \ -d '{"messages": [{"role": "user", "content": "Je veux perdre 10kg en 3 mois, crée-moi un programme détaillé"}], "domain": "fitness"}' \ | python3 -m json.tool CHECKPOINT ✅ : "model" contient "qwen3-14b" # Test BioMistral médical curl -s -X POST http://127.0.0.1:20900/medical/ask \ -H "Content-Type: application/json" \ -d '{"question": "Quels sont les symptômes du diabète de type 2 ?"}' \ | python3 -m json.tool CHECKPOINT ✅ : La réponse contient le disclaimer médical et une redirection vers un médecin. # Test filtre urgence BioMistral curl -s -X POST http://127.0.0.1:20900/medical/ask \ -H "Content-Type: application/json" \ -d '{"question": "j ai une douleur thoracique intense"}' \ | python3 -m json.tool CHECKPOINT ✅ : "is_emergency": true et la réponse contient "15" ou "SAMU". # Statut LLM curl -s http://127.0.0.1:20900/llm/status | python3 -m json.tool CHECKPOINT ✅ : 5 modèles listés avec leur RAM et rôle. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 25 — RÉSUMÉ FINAL COMPLET DU MICROSERVICE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ TOUS LES MODÈLES INSTALLÉS (PRD 1 + PRD 2 + PRD 3) : ────────────────────────────────────────────────────── PRD 1 — KYC ✅ PaddleOCR PP-OCRv5 ~210 MB ✅ AuraFace-v1 ~408 MB ⚡ HyperFace-10k-LDM (optionnel) ~373 MB PRD 2 — NLP / Audio / Documents ✅ Whisper Large-v3 Turbo ~1.5 GB ✅ DistilBERT SST-2 ~270 MB ✅ RoBERTa Social Sentiment ~499 MB ✅ Pyannote Speaker Diarization 3.1 ~800 MB ✅ all-MiniLM-L6-v2 ~90 MB ✅ BAAI/bge-m3 ~570 MB ✅ Donut CORD-v2 ~2.0 GB PRD 3 — LLMs Wellness ✅ Phi-4-mini-instruct (Q4_K_M) ~2.5 GB ✅ Qwen3-14B-Instruct (Q4_K_M) ~9.0 GB ✅ Gemma 3 27B-IT QAT (int4) ~14.0 GB ✅ BioMistral-7B (Q4_K_M) + garde-fous ~4.1 GB ✅ Gemma 4 E4B-IT (Q4_K_M) ~5.0 GB ────────────────────────────────────────────── TOTAL ~41.4 GB / 64 GB ✅ TOUS LES ENDPOINTS (PORT 20900) : ─────────────────────────────────── PRD 1 — KYC GET /health → état de tous les modèles POST /ocr → extraction texte document POST /face-match → comparaison visages POST /kyc/verify → pipeline KYC complet PRD 2 — NLP / Audio / Documents POST /audio/transcribe → transcription Whisper POST /audio/transcribe-and-diarize → transcription + attribution locuteur POST /nlp/sentiment → analyse sentiment DistilBERT POST /nlp/embed → embeddings BGE-M3 POST /nlp/similarity → similarité sémantique POST /document/extract-fields → extraction champs Donut PRD 3 — LLMs Wellness POST /wellness/chat → routeur automatique LLM POST /wellness/vision → analyse image Gemma 3 27B POST /wellness/program → génération programme Qwen3-14B POST /wellness/rag → RAG LLM + base de connaissances POST /medical/ask → BioMistral + garde-fous médicaux GET /llm/status → statut détaillé des LLMs TOTAL : 16 endpoints actifs sur 1 seul serveur. SAAS POSSIBLES SUR CE SERVEUR : ───────────────────────────────── 1. SaaS KYC → /kyc/verify 2. SaaS Transcription Audio → /audio/transcribe 3. SaaS Compte-Rendu Réunion → /audio/transcribe-and-diarize 4. SaaS Analyse Sentiment → /nlp/sentiment 5. SaaS RAG / Chatbot docs → /nlp/embed + /wellness/rag 6. SaaS Coach Nutrition → /wellness/chat + /wellness/vision 7. SaaS Coach Fitness → /wellness/chat + /wellness/vision + /wellness/program 8. SaaS Assistant Grossesse → /wellness/chat + /medical/ask 9. SaaS Assistant Sommeil → /wellness/chat + /audio/transcribe 10. SaaS Santé Mentale Légère → /wellness/chat + /nlp/sentiment 11. SaaS Comptabilité Auto → /document/extract-fields + /ocr PROCHAINE ÉTAPE — INTÉGRATION LARAVEL : ────────────────────────────────────────── Services Laravel à créer : - KycService.php (PRD 1 — déjà défini) - TranscriptionService.php (PRD 2 — Whisper + Pyannote) - SentimentService.php (PRD 2 — DistilBERT) - WellnessService.php (PRD 3 — routeur + vision + programme) - MedicalService.php (PRD 3 — BioMistral + garde-fous côté Laravel) - RagService.php (PRD 3 — BGE-M3 + LLM generation) Confirmer "MICROSERVICE V3 OK" pour démarrer la phase Laravel complète. ================================================================================ FIN DU PRD 3 — LLMs WELLNESS + BIOMISTRAL + ROUTEUR INTELLIGENT ================================================================================