feat: chat unifié, GestureCatalog, Copilot, Léa UI, extraction données, vérification replay
Refonte majeure du système Agent Chat et ajout de nombreux modules : - Chat unifié : suppression du dual Workflows/Agent Libre, tout passe par /api/chat avec résolution en 3 niveaux (workflow → geste → "montre-moi") - GestureCatalog : 38 raccourcis clavier universels Windows avec matching sémantique, substitution automatique dans les replays, et endpoint /api/gestures - Mode Copilot : exécution pas-à-pas des workflows avec validation humaine via WebSocket (approve/skip/abort) avant chaque action - Léa UI (agent_v0/lea_ui/) : interface PyQt5 pour Windows avec overlay transparent pour feedback visuel pendant le replay - Data Extraction (core/extraction/) : moteur d'extraction visuelle de données (OCR + VLM → SQLite), avec schémas YAML et export CSV/Excel - ReplayVerifier (agent_v0/server_v1/) : vérification post-action par comparaison de screenshots, avec logique de retry (max 3) - IntentParser durci : meilleur fallback regex, type GREETING, patterns améliorés - Dashboard : nouvelles pages gestures, streaming, extractions - Tests : 63 tests GestureCatalog, 47 tests extraction, corrections tests existants - Dépréciation : /api/agent/plan et /api/agent/execute retournent HTTP 410, suppression du code hardcodé _plan_to_replay_actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
312
core/extraction/extraction_engine.py
Normal file
312
core/extraction/extraction_engine.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
ExtractionEngine - Orchestrateur principal du moteur d'extraction de donnees
|
||||
|
||||
Orchestre le cycle complet :
|
||||
naviguer -> screenshot -> extraire -> valider -> stocker -> suivant
|
||||
|
||||
S'appuie sur FieldExtractor (VLM/OCR), DataStore (SQLite), et
|
||||
IterationController (navigation) pour realiser l'extraction automatisee
|
||||
de donnees depuis des interfaces utilisateur.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .data_store import DataStore
|
||||
from .field_extractor import FieldExtractor
|
||||
from .iteration_controller import IterationController
|
||||
from .schema import ExtractionSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtractionEngine:
|
||||
"""
|
||||
Moteur d'extraction principal.
|
||||
|
||||
Orchestre le cycle : naviguer -> screenshot -> extraire -> stocker -> suivant.
|
||||
|
||||
Modes d'utilisation :
|
||||
1. Automatique : start_extraction() — boucle complete avec navigation
|
||||
2. Manuel : extract_current_screen() — extraction ponctuelle d'un screenshot
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: ExtractionSchema,
|
||||
store: Optional[DataStore] = None,
|
||||
field_extractor: Optional[FieldExtractor] = None,
|
||||
streaming_server_url: str = "http://localhost:5005",
|
||||
screenshot_dir: str = "data/extractions/screenshots",
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
schema: Schema d'extraction decrivant les champs et la navigation
|
||||
store: DataStore pour le stockage (cree un par defaut si absent)
|
||||
field_extractor: Extracteur de champs (cree un par defaut si absent)
|
||||
streaming_server_url: URL du streaming server Agent V1
|
||||
screenshot_dir: Repertoire pour sauvegarder les screenshots
|
||||
"""
|
||||
self.schema = schema
|
||||
self.store = store or DataStore()
|
||||
self.field_extractor = field_extractor or FieldExtractor()
|
||||
self.controller = IterationController(schema, streaming_server_url)
|
||||
self.streaming_server_url = streaming_server_url.rstrip("/")
|
||||
self.screenshot_dir = Path(screenshot_dir)
|
||||
self.screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Etat interne
|
||||
self._current_extraction_id: Optional[str] = None
|
||||
self._is_running = False
|
||||
self._should_stop = False
|
||||
self._progress_callback: Optional[Callable] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique - Extraction automatique
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_extraction(
|
||||
self,
|
||||
session_id: str,
|
||||
on_progress: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Demarrer une session d'extraction automatique.
|
||||
|
||||
Boucle :
|
||||
1. Creer l'extraction dans le store
|
||||
2. Pour chaque enregistrement :
|
||||
a. Prendre un screenshot
|
||||
b. Extraire les champs
|
||||
c. Valider
|
||||
d. Stocker
|
||||
e. Naviguer au suivant
|
||||
3. Finaliser et retourner l'extraction_id
|
||||
|
||||
Args:
|
||||
session_id: ID de la session de streaming (pour navigation)
|
||||
on_progress: Callback appele a chaque record (optionnel)
|
||||
|
||||
Returns:
|
||||
extraction_id
|
||||
"""
|
||||
self._is_running = True
|
||||
self._should_stop = False
|
||||
self._progress_callback = on_progress
|
||||
|
||||
# Creer la session d'extraction
|
||||
extraction_id = self.store.create_extraction(self.schema)
|
||||
self._current_extraction_id = extraction_id
|
||||
|
||||
logger.info(
|
||||
"Demarrage extraction %s (schema=%s, max=%d)",
|
||||
extraction_id[:8],
|
||||
self.schema.name,
|
||||
self.controller.max_records,
|
||||
)
|
||||
|
||||
try:
|
||||
while self.controller.has_next() and not self._should_stop:
|
||||
idx = self.controller.current_index
|
||||
|
||||
# 1. Screenshot
|
||||
screenshot_path = self._take_screenshot(session_id, idx)
|
||||
if screenshot_path is None:
|
||||
logger.warning("Screenshot echoue a l'index %d, on continue", idx)
|
||||
# Naviguer quand meme pour ne pas rester bloque
|
||||
self.controller.navigate_to_next(session_id)
|
||||
continue
|
||||
|
||||
# 2. Extraction
|
||||
result = self.extract_current_screen(screenshot_path)
|
||||
|
||||
# 3. Stockage
|
||||
self.store.add_record(
|
||||
extraction_id=extraction_id,
|
||||
data=result["data"],
|
||||
screenshot_path=screenshot_path,
|
||||
confidence=result["confidence"],
|
||||
errors=result.get("errors"),
|
||||
)
|
||||
|
||||
# 4. Callback de progression
|
||||
if self._progress_callback:
|
||||
progress = self.get_progress()
|
||||
progress["last_record"] = result["data"]
|
||||
progress["last_confidence"] = result["confidence"]
|
||||
self._progress_callback(progress)
|
||||
|
||||
logger.info(
|
||||
"Record %d/%d extrait (confiance=%.2f)",
|
||||
idx + 1,
|
||||
self.controller.max_records,
|
||||
result["confidence"],
|
||||
)
|
||||
|
||||
# 5. Navigation
|
||||
if not self.controller.navigate_to_next(session_id):
|
||||
logger.info("Fin de navigation a l'index %d", idx)
|
||||
break
|
||||
|
||||
# Finaliser
|
||||
status = "stopped" if self._should_stop else "completed"
|
||||
self.store.finish_extraction(extraction_id, status=status)
|
||||
|
||||
logger.info(
|
||||
"Extraction %s terminee : %s (%d records)",
|
||||
extraction_id[:8],
|
||||
status,
|
||||
self.controller.current_index,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Erreur pendant l'extraction : %s", e)
|
||||
self.store.finish_extraction(extraction_id, status="error")
|
||||
raise
|
||||
|
||||
finally:
|
||||
self._is_running = False
|
||||
self._current_extraction_id = None
|
||||
|
||||
return extraction_id
|
||||
|
||||
def stop_extraction(self) -> None:
|
||||
"""Demander l'arret de l'extraction en cours."""
|
||||
if self._is_running:
|
||||
logger.info("Arret demande pour l'extraction en cours")
|
||||
self._should_stop = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique - Extraction ponctuelle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract_current_screen(self, screenshot_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extraire les champs du screenshot actuel sans navigation.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le screenshot
|
||||
|
||||
Returns:
|
||||
Dict avec 'data', 'confidence', 'errors', 'validation'
|
||||
"""
|
||||
# Extraction
|
||||
result = self.field_extractor.extract_fields(screenshot_path, self.schema)
|
||||
|
||||
# Validation contre le schema
|
||||
validation = self.schema.validate_record(result["data"])
|
||||
result["validation"] = validation
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique - Progression
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_progress(self) -> Dict[str, Any]:
|
||||
"""Retourne la progression actuelle de l'extraction."""
|
||||
nav_progress = self.controller.progress
|
||||
stats = {}
|
||||
|
||||
if self._current_extraction_id:
|
||||
stats = self.store.get_stats(self._current_extraction_id)
|
||||
|
||||
return {
|
||||
"extraction_id": self._current_extraction_id,
|
||||
"is_running": self._is_running,
|
||||
"navigation": nav_progress,
|
||||
"stats": stats,
|
||||
"schema_name": self.schema.name,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screenshot
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _take_screenshot(self, session_id: str, index: int) -> Optional[str]:
|
||||
"""
|
||||
Prendre un screenshot via le streaming server.
|
||||
|
||||
Essaie d'appeler l'API du streaming server pour obtenir
|
||||
le screenshot courant. En cas d'echec, retourne None.
|
||||
|
||||
Args:
|
||||
session_id: ID de la session de streaming
|
||||
index: Index de l'enregistrement courant
|
||||
|
||||
Returns:
|
||||
Chemin du screenshot sauvegarde, ou None
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.streaming_server_url}/api/screenshot",
|
||||
params={"session_id": session_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Sauvegarder le screenshot
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"record_{index:04d}_{timestamp}.png"
|
||||
filepath = self.screenshot_dir / filename
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return str(filepath)
|
||||
else:
|
||||
logger.warning(
|
||||
"Screenshot echoue : HTTP %d", response.status_code
|
||||
)
|
||||
return None
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(
|
||||
"Streaming server non accessible pour screenshot"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Erreur screenshot : %s", e)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilitaires
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract_from_file(self, screenshot_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Raccourci pour extraire depuis un fichier existant
|
||||
et stocker le resultat.
|
||||
|
||||
Utile pour du retraitement offline de screenshots.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers un screenshot existant
|
||||
|
||||
Returns:
|
||||
Dict avec les donnees extraites et le record_id
|
||||
"""
|
||||
if self._current_extraction_id is None:
|
||||
extraction_id = self.store.create_extraction(self.schema)
|
||||
else:
|
||||
extraction_id = self._current_extraction_id
|
||||
|
||||
result = self.extract_current_screen(screenshot_path)
|
||||
|
||||
record_id = self.store.add_record(
|
||||
extraction_id=extraction_id,
|
||||
data=result["data"],
|
||||
screenshot_path=screenshot_path,
|
||||
confidence=result["confidence"],
|
||||
errors=result.get("errors"),
|
||||
)
|
||||
|
||||
result["record_id"] = record_id
|
||||
result["extraction_id"] = extraction_id
|
||||
return result
|
||||
Reference in New Issue
Block a user