feat(anonymisation): blur PII côté serveur via EDS-NLP + VLM local-first

Blur PII server-side (core/anonymisation/pii_blur.py) :
- Pipeline OCR (docTR) → NER (EDS-NLP + fallback regex)
- Détection ciblée noms/prénoms/adresses/NIR/téléphone/email
- Protection explicite CIM-10, CCAM, montants €, dates, IDs techniques
- Dual-storage : shot_XXXX_full.png (brut) + _blurred.png (affichage)
- 18 tests

Client :
- RPA_BLUR_SENSITIVE=false par défaut (blur serveur uniquement)
- Zéro overhead côté poste utilisateur

VLM config :
- vlm_config.py : gemma4:latest, fallbacks qwen3-vl:8b + UI-TARS
- think=false auto pour gemma4 (bug Ollama 0.20.x)
- VLM provider VWB : local-first (Ollama), cloud opt-in via VLM_ALLOW_CLOUD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:48:23 +02:00
parent a9a99953dd
commit f7b8cddd2b
10 changed files with 1283 additions and 65 deletions

View File

@@ -23,9 +23,9 @@ class OllamaClient:
Permet d'envoyer des images et prompts à un VLM via l'API Ollama.
"""
def __init__(self,
def __init__(self,
endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
model: str = None,
timeout: int = 180):
"""
Initialiser le client Ollama
@@ -36,7 +36,12 @@ class OllamaClient:
timeout: Timeout en secondes
"""
self.endpoint = endpoint.rstrip('/')
self.model = model
# Résolution du modèle : paramètre explicite > config centralisée
if model is not None:
self.model = model
else:
from core.detection.vlm_config import get_vlm_model
self.model = get_vlm_model(endpoint=self.endpoint)
self.timeout = timeout
self._check_connection()
@@ -126,7 +131,12 @@ class OllamaClient:
messages.append(user_message)
# Déterminer si le modèle est un modèle thinking (qwen3)
is_thinking_model = "qwen3" in self.model.lower()
# Les modèles non-thinking (gemma4, qwen2.5vl) n'ont pas besoin
# du workaround prefill et supportent le rôle system natif.
from core.detection.vlm_config import is_thinking_model as _is_thinking
from core.detection.vlm_config import needs_think_false as _needs_think_false
is_thinking_model = _is_thinking(self.model)
requires_think_false = _needs_think_false(self.model)
# WORKAROUND Ollama 0.18.x : think=false est ignoré par le
# renderer qwen3-vl-thinking. On utilise un assistant prefill
@@ -168,9 +178,9 @@ class OllamaClient:
}
}
# Garder think=false au cas où une future version d'Ollama le
# corrige — le prefill reste le mécanisme principal
if is_thinking_model:
# think=false : requis pour qwen3 (prefill reste le mécanisme
# principal) ET pour gemma4 (sinon tokens vides sur Ollama >=0.20)
if is_thinking_model or requires_think_false:
payload["think"] = False
if force_json:
@@ -575,7 +585,7 @@ Your answer:"""
# Fonctions utilitaires
# ============================================================================
def create_ollama_client(model: str = "qwen3-vl:8b",
def create_ollama_client(model: str = None,
endpoint: str = "http://localhost:11434") -> OllamaClient:
"""
Créer un client Ollama

View File

@@ -72,9 +72,9 @@ class BoundingBox:
class DetectionConfig:
"""Configuration de la détection UI hybride"""
# VLM — modèle configurable via variable d'environnement RPA_VLM_MODEL
# Production (local) : "qwen3-vl:8b" — GPU local, pas de réseau
# Tests (cloud) : "qwen3-vl:235b-cloud" — pas de GPU, plus lent mais libère la VRAM
vlm_model: str = os.environ.get("RPA_VLM_MODEL", "qwen3-vl:8b")
# Par défaut : gemma4:e4b (meilleur grounding + contextualisation)
# Fallback : qwen3-vl:8b si gemma4 non disponible
vlm_model: str = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
vlm_endpoint: str = "http://localhost:11434"
use_vlm_classification: bool = True # Utiliser VLM pour classifier
@@ -865,21 +865,24 @@ JSON array: [{{"id":0,"type":"...","role":"...","text":"..."}}]"""
# ============================================================================
def create_detector(
vlm_model: str = "qwen3-vl:8b",
vlm_model: str = None,
confidence_threshold: float = 0.7,
use_vlm: bool = True
) -> UIDetector:
"""
Créer un détecteur avec configuration personnalisée
Args:
vlm_model: Modèle VLM à utiliser
vlm_model: Modèle VLM à utiliser (None = résolution automatique via vlm_config)
confidence_threshold: Seuil de confiance
use_vlm: Utiliser le VLM pour la classification
Returns:
UIDetector configuré
"""
if vlm_model is None:
from core.detection.vlm_config import get_vlm_model
vlm_model = get_vlm_model()
config = DetectionConfig(
vlm_model=vlm_model,
confidence_threshold=confidence_threshold,

View File

@@ -0,0 +1,194 @@
"""
Configuration centralisée du modèle VLM (Vision-Language Model).
Point unique de configuration pour le modèle VLM utilisé dans tout le pipeline.
Gère la variable d'environnement RPA_VLM_MODEL avec fallback automatique
si le modèle configuré n'est pas disponible dans Ollama.
Ordre de résolution du modèle :
1. Variable d'env RPA_VLM_MODEL (prioritaire)
2. Variable d'env VLM_MODEL (compatibilité)
3. Modèle par défaut : gemma4:latest
Fallback automatique :
Si le modèle choisi n'est pas trouvé dans Ollama, on essaie les
modèles de fallback dans l'ordre (FALLBACK_VLM_MODELS).
"""
import logging
import os
from typing import List, Optional
import requests
logger = logging.getLogger(__name__)
# Modèle VLM par défaut — Gemma 4 latest (8B dense, Q4_K_M)
# Nécessite think=false dans le payload (sinon tokens vides sur Ollama >=0.20)
DEFAULT_VLM_MODEL = "gemma4:latest"
# Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo
FALLBACK_VLM_MODELS = ["qwen3-vl:8b", "0000/ui-tars-1.5-7b-q8_0:7b"]
# Endpoint Ollama par défaut
DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
# Cache du modèle résolu (évite de requêter Ollama à chaque appel)
_resolved_model: Optional[str] = None
_resolved_model_checked = False
def get_vlm_model(
endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
force_check: bool = False,
) -> str:
"""Retourne le nom du modèle VLM à utiliser, avec fallback automatique.
Vérifie la disponibilité du modèle dans Ollama au premier appel,
puis cache le résultat pour les appels suivants.
Args:
endpoint: URL de l'API Ollama
force_check: Forcer une nouvelle vérification (ignorer le cache)
Returns:
Nom du modèle VLM disponible (ex: "gemma4:latest")
"""
global _resolved_model, _resolved_model_checked
if _resolved_model_checked and not force_check:
return _resolved_model
# Lire le modèle configuré depuis l'environnement
configured = (
os.environ.get("RPA_VLM_MODEL")
or os.environ.get("VLM_MODEL")
or DEFAULT_VLM_MODEL
)
# Vérifier la disponibilité dans Ollama
available = _list_ollama_models(endpoint)
if available is None:
# Ollama non joignable — utiliser le modèle configuré sans vérification
logger.warning(
"Ollama non joignable (%s) — utilisation de '%s' sans vérification",
endpoint, configured,
)
_resolved_model = configured
_resolved_model_checked = True
return _resolved_model
# Vérifier si le modèle configuré est disponible
if _model_available(configured, available):
logger.info("VLM model: %s (configuré, disponible)", configured)
_resolved_model = configured
_resolved_model_checked = True
return _resolved_model
# Fallback : essayer les modèles alternatifs
logger.warning(
"Modèle VLM '%s' non trouvé dans Ollama. Recherche d'un fallback...",
configured,
)
# Construire la liste de fallback complète
fallback_candidates = [DEFAULT_VLM_MODEL] + FALLBACK_VLM_MODELS
for candidate in fallback_candidates:
if candidate == configured:
continue # Déjà testé
if _model_available(candidate, available):
logger.info(
"VLM model: %s (fallback, '%s' non disponible)",
candidate, configured,
)
_resolved_model = candidate
_resolved_model_checked = True
return _resolved_model
# Aucun fallback trouvé — utiliser le modèle configuré quand même
# (Ollama le téléchargera peut-être au premier appel)
logger.warning(
"Aucun modèle VLM trouvé dans Ollama. "
"Modèles disponibles : %s. Utilisation de '%s' par défaut.",
[m for m in available if "vl" in m.lower() or "gemma" in m.lower()],
configured,
)
_resolved_model = configured
_resolved_model_checked = True
return _resolved_model
def reset_vlm_model_cache():
"""Réinitialiser le cache du modèle résolu.
Utile après un changement de configuration ou un pull de modèle.
"""
global _resolved_model, _resolved_model_checked
_resolved_model = None
_resolved_model_checked = False
def is_thinking_model(model_name: str) -> bool:
"""Détermine si un modèle est un modèle 'thinking' (qwen3).
Les modèles thinking nécessitent un assistant prefill pour éviter
le mode réflexion interne qui peut durer >180s avec des images.
Args:
model_name: Nom du modèle (ex: "qwen3-vl:8b", "gemma4:e4b")
Returns:
True si le modèle est de type thinking (nécessite prefill workaround)
"""
return "qwen3" in model_name.lower()
def needs_think_false(model_name: str) -> bool:
"""Détermine si un modèle nécessite think=false dans le payload.
Sur Ollama >=0.20, gemma4 produit des tokens vides si think n'est pas
explicitement désactivé. Ce flag doit être envoyé dans le payload chat.
Args:
model_name: Nom du modèle (ex: "gemma4:latest", "gemma4:e4b")
Returns:
True si le modèle nécessite think=false
"""
return "gemma4" in model_name.lower()
def _list_ollama_models(endpoint: str) -> Optional[List[str]]:
"""Lister les modèles disponibles dans Ollama.
Returns:
Liste des noms de modèles, ou None si Ollama n'est pas joignable.
"""
try:
resp = requests.get(f"{endpoint}/api/tags", timeout=5)
if resp.status_code == 200:
models = resp.json().get("models", [])
return [m["name"] for m in models]
except Exception:
pass
return None
def _model_available(model_name: str, available_models: List[str]) -> bool:
"""Vérifie si un modèle est disponible dans la liste Ollama.
Supporte la correspondance exacte et le match sans tag de version
(ex: "gemma4:e4b" match "gemma4:e4b" ou "gemma4:e4b-q4_0").
"""
# Match exact
if model_name in available_models:
return True
# Match par préfixe (sans tag) — "gemma4:e4b" match "gemma4:e4b"
base_name = model_name.split(":")[0] if ":" in model_name else model_name
for m in available_models:
if m.startswith(base_name + ":"):
return True
return False