refactor(pipeline): ScreenAnalyzer thread-safe et isolé (Lot C)

Retrait de l'état global toxique :
  - analyze() : kwargs-only enable_ocr, enable_ui_detection, session_id
  - Ne mute JAMAIS self pour les flags (variables locales + branches)
  - _resolve_ocr_instance() / _resolve_ui_detector_instance() : lecture seule
  - _init_lock par instance pour lazy init concurrent safe
  - session_id par appel, plus via mutation singleton

Avant : ExecutionLoop mutait analyzer._ocr, _ui_detector,
_ocr_initialized, _ui_detector_initialized pour désactiver OCR/UI.
Deux loops partageant le singleton se polluaient mutuellement.

Après : deux loops partageant l'analyzer sont complètement isolés.
Preuve par TestAnalyzerIsolationBetweenLoops (3 tests).

Singleton get_screen_analyzer() préservé — garde uniquement les
ressources lourdes, plus de contexte d'exécution.

9 nouveaux tests (3 isolation + 6 kwargs-only/lazy-init).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-15 09:06:41 +02:00
parent 8c7b6e5696
commit 9ca277a63f
4 changed files with 1221 additions and 42 deletions

View File

@@ -2,7 +2,140 @@
Pipeline module - Orchestration du flux RPA Vision V3
"""
from __future__ import annotations
import threading
from typing import Optional
from .workflow_pipeline import WorkflowPipeline, create_pipeline
from .screen_analyzer import ScreenAnalyzer
from .screen_state_cache import ScreenStateCache, compute_perceptual_hash
from .edge_scorer import EdgeScorer, EdgeScore
__all__ = ["WorkflowPipeline", "create_pipeline", "ScreenAnalyzer"]
__all__ = [
"WorkflowPipeline",
"create_pipeline",
"ScreenAnalyzer",
"ScreenStateCache",
"compute_perceptual_hash",
"EdgeScorer",
"EdgeScore",
"get_screen_analyzer",
"reset_screen_analyzer",
"get_screen_state_cache",
"reset_screen_state_cache",
]
# =============================================================================
# Singleton ScreenAnalyzer
# =============================================================================
#
# Une seule instance est partagée entre ExecutionLoop, GraphBuilder et
# stream_processor pour éviter le double chargement GPU (UIDetector + CLIP
# = 6-10 Go VRAM, plafond 12 Go sur RTX 5070).
#
# Thread-safe : protégé par un lock.
#
# IMPORTANT (Lot C — avril 2026) :
# Ce singleton ne porte plus AUCUN contexte d'exécution. Il détient
# uniquement les ressources lourdes (modèles OCR, UIDetector, CLIP).
# • Les flags runtime (`enable_ocr`, `enable_ui_detection`) et l'identité
# de session (`session_id`) se passent en kwargs-only à `analyze()`,
# jamais en mutant l'instance. Voir `ScreenAnalyzer.analyze()`.
# • L'argument `session_id` de `get_screen_analyzer()` ne sert QUE de
# valeur par défaut historique, ignorée après la première création.
# À terme, prévoir sa suppression.
# =============================================================================
_SCREEN_ANALYZER_SINGLETON: Optional[ScreenAnalyzer] = None
_SCREEN_ANALYZER_LOCK = threading.Lock()
def get_screen_analyzer(
ui_detector=None,
ocr_engine: Optional[str] = None,
session_id: str = "",
force_new: bool = False,
) -> ScreenAnalyzer:
"""
Récupérer l'instance partagée de ScreenAnalyzer.
Création à la première demande (lazy). Les appels ultérieurs retournent
la même instance, quels que soient les arguments (sauf `force_new=True`).
Args:
ui_detector: UIDetector optionnel (utilisé seulement à la 1ère création)
ocr_engine: Moteur OCR ("doctr", "tesseract", None=auto)
session_id: ID de session pour la 1ère création
force_new: Forcer la création d'une nouvelle instance (tests)
Returns:
Instance partagée de ScreenAnalyzer
"""
global _SCREEN_ANALYZER_SINGLETON
if force_new:
with _SCREEN_ANALYZER_LOCK:
_SCREEN_ANALYZER_SINGLETON = ScreenAnalyzer(
ui_detector=ui_detector,
ocr_engine=ocr_engine,
session_id=session_id,
)
return _SCREEN_ANALYZER_SINGLETON
if _SCREEN_ANALYZER_SINGLETON is not None:
return _SCREEN_ANALYZER_SINGLETON
with _SCREEN_ANALYZER_LOCK:
# Double-check locking
if _SCREEN_ANALYZER_SINGLETON is None:
_SCREEN_ANALYZER_SINGLETON = ScreenAnalyzer(
ui_detector=ui_detector,
ocr_engine=ocr_engine,
session_id=session_id,
)
return _SCREEN_ANALYZER_SINGLETON
def reset_screen_analyzer() -> None:
"""Réinitialiser le singleton (tests uniquement)."""
global _SCREEN_ANALYZER_SINGLETON
with _SCREEN_ANALYZER_LOCK:
_SCREEN_ANALYZER_SINGLETON = None
# =============================================================================
# Singleton ScreenStateCache (partagé)
# =============================================================================
_SCREEN_STATE_CACHE_SINGLETON: Optional[ScreenStateCache] = None
_SCREEN_STATE_CACHE_LOCK = threading.Lock()
def get_screen_state_cache(
ttl_seconds: float = 2.0,
max_entries: int = 16,
) -> ScreenStateCache:
"""
Retourne le cache de ScreenState partagé (créé à la 1ère demande).
"""
global _SCREEN_STATE_CACHE_SINGLETON
if _SCREEN_STATE_CACHE_SINGLETON is not None:
return _SCREEN_STATE_CACHE_SINGLETON
with _SCREEN_STATE_CACHE_LOCK:
if _SCREEN_STATE_CACHE_SINGLETON is None:
_SCREEN_STATE_CACHE_SINGLETON = ScreenStateCache(
ttl_seconds=ttl_seconds,
max_entries=max_entries,
)
return _SCREEN_STATE_CACHE_SINGLETON
def reset_screen_state_cache() -> None:
"""Réinitialiser le cache partagé (tests uniquement)."""
global _SCREEN_STATE_CACHE_SINGLETON
with _SCREEN_STATE_CACHE_LOCK:
_SCREEN_STATE_CACHE_SINGLETON = None

View File

@@ -9,13 +9,33 @@ Orchestre les 4 niveaux du ScreenState :
Ce module comble le chaînon manquant entre la capture brute (Couche 0)
et la construction d'embeddings (Couche 3).
=============================================================================
Thread-safety & partage multi-loops (Lot C — avril 2026)
=============================================================================
Cet analyseur peut être partagé entre plusieurs `ExecutionLoop` (singleton
`get_screen_analyzer()`). Pour éviter la contamination croisée :
• `analyze()` NE MUTE JAMAIS `self._ocr`, `self._ui_detector`,
`self._ocr_initialized`, `self._ui_detector_initialized` pour gérer les
flags runtime (enable_ocr / enable_ui_detection). Ces flags sont par
appel, résolus en variables locales.
• `session_id` circule en paramètre d'appel et renseigne la metadata du
ScreenState ; l'attribut `self.session_id` n'est qu'un défaut historique
(rétrocompat) et n'est plus la source de vérité.
• L'init lazy des composants lourds (OCR, UIDetector) est protégée par un
`_init_lock` par instance pour empêcher une double initialisation
concurrente.
"""
import contextlib
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Tuple
from PIL import Image
@@ -32,6 +52,44 @@ from core.models.ui_element import UIElement
logger = logging.getLogger(__name__)
# Lock d'inférence local au module : sert de fallback si le GPUResourceManager
# n'est pas disponible (import error, tests). Partagé entre toutes les instances
# ScreenAnalyzer du process, cohérent avec le singleton get_screen_analyzer().
_ANALYZE_FALLBACK_LOCK = threading.Lock()
def _acquire_gpu_context(timeout: Optional[float] = None):
"""
Retourne un context manager pour sérialiser les appels GPU.
Préfère `GPUResourceManager.acquire_inference()` si disponible (coordination
globale), sinon bascule sur un lock threading local au module.
"""
try:
from core.gpu import get_gpu_resource_manager
manager = get_gpu_resource_manager()
return manager.acquire_inference(timeout=timeout)
except Exception as e: # pragma: no cover - fallback defensif
logger.debug(f"GPUResourceManager indisponible, fallback lock local: {e}")
@contextlib.contextmanager
def _fallback():
if timeout is None:
_ANALYZE_FALLBACK_LOCK.acquire()
yield True
_ANALYZE_FALLBACK_LOCK.release()
else:
got = _ANALYZE_FALLBACK_LOCK.acquire(timeout=timeout)
try:
yield got
finally:
if got:
_ANALYZE_FALLBACK_LOCK.release()
return _fallback()
class ScreenAnalyzer:
"""
Construit un ScreenState complet (4 niveaux) depuis un screenshot.
@@ -44,6 +102,14 @@ class ScreenAnalyzer:
>>> state = analyzer.analyze("/path/to/screenshot.png")
>>> print(state.perception.detected_text)
>>> print(len(state.ui_elements))
Runtime overrides (kwargs-only) sur analyze() :
>>> state = analyzer.analyze(
... path,
... enable_ocr=False, # bypass OCR pour cet appel
... enable_ui_detection=False, # bypass UIDetector
... session_id="session_42", # session par appel
... )
"""
def __init__(
@@ -56,18 +122,27 @@ class ScreenAnalyzer:
Args:
ui_detector: Instance de UIDetector (créé si None)
ocr_engine: Moteur OCR à utiliser ("doctr", "tesseract", None=auto)
session_id: ID de la session en cours
session_id: ID de session par défaut (rétrocompat ; préférer passer
`session_id` en kwarg de `analyze()` pour chaque appel).
"""
self._ui_detector = ui_detector
self._ocr_engine_name = ocr_engine
self._ocr = None
# Session par défaut (rétrocompat). La source de vérité est désormais
# le paramètre `session_id` de `analyze()`.
self.session_id = session_id
# Compteur d'états — protégé par _state_lock pour être safe en parallèle.
self._state_counter = 0
self._state_lock = threading.Lock()
# Initialisation lazy pour éviter les imports lourds au démarrage
# Initialisation lazy pour éviter les imports lourds au démarrage.
self._ui_detector_initialized = ui_detector is not None
self._ocr_initialized = False
# Lock dédié à l'init lazy : empêche deux threads d'initialiser
# simultanément OCR ou UIDetector (double chargement GPU).
self._init_lock = threading.Lock()
# =========================================================================
# API publique
# =========================================================================
@@ -77,28 +152,85 @@ class ScreenAnalyzer:
screenshot_path: str,
window_info: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None,
*,
enable_ocr: bool = True,
enable_ui_detection: bool = True,
session_id: str = "",
) -> ScreenState:
"""
Analyser un screenshot et construire un ScreenState complet.
Les flags `enable_ocr`, `enable_ui_detection` et `session_id` sont
**par appel, kwargs-only**, pour ne pas polluer l'état partagé du
singleton quand plusieurs `ExecutionLoop` se partagent l'analyseur.
Args:
screenshot_path: Chemin vers le fichier image
window_info: Infos fenêtre active {"title": ..., "app_name": ...}
context: Contexte métier optionnel
enable_ocr: Active l'OCR pour cet appel (True par défaut).
False → `detected_text=[]`, aucune init d'OCR déclenchée.
enable_ui_detection: Active la détection UI pour cet appel
(True par défaut). False → `ui_elements=[]`.
session_id: ID de session pour cet appel. Si vide, on retombe sur
`self.session_id` (rétrocompat). Cette valeur est propagée
dans `ScreenState.session_id` et `metadata["session_id"]`.
Returns:
ScreenState avec les 4 niveaux remplis
ScreenState avec les 4 niveaux remplis.
"""
screenshot_path = str(screenshot_path)
self._state_counter += 1
state_id = f"{self.session_id}_state_{self._state_counter:04d}" if self.session_id else f"state_{self._state_counter:04d}"
# Résolution de la session : priorité au kwarg, fallback sur l'état
# interne (legacy). Variable locale uniquement — pas de mutation.
effective_session_id = session_id or self.session_id
# Niveau 1 : Raw
# Compteur incrémenté sous lock pour identifiants uniques même en
# parallèle. C'est la seule mutation tolérée : elle n'impacte pas le
# comportement OCR/UI.
with self._state_lock:
self._state_counter += 1
state_counter = self._state_counter
state_id = (
f"{effective_session_id}_state_{state_counter:04d}"
if effective_session_id
else f"state_{state_counter:04d}"
)
# Niveau 1 : Raw (léger, hors lock GPU)
raw = self._build_raw_level(screenshot_path)
# Niveau 2 : Perception (OCR)
detected_text = self._extract_text(screenshot_path)
# Résolution locale des instances OCR / UIDetector selon les flags.
# Aucune mutation de self ici : on décide simplement ce qu'on utilise.
ocr_instance = self._resolve_ocr_instance(enable_ocr=enable_ocr)
ui_detector_instance = self._resolve_ui_detector_instance(
enable_ui_detection=enable_ui_detection
)
# Niveaux 2 et 3 : OCR + détection UI sont les étapes lourdes en GPU.
# On sérialise via GPUResourceManager.acquire_inference() pour éviter
# que ExecutionLoop et stream_processor saturent simultanément la VRAM
# sur RTX 5070 (12 Go). Timeout généreux : un appel peut prendre 15-20s.
with _acquire_gpu_context(timeout=60.0) as acquired:
if not acquired:
logger.warning(
"Timeout en attendant le lock GPU pour ScreenAnalyzer.analyze() "
"→ exécution sans sérialisation (risque saturation VRAM)"
)
# Niveau 2 : Perception (OCR) — mesure du temps OCR
ocr_t0 = time.time()
detected_text = self._extract_text_with(ocr_instance, screenshot_path)
ocr_ms = (time.time() - ocr_t0) * 1000
# Niveau 3 : UI Elements — mesure du temps détection
ui_t0 = time.time()
ui_elements = self._detect_ui_elements_with(
ui_detector_instance, screenshot_path, window_info
)
ui_ms = (time.time() - ui_t0) * 1000
perception = PerceptionLevel(
embedding=EmbeddingRef(
provider="openclip_ViT-B-32",
@@ -106,13 +238,10 @@ class ScreenAnalyzer:
dimensions=512,
),
detected_text=detected_text,
text_detection_method=self._get_ocr_method_name(),
text_detection_method=self._get_ocr_method_name(ocr_instance),
confidence_avg=0.85 if detected_text else 0.0,
)
# Niveau 3 : UI Elements
ui_elements = self._detect_ui_elements(screenshot_path, window_info)
# Niveau 4 : Contexte
window_ctx = self._build_window_context(window_info)
context_level = self._build_context_level(context)
@@ -120,22 +249,28 @@ class ScreenAnalyzer:
state = ScreenState(
screen_state_id=state_id,
timestamp=datetime.now(),
session_id=self.session_id,
session_id=effective_session_id,
window=window_ctx,
raw=raw,
perception=perception,
context=context_level,
metadata={
"analyzer_version": "1.0",
"analyzer_version": "1.1",
"session_id": effective_session_id,
"ui_elements_count": len(ui_elements),
"text_regions_count": len(detected_text),
"ocr_ms": ocr_ms,
"ui_ms": ui_ms,
"ocr_enabled": enable_ocr,
"ui_detection_enabled": enable_ui_detection,
},
ui_elements=ui_elements,
)
logger.info(
f"ScreenState {state_id} construit: "
f"{len(ui_elements)} éléments UI, {len(detected_text)} textes détectés"
f"{len(ui_elements)} éléments UI, {len(detected_text)} textes détectés "
f"(ocr={enable_ocr}, ui={enable_ui_detection})"
)
return state
@@ -145,11 +280,16 @@ class ScreenAnalyzer:
save_dir: str = "data/screens",
window_info: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None,
*,
enable_ocr: bool = True,
enable_ui_detection: bool = True,
session_id: str = "",
) -> ScreenState:
"""
Analyser une PIL Image (utile quand on a déjà l'image en mémoire).
Sauvegarde l'image sur disque puis appelle analyze().
Sauvegarde l'image sur disque puis appelle analyze(). Les flags
runtime sont propagés à `analyze()` en kwargs-only.
"""
save_path = Path(save_dir)
save_path.mkdir(parents=True, exist_ok=True)
@@ -159,7 +299,49 @@ class ScreenAnalyzer:
filepath = save_path / filename
image.save(str(filepath))
return self.analyze(str(filepath), window_info=window_info, context=context)
return self.analyze(
str(filepath),
window_info=window_info,
context=context,
enable_ocr=enable_ocr,
enable_ui_detection=enable_ui_detection,
session_id=session_id,
)
# =========================================================================
# Résolution des instances OCR / UI selon les flags d'appel
# =========================================================================
def _resolve_ocr_instance(self, *, enable_ocr: bool):
"""
Retourner l'instance OCR à utiliser pour cet appel.
- `enable_ocr=False` → None (pas d'init, pas d'appel OCR)
- sinon → init lazy sous lock si nécessaire, puis retour de `self._ocr`
Ne mute `self._ocr` / `self._ocr_initialized` QUE pendant l'init lazy
réelle, jamais pour bypasser l'OCR d'un appel.
"""
if not enable_ocr:
return None
if not self._ocr_initialized:
with self._init_lock:
# Double-check : un autre thread a pu initialiser entretemps.
if not self._ocr_initialized:
self._ensure_ocr_locked()
return self._ocr
def _resolve_ui_detector_instance(self, *, enable_ui_detection: bool):
"""
Retourner l'instance UIDetector pour cet appel (idem _resolve_ocr_instance).
"""
if not enable_ui_detection:
return None
if not self._ui_detector_initialized:
with self._init_lock:
if not self._ui_detector_initialized:
self._ensure_ui_detector_locked()
return self._ui_detector
# =========================================================================
# Niveau 1 : Raw
@@ -182,23 +364,24 @@ class ScreenAnalyzer:
# Niveau 2 : Perception — OCR
# =========================================================================
def _extract_text(self, screenshot_path: str) -> List[str]:
"""Extraire le texte d'un screenshot via OCR."""
self._ensure_ocr()
if self._ocr is None:
def _extract_text_with(self, ocr_callable, screenshot_path: str) -> List[str]:
"""Extraire le texte via un callable OCR donné (peut être None)."""
if ocr_callable is None:
return []
try:
return self._ocr(screenshot_path)
return ocr_callable(screenshot_path)
except Exception as e:
logger.warning(f"OCR échoué: {e}")
return []
def _ensure_ocr(self) -> None:
"""Initialiser le moteur OCR (lazy)."""
if self._ocr_initialized:
return
def _ensure_ocr_locked(self) -> None:
"""
Initialiser le moteur OCR (appelé sous `self._init_lock`).
Ne doit PAS être appelé hors de `_resolve_ocr_instance()`.
"""
# Mutation intentionnelle : on installe l'instance OCR réelle.
# Protégée par le lock d'init (pas le lock GPU).
self._ocr_initialized = True
engine = self._ocr_engine_name
@@ -257,8 +440,9 @@ class ScreenAnalyzer:
return ocr_func
def _get_ocr_method_name(self) -> str:
if self._ocr is None:
def _get_ocr_method_name(self, ocr_instance=None) -> str:
"""Nom du moteur OCR effectivement utilisé pour cet appel."""
if ocr_instance is None:
return "none"
if self._ocr_engine_name:
return self._ocr_engine_name
@@ -268,19 +452,18 @@ class ScreenAnalyzer:
# Niveau 3 : UI Elements
# =========================================================================
def _detect_ui_elements(
def _detect_ui_elements_with(
self,
ui_detector,
screenshot_path: str,
window_info: Optional[Dict[str, Any]] = None,
) -> List[UIElement]:
"""Détecter les éléments UI dans le screenshot."""
self._ensure_ui_detector()
if self._ui_detector is None:
"""Détecter les éléments UI via un détecteur donné (peut être None)."""
if ui_detector is None:
return []
try:
elements = self._ui_detector.detect(
elements = ui_detector.detect(
screenshot_path, window_context=window_info
)
return elements
@@ -288,10 +471,10 @@ class ScreenAnalyzer:
logger.warning(f"Détection UI échouée: {e}")
return []
def _ensure_ui_detector(self) -> None:
"""Initialiser le UIDetector (lazy)."""
if self._ui_detector_initialized:
return
def _ensure_ui_detector_locked(self) -> None:
"""
Initialiser le UIDetector (appelé sous `self._init_lock`).
"""
self._ui_detector_initialized = True
try: