perf: 1 appel VLM par screenshot + sélection intelligente + Rust auto-launch Léa

Analyse VLM :
- 1 seul appel VLM par screenshot au lieu de 30 (~15s vs 6.5min)
- Sélection screenshots par hash perceptuel (3-4 utiles sur 12)
- Fallback classification individuelle si appel unique échoue
- Estimation : ~1min par workflow au lieu de 78min

Rust agent :
- Léa (Edge mode app) s'ouvre automatiquement au démarrage
- Plus besoin de systray pour lancer le chat
- Fix URL chat /chat → /

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-19 00:26:29 +01:00
parent 90ee91caf9
commit 24a947b51d
6 changed files with 661 additions and 296 deletions

View File

@@ -7,7 +7,9 @@ pour traiter en temps réel les screenshots et événements reçus via fibre.
Tous les calculs GPU tournent ici (serveur RTX 5070).
"""
import hashlib
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
@@ -506,17 +508,21 @@ class StreamProcessor:
return {"error": f"Dossier shots/ introuvable pour {session_id}"}
# Lister les screenshots full (shot_XXXX_full.png), triés par nom
full_shots = sorted(shots_dir.glob("shot_*_full.png"))
if not full_shots:
all_shots = sorted(shots_dir.glob("shot_*_full.png"))
if not all_shots:
return {
"error": f"Aucun screenshot shot_*_full.png trouvé dans {shots_dir}",
"session_id": session_id,
}
total = len(full_shots)
# Sélection intelligente : ne garder que les screenshots significatifs
# pour éviter d'analyser des captures redondantes (~identiques)
key_shots = self._select_key_screenshots(session_id, all_shots)
total_all = len(all_shots)
total = len(key_shots)
logger.info(
f"Session {session_id} : {total} screenshots full à analyser "
f"dans {shots_dir}"
f"Screenshots sélectionnés : {total}/{total_all} "
f"(déduplication perceptuelle) dans {shots_dir}"
)
# S'assurer que la session est enregistrée dans le session_manager
@@ -527,9 +533,9 @@ class StreamProcessor:
self._screen_states.pop(session_id, None)
self._embeddings.pop(session_id, None)
# Analyser chaque screenshot full
# Analyser chaque screenshot sélectionné
errors = 0
for i, shot_file in enumerate(full_shots):
for i, shot_file in enumerate(key_shots):
shot_id = shot_file.stem # ex: "shot_0001_full"
file_path = str(shot_file)
@@ -556,7 +562,7 @@ class StreamProcessor:
logger.info(
f"Session {session_id} : {states_count}/{total} screenshots analysés "
f"({errors} erreurs)"
f"({errors} erreurs, {total_all - total} skippés par dédup)"
)
# Construire le workflow via finalize_session()
@@ -566,6 +572,59 @@ class StreamProcessor:
result = self.finalize_session(session_id)
return result
def _select_key_screenshots(self, session_id: str, shot_paths: List[Path]) -> List[Path]:
"""Sélectionner uniquement les screenshots significatifs pour éviter les analyses redondantes.
Critères :
1. Garder le premier et le dernier screenshot (toujours)
2. Comparer chaque screenshot au précédent via hash perceptuel (32x32 grayscale)
3. Si l'image est identique au précédent → skip (même écran, pas de changement)
4. Privilégier les screenshots d'action (shot_*_full) vs heartbeat
Réduit typiquement 12 screenshots à 3-4 screenshots utiles.
"""
if len(shot_paths) <= 2:
return list(shot_paths)
from PIL import Image
selected = []
last_hash = None
for path in shot_paths:
basename = os.path.basename(str(path))
# Les screenshots d'action sont prioritaires
is_action = 'shot_' in basename and '_full' in basename
# Hash perceptuel : redimensionner à 32x32 en niveaux de gris
# Assez discriminant pour détecter les changements d'état de l'UI
try:
img = Image.open(str(path)).resize((32, 32)).convert('L')
current_hash = hashlib.md5(img.tobytes()).hexdigest()
except Exception as e:
logger.debug(f"Impossible de hasher {basename}: {e}")
# En cas d'erreur, inclure le screenshot par sécurité
selected.append(path)
continue
# Inclure si : premier screenshot, hash différent, ou screenshot d'action
if last_hash is None or current_hash != last_hash:
selected.append(path)
last_hash = current_hash
elif is_action:
# Action mais visuellement identique — skip quand même
# car l'état de l'écran n'a pas changé
logger.debug(f"Screenshot d'action {basename} identique au précédent, skip")
# Garantir que le premier et le dernier sont toujours inclus
if shot_paths[0] not in selected:
selected.insert(0, shot_paths[0])
if shot_paths[-1] not in selected:
selected.append(shot_paths[-1])
return selected
def _find_session_dir(self, session_id: str) -> Optional[Path]:
"""Trouver le dossier d'une session sur disque.