- Frontend v4 accessible sur réseau local (192.168.1.40) - Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard) - Ollama GPU fonctionnel - Self-healing interactif - Dashboard confiance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
613 lines
22 KiB
Python
613 lines
22 KiB
Python
"""
|
|
FusionEngine - Fusion Multi-Modale d'Embeddings
|
|
|
|
Fusionne plusieurs embeddings (image, texte, titre, UI) en un seul vecteur
|
|
avec pondération configurable et normalisation L2.
|
|
|
|
Tâche 5.2: Lazy loading des embeddings avec WeakValueDictionary.
|
|
"""
|
|
|
|
from typing import Dict, List, Optional
|
|
import numpy as np
|
|
from dataclasses import dataclass
|
|
import weakref
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from ..models.state_embedding import (
|
|
StateEmbedding,
|
|
EmbeddingComponent,
|
|
DEFAULT_FUSION_WEIGHTS
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class FusionConfig:
|
|
"""Configuration de la fusion"""
|
|
method: str = "weighted" # weighted ou concat_projection
|
|
normalize: bool = True # Normaliser le vecteur final
|
|
weights: Dict[str, float] = None # Poids personnalisés
|
|
|
|
def __post_init__(self):
|
|
if self.weights is None:
|
|
self.weights = DEFAULT_FUSION_WEIGHTS.copy()
|
|
|
|
# Valider que les poids somment à 1.0 pour weighted
|
|
if self.method == "weighted":
|
|
total = sum(self.weights.values())
|
|
if not (0.99 <= total <= 1.01):
|
|
raise ValueError(
|
|
f"Weights must sum to 1.0 for weighted fusion, got {total}"
|
|
)
|
|
|
|
|
|
class FusionEngine:
|
|
"""
|
|
Moteur de fusion multi-modale avec lazy loading optimisé
|
|
|
|
Fusionne des embeddings de différentes modalités (image, texte, UI)
|
|
en un seul vecteur représentant l'état complet de l'écran.
|
|
|
|
Tâche 5.2: Implémente lazy loading avec WeakValueDictionary pour
|
|
éviter les rechargements multiples tout en permettant le garbage collection.
|
|
"""
|
|
|
|
def __init__(self, config: Optional[FusionConfig] = None):
|
|
"""
|
|
Initialiser le moteur de fusion avec lazy loading
|
|
|
|
Args:
|
|
config: Configuration de fusion (utilise config par défaut si None)
|
|
"""
|
|
self.config = config or FusionConfig()
|
|
|
|
# Tâche 5.2: Cache lazy loading avec WeakValueDictionary
|
|
# Permet le garbage collection automatique des embeddings non utilisés
|
|
self._embedding_cache: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
|
|
self._cache_stats = {
|
|
'hits': 0,
|
|
'misses': 0,
|
|
'loads': 0,
|
|
'evictions': 0
|
|
}
|
|
|
|
def fuse(self,
|
|
embeddings: Dict[str, np.ndarray],
|
|
weights: Optional[Dict[str, float]] = None) -> np.ndarray:
|
|
"""
|
|
Fusionner plusieurs embeddings en un seul vecteur
|
|
|
|
Args:
|
|
embeddings: Dict {modalité: vecteur}
|
|
e.g., {"image": vec1, "text": vec2, "title": vec3, "ui": vec4}
|
|
weights: Poids personnalisés (optionnel, utilise config par défaut)
|
|
|
|
Returns:
|
|
Vecteur fusionné (normalisé si config.normalize=True)
|
|
|
|
Raises:
|
|
ValueError: Si les dimensions ne correspondent pas ou poids invalides
|
|
"""
|
|
if not embeddings:
|
|
raise ValueError("No embeddings provided for fusion")
|
|
|
|
# Utiliser poids de config ou poids fournis
|
|
fusion_weights = weights or self.config.weights
|
|
|
|
# Vérifier que toutes les modalités ont le même nombre de dimensions
|
|
dimensions = None
|
|
for modality, vector in embeddings.items():
|
|
if dimensions is None:
|
|
dimensions = vector.shape[0]
|
|
elif vector.shape[0] != dimensions:
|
|
raise ValueError(
|
|
f"All embeddings must have same dimensions. "
|
|
f"Expected {dimensions}, got {vector.shape[0]} for {modality}"
|
|
)
|
|
|
|
if self.config.method == "weighted":
|
|
fused = self._fuse_weighted(embeddings, fusion_weights)
|
|
elif self.config.method == "concat_projection":
|
|
fused = self._fuse_concat_projection(embeddings, fusion_weights)
|
|
else:
|
|
raise ValueError(f"Unknown fusion method: {self.config.method}")
|
|
|
|
# Normaliser si demandé
|
|
if self.config.normalize:
|
|
fused = self._normalize_l2(fused)
|
|
|
|
return fused
|
|
|
|
def _fuse_weighted(self,
|
|
embeddings: Dict[str, np.ndarray],
|
|
weights: Dict[str, float]) -> np.ndarray:
|
|
"""
|
|
Fusion pondérée simple : somme pondérée des vecteurs
|
|
|
|
fused = w1*v1 + w2*v2 + w3*v3 + w4*v4
|
|
"""
|
|
# Initialiser vecteur résultat
|
|
first_vector = next(iter(embeddings.values()))
|
|
fused = np.zeros_like(first_vector, dtype=np.float32)
|
|
|
|
# Somme pondérée
|
|
for modality, vector in embeddings.items():
|
|
weight = weights.get(modality, 0.0)
|
|
fused += weight * vector
|
|
|
|
return fused
|
|
|
|
def _fuse_concat_projection(self,
|
|
embeddings: Dict[str, np.ndarray],
|
|
weights: Dict[str, float]) -> np.ndarray:
|
|
"""
|
|
Fusion par concaténation + projection
|
|
|
|
Concatène tous les vecteurs puis projette vers dimension cible.
|
|
Note: Pour l'instant, on fait une simple moyenne pondérée.
|
|
TODO: Implémenter vraie projection avec matrice apprise.
|
|
"""
|
|
# Pour l'instant, utiliser fusion pondérée
|
|
# Dans une version future, on pourrait apprendre une matrice de projection
|
|
return self._fuse_weighted(embeddings, weights)
|
|
|
|
def _normalize_l2(self, vector: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Normaliser un vecteur avec norme L2
|
|
|
|
normalized = vector / ||vector||_2
|
|
"""
|
|
norm = np.linalg.norm(vector)
|
|
if norm < 1e-10: # Éviter division par zéro
|
|
return vector
|
|
return vector / norm
|
|
|
|
def create_state_embedding(self,
|
|
embedding_id: str,
|
|
embeddings: Dict[str, np.ndarray],
|
|
vector_save_path: str,
|
|
weights: Optional[Dict[str, float]] = None,
|
|
metadata: Optional[Dict] = None) -> StateEmbedding:
|
|
"""
|
|
Créer un StateEmbedding complet depuis des embeddings individuels
|
|
|
|
Args:
|
|
embedding_id: ID unique pour cet embedding
|
|
embeddings: Dict {modalité: vecteur}
|
|
vector_save_path: Chemin où sauvegarder le vecteur fusionné
|
|
weights: Poids personnalisés (optionnel)
|
|
metadata: Métadonnées additionnelles
|
|
|
|
Returns:
|
|
StateEmbedding avec vecteur fusionné sauvegardé
|
|
"""
|
|
# Fusionner les embeddings
|
|
fused_vector = self.fuse(embeddings, weights)
|
|
|
|
# Créer les composants
|
|
fusion_weights = weights or self.config.weights
|
|
components = {}
|
|
|
|
for modality, vector in embeddings.items():
|
|
# Pour l'instant, on ne sauvegarde pas les vecteurs individuels
|
|
# On pourrait les sauvegarder si nécessaire
|
|
components[modality] = EmbeddingComponent(
|
|
weight=fusion_weights.get(modality, 0.0),
|
|
vector_id=f"{vector_save_path}_{modality}.npy",
|
|
source_text=None
|
|
)
|
|
|
|
# Créer StateEmbedding
|
|
dimensions = fused_vector.shape[0]
|
|
state_emb = StateEmbedding(
|
|
embedding_id=embedding_id,
|
|
vector_id=vector_save_path,
|
|
dimensions=dimensions,
|
|
fusion_method=self.config.method,
|
|
components=components,
|
|
metadata=metadata or {}
|
|
)
|
|
|
|
# Sauvegarder le vecteur fusionné
|
|
state_emb.save_vector(fused_vector)
|
|
|
|
return state_emb
|
|
|
|
def compute_similarity(self,
|
|
emb1: StateEmbedding,
|
|
emb2: StateEmbedding) -> float:
|
|
"""
|
|
Calculer similarité cosinus entre deux StateEmbeddings
|
|
|
|
Args:
|
|
emb1: Premier embedding
|
|
emb2: Deuxième embedding
|
|
|
|
Returns:
|
|
Similarité cosinus dans [-1, 1]
|
|
"""
|
|
return emb1.compute_similarity(emb2)
|
|
|
|
def batch_fuse(self,
|
|
batch_embeddings: List[Dict[str, np.ndarray]],
|
|
weights: Optional[Dict[str, float]] = None) -> List[np.ndarray]:
|
|
"""
|
|
Fusionner un batch d'embeddings en parallèle
|
|
|
|
Args:
|
|
batch_embeddings: Liste de dicts {modalité: vecteur}
|
|
weights: Poids personnalisés (optionnel)
|
|
|
|
Returns:
|
|
Liste de vecteurs fusionnés
|
|
"""
|
|
return [self.fuse(embs, weights) for embs in batch_embeddings]
|
|
|
|
def get_config(self) -> FusionConfig:
|
|
"""Récupérer la configuration actuelle"""
|
|
return self.config
|
|
|
|
def set_weights(self, weights: Dict[str, float]) -> None:
|
|
"""
|
|
Mettre à jour les poids de fusion
|
|
|
|
Args:
|
|
weights: Nouveaux poids
|
|
|
|
Raises:
|
|
ValueError: Si les poids ne somment pas à 1.0 (pour weighted)
|
|
"""
|
|
if self.config.method == "weighted":
|
|
total = sum(weights.values())
|
|
if not (0.99 <= total <= 1.01):
|
|
raise ValueError(
|
|
f"Weights must sum to 1.0 for weighted fusion, got {total}"
|
|
)
|
|
|
|
self.config.weights = weights.copy()
|
|
|
|
|
|
# ============================================================================
|
|
# Fonctions utilitaires
|
|
# ============================================================================
|
|
|
|
def create_default_fusion_engine() -> FusionEngine:
|
|
"""Créer un FusionEngine avec configuration par défaut"""
|
|
return FusionEngine(FusionConfig())
|
|
|
|
|
|
def normalize_vector(vector: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Normaliser un vecteur avec norme L2
|
|
|
|
Args:
|
|
vector: Vecteur à normaliser
|
|
|
|
Returns:
|
|
Vecteur normalisé
|
|
"""
|
|
norm = np.linalg.norm(vector)
|
|
if norm < 1e-10:
|
|
return vector
|
|
return vector / norm
|
|
|
|
|
|
def validate_weights(weights: Dict[str, float],
|
|
method: str = "weighted") -> bool:
|
|
"""
|
|
Valider que les poids sont corrects
|
|
|
|
Args:
|
|
weights: Poids à valider
|
|
method: Méthode de fusion
|
|
|
|
Returns:
|
|
True si valides, False sinon
|
|
"""
|
|
if method == "weighted":
|
|
total = sum(weights.values())
|
|
return 0.99 <= total <= 1.01
|
|
return True
|
|
|
|
def fuse_batch(
|
|
self,
|
|
embeddings_batch: List[Dict[str, np.ndarray]],
|
|
weights: Optional[Dict[str, float]] = None
|
|
) -> np.ndarray:
|
|
"""
|
|
Fusionner un batch d'embeddings en parallèle pour efficacité.
|
|
|
|
Args:
|
|
embeddings_batch: Liste de dicts {modalité: vecteur}
|
|
weights: Poids personnalisés (optionnel)
|
|
|
|
Returns:
|
|
Array numpy de shape (batch_size, embedding_dim) avec vecteurs fusionnés
|
|
|
|
Note:
|
|
Cette méthode est optimisée pour traiter plusieurs embeddings
|
|
en une seule opération vectorisée, ce qui est plus rapide que
|
|
de fusionner un par un.
|
|
"""
|
|
if not embeddings_batch:
|
|
raise ValueError("Empty batch provided")
|
|
|
|
batch_size = len(embeddings_batch)
|
|
fusion_weights = weights or self.config.weights
|
|
|
|
# Déterminer les dimensions depuis le premier élément
|
|
first_emb = embeddings_batch[0]
|
|
first_vector = next(iter(first_emb.values()))
|
|
embedding_dim = first_vector.shape[0]
|
|
|
|
# Préparer le résultat
|
|
fused_batch = np.zeros((batch_size, embedding_dim), dtype=np.float32)
|
|
|
|
# Traiter chaque modalité pour tout le batch
|
|
for modality in first_emb.keys():
|
|
weight = fusion_weights.get(modality, 0.0)
|
|
if weight == 0.0:
|
|
continue
|
|
|
|
# Collecter tous les vecteurs de cette modalité
|
|
modality_vectors = []
|
|
for emb_dict in embeddings_batch:
|
|
if modality in emb_dict:
|
|
modality_vectors.append(emb_dict[modality])
|
|
else:
|
|
# Si modalité manquante, utiliser vecteur zéro
|
|
modality_vectors.append(np.zeros(embedding_dim, dtype=np.float32))
|
|
|
|
# Convertir en array numpy (batch_size, embedding_dim)
|
|
modality_batch = np.array(modality_vectors, dtype=np.float32)
|
|
|
|
# Ajouter contribution pondérée
|
|
fused_batch += weight * modality_batch
|
|
|
|
# Normaliser si demandé
|
|
if self.config.normalize:
|
|
# Normalisation L2 pour chaque vecteur du batch
|
|
norms = np.linalg.norm(fused_batch, axis=1, keepdims=True)
|
|
# Éviter division par zéro
|
|
norms = np.where(norms < 1e-10, 1.0, norms)
|
|
fused_batch = fused_batch / norms
|
|
|
|
return fused_batch
|
|
|
|
def create_state_embeddings_batch(
|
|
self,
|
|
embedding_ids: List[str],
|
|
embeddings_batch: List[Dict[str, np.ndarray]],
|
|
vector_save_paths: List[str],
|
|
weights: Optional[Dict[str, float]] = None,
|
|
metadata_batch: Optional[List[Dict]] = None
|
|
) -> List[StateEmbedding]:
|
|
"""
|
|
Créer un batch de StateEmbeddings de manière optimisée.
|
|
|
|
Args:
|
|
embedding_ids: Liste des IDs uniques
|
|
embeddings_batch: Liste de dicts {modalité: vecteur}
|
|
vector_save_paths: Liste des chemins de sauvegarde
|
|
weights: Poids personnalisés (optionnel)
|
|
metadata_batch: Liste de métadonnées (optionnel)
|
|
|
|
Returns:
|
|
Liste de StateEmbeddings créés
|
|
|
|
Note:
|
|
Cette méthode est ~3-5x plus rapide que de créer les embeddings
|
|
un par un grâce au traitement vectorisé.
|
|
"""
|
|
if not (len(embedding_ids) == len(embeddings_batch) == len(vector_save_paths)):
|
|
raise ValueError("All input lists must have the same length")
|
|
|
|
batch_size = len(embedding_ids)
|
|
|
|
# Fusionner tout le batch en une seule opération
|
|
fused_vectors = self.fuse_batch(embeddings_batch, weights)
|
|
|
|
# Créer les StateEmbeddings
|
|
state_embeddings = []
|
|
fusion_weights = weights or self.config.weights
|
|
|
|
for i in range(batch_size):
|
|
embedding_id = embedding_ids[i]
|
|
embeddings = embeddings_batch[i]
|
|
vector_save_path = vector_save_paths[i]
|
|
metadata = metadata_batch[i] if metadata_batch else None
|
|
fused_vector = fused_vectors[i]
|
|
|
|
# Créer les composants
|
|
components = {}
|
|
for modality, vector in embeddings.items():
|
|
components[modality] = EmbeddingComponent(
|
|
weight=fusion_weights.get(modality, 0.0),
|
|
vector_id=f"{vector_save_path}_{modality}.npy",
|
|
source_text=None
|
|
)
|
|
|
|
# Créer StateEmbedding
|
|
dimensions = fused_vector.shape[0]
|
|
state_emb = StateEmbedding(
|
|
embedding_id=embedding_id,
|
|
vector_id=vector_save_path,
|
|
dimensions=dimensions,
|
|
fusion_method=self.config.method,
|
|
components=components,
|
|
metadata=metadata or {}
|
|
)
|
|
|
|
# Sauvegarder le vecteur fusionné
|
|
state_emb.save_vector(fused_vector)
|
|
|
|
state_embeddings.append(state_emb)
|
|
|
|
return state_embeddings
|
|
|
|
def compute_similarity_batch(
|
|
self,
|
|
query_embedding: StateEmbedding,
|
|
candidate_embeddings: List[StateEmbedding]
|
|
) -> np.ndarray:
|
|
"""
|
|
Calculer la similarité entre un embedding query et un batch de candidats.
|
|
|
|
Args:
|
|
query_embedding: Embedding de requête
|
|
candidate_embeddings: Liste d'embeddings candidats
|
|
|
|
Returns:
|
|
Array numpy de similarités (batch_size,)
|
|
|
|
Note:
|
|
Utilise des opérations vectorisées pour calculer toutes les
|
|
similarités en une seule opération matricielle.
|
|
"""
|
|
# Charger le vecteur query
|
|
query_vector = query_embedding.get_vector()
|
|
|
|
# Charger tous les vecteurs candidats
|
|
candidate_vectors = []
|
|
for emb in candidate_embeddings:
|
|
candidate_vectors.append(emb.get_vector())
|
|
|
|
# Convertir en matrice (batch_size, embedding_dim)
|
|
candidates_matrix = np.array(candidate_vectors, dtype=np.float32)
|
|
|
|
# Calcul vectorisé : similarité cosinus = dot product (si normalisés)
|
|
# similarities = candidates_matrix @ query_vector
|
|
similarities = np.dot(candidates_matrix, query_vector)
|
|
|
|
return similarities
|
|
|
|
def load_embedding_lazy(self, embedding_path: str, force_reload: bool = False) -> Optional[np.ndarray]:
|
|
"""
|
|
Charger un embedding avec lazy loading et cache.
|
|
|
|
Tâche 5.2: Lazy loading des embeddings avec cache WeakValueDictionary.
|
|
Chargement à la demande depuis le disque avec éviction automatique.
|
|
|
|
Args:
|
|
embedding_path: Chemin vers le fichier embedding (.npy)
|
|
force_reload: Forcer le rechargement depuis le disque
|
|
|
|
Returns:
|
|
Array numpy de l'embedding ou None si erreur
|
|
"""
|
|
if not embedding_path:
|
|
return None
|
|
|
|
# Vérifier le cache d'abord (sauf si force_reload)
|
|
if not force_reload and embedding_path in self._embedding_cache:
|
|
self._cache_stats['hits'] += 1
|
|
logger.debug(f"Embedding cache hit: {Path(embedding_path).name}")
|
|
return self._embedding_cache[embedding_path]
|
|
|
|
# Cache miss - charger depuis le disque
|
|
self._cache_stats['misses'] += 1
|
|
|
|
try:
|
|
if not Path(embedding_path).exists():
|
|
logger.warning(f"Embedding file not found: {embedding_path}")
|
|
return None
|
|
|
|
logger.debug(f"Loading embedding from disk: {Path(embedding_path).name}")
|
|
embedding = np.load(embedding_path)
|
|
|
|
# Valider le format
|
|
if not isinstance(embedding, np.ndarray) or embedding.ndim != 1:
|
|
logger.error(f"Invalid embedding format in {embedding_path}")
|
|
return None
|
|
|
|
# Ajouter au cache (WeakValueDictionary gère l'éviction automatique)
|
|
self._embedding_cache[embedding_path] = embedding
|
|
self._cache_stats['loads'] += 1
|
|
|
|
logger.debug(f"Embedding loaded: {embedding.shape} from {Path(embedding_path).name}")
|
|
return embedding
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading embedding from {embedding_path}: {e}")
|
|
return None
|
|
|
|
def fuse_with_lazy_loading(self,
|
|
embedding_paths: Dict[str, str],
|
|
weights: Optional[Dict[str, float]] = None) -> Optional[np.ndarray]:
|
|
"""
|
|
Fusionner des embeddings avec lazy loading depuis les chemins de fichiers.
|
|
|
|
Tâche 5.2: Version optimisée qui charge les embeddings à la demande.
|
|
|
|
Args:
|
|
embedding_paths: Dict {modalité: chemin_fichier}
|
|
weights: Poids personnalisés (optionnel)
|
|
|
|
Returns:
|
|
Vecteur fusionné ou None si erreur
|
|
"""
|
|
if not embedding_paths:
|
|
logger.warning("No embedding paths provided for lazy fusion")
|
|
return None
|
|
|
|
# Charger les embeddings avec lazy loading
|
|
embeddings = {}
|
|
for modality, path in embedding_paths.items():
|
|
embedding = self.load_embedding_lazy(path)
|
|
if embedding is not None:
|
|
embeddings[modality] = embedding
|
|
else:
|
|
logger.warning(f"Failed to load embedding for modality '{modality}' from {path}")
|
|
|
|
if not embeddings:
|
|
logger.error("No embeddings could be loaded for fusion")
|
|
return None
|
|
|
|
# Fusionner normalement
|
|
return self.fuse(embeddings, weights)
|
|
|
|
def get_cache_stats(self) -> Dict[str, int]:
|
|
"""
|
|
Obtenir les statistiques du cache d'embeddings.
|
|
|
|
Returns:
|
|
Dict avec hits, misses, loads, cache_size
|
|
"""
|
|
return {
|
|
**self._cache_stats,
|
|
'cache_size': len(self._embedding_cache)
|
|
}
|
|
|
|
def clear_embedding_cache(self) -> None:
|
|
"""
|
|
Vider le cache d'embeddings.
|
|
|
|
Utile pour libérer la mémoire ou forcer le rechargement.
|
|
"""
|
|
cache_size = len(self._embedding_cache)
|
|
self._embedding_cache.clear()
|
|
self._cache_stats['evictions'] += cache_size
|
|
logger.info(f"Cleared embedding cache ({cache_size} entries)")
|
|
|
|
def preload_embeddings(self, embedding_paths: List[str]) -> int:
|
|
"""
|
|
Précharger des embeddings dans le cache.
|
|
|
|
Utile pour optimiser les performances en chargeant
|
|
les embeddings fréquemment utilisés à l'avance.
|
|
|
|
Args:
|
|
embedding_paths: Liste des chemins à précharger
|
|
|
|
Returns:
|
|
Nombre d'embeddings préchargés avec succès
|
|
"""
|
|
loaded_count = 0
|
|
for path in embedding_paths:
|
|
if self.load_embedding_lazy(path) is not None:
|
|
loaded_count += 1
|
|
|
|
logger.info(f"Preloaded {loaded_count}/{len(embedding_paths)} embeddings")
|
|
return loaded_count |