feat: Léa chat + IRBuilder enrichi (stratégies V4 complètes)

Aspect 2/4 Léa : interface conversationnelle
- chat_interface.py : ChatSession thread-safe, états idle/planning/awaiting/executing/done
- 5 endpoints REST : /api/v1/chat/* (session, message, history, confirm, sessions)
- web_dashboard/chat.html + chat.js : UI minimaliste, polling 2s, pas de framework
- Proxy Flask /api/chat/* → serveur streaming
- 34 tests (happy path, abandon, refus, erreurs, gemma4 down)

IRBuilder enrichi pour plans V4 complets
- _event_to_action() appelle enrich_click_from_screenshot() quand session_dir dispo
- Chaque clic porte _enrichment (by_text OCR, anchor_image_base64, vlm_description)
- ExecutionCompiler consomme l'enrichissement pour produire 3 stratégies par clic
  Avant : [ocr] uniquement, target="unknown_window"
  Après : [ocr, template, vlm] avec vrai texte OCR ("Rechercher", "Ouvrir")

Validé sur session réelle : 10/10 clics enrichis (by_text + anchor + vlm_description)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 09:01:13 +02:00
parent a6eb4c168f
commit f541bb8ce4
8 changed files with 2241 additions and 26 deletions

View File

@@ -780,6 +780,11 @@ async def stream_event(data: StreamEvent):
# Traitement direct via StreamProcessor
result = worker.process_event_direct(session_id, data.event)
# ── Observation Shadow (si mode Shadow activé pour cette session) ──
# L'appel est protégé et non bloquant : si l'observer n'est pas
# actif, ou s'il lève, la capture continue normalement.
shadow_observe_event(session_id, data.event)
# ── Enrichissement SomEngine temps réel pour les mouse_click ──
# Après l'enregistrement de l'event, tenter l'enrichissement si le
# screenshot est déjà arrivé. Sinon, l'event est mis en attente et
@@ -1338,6 +1343,249 @@ async def requeue_session(session_id: str):
}
# =========================================================================
# Shadow mode — observation temps réel + feedback utilisateur
# =========================================================================
#
# Endpoints utilisés par la GUI Léa pour :
# - Démarrer/arrêter le mode Shadow sur une session en cours
# - Récupérer en temps réel ce que Léa a compris
# - Envoyer des feedbacks (valider/corriger/annuler/fusionner)
# - Construire le WorkflowIR final après validation
#
# Source de vérité : events.jsonl (inchangé). Le ShadowObserver est une
# couche d'observation facultative qui ne modifie PAS la capture.
#
# Import paresseux pour ne pas alourdir le démarrage serveur si la
# feature n'est pas utilisée.
# =========================================================================
_shadow_observer = None
_shadow_validators: Dict[str, Any] = {} # session_id -> ShadowValidator
_shadow_lock = threading.Lock()
def _get_shadow_observer():
"""Retourner le ShadowObserver partagé (lazy init)."""
global _shadow_observer
with _shadow_lock:
if _shadow_observer is None:
from core.workflow.shadow_observer import get_shared_observer
_shadow_observer = get_shared_observer()
return _shadow_observer
def _get_shadow_validator(session_id: str):
"""Retourner (ou créer) le ShadowValidator pour une session."""
from core.workflow.shadow_validator import ShadowValidator
with _shadow_lock:
if session_id not in _shadow_validators:
_shadow_validators[session_id] = ShadowValidator()
return _shadow_validators[session_id]
def shadow_observe_event(session_id: str, event: Dict[str, Any]) -> None:
"""Injection d'un événement dans le ShadowObserver (si session active).
Helper appelé depuis stream_event() pour alimenter l'observer sans
casser le flux de capture. Protégé contre les exceptions pour
garantir qu'une erreur d'observation ne fait jamais planter la
capture.
"""
try:
observer = _get_shadow_observer()
if observer.has_session(session_id):
observer.observe_event(session_id, event)
except Exception as e:
logger.debug(f"shadow_observe_event: {e}")
class ShadowStartRequest(BaseModel):
session_id: str
class ShadowFeedbackRequest(BaseModel):
"""Feedback utilisateur pendant l'enregistrement.
action :
- "validate" : valider l'étape
- "correct" : corriger l'intention (new_intent requis)
- "undo" : annuler l'étape
- "cancel" : annuler tout le workflow
- "merge_next" : fusionner avec la suivante
- "split" : couper (at_event_index requis)
"""
session_id: str
action: str
step_index: Optional[int] = None
new_intent: Optional[str] = None
at_event_index: Optional[int] = None
class ShadowBuildRequest(BaseModel):
"""Construire le WorkflowIR final à partir des feedbacks."""
session_id: str
name: str = ""
domain: str = "generic"
require_all_validated: bool = False
@app.post("/api/v1/shadow/start")
async def shadow_start(request: ShadowStartRequest):
"""Démarrer le mode Shadow pour une session en cours.
Une fois démarré, chaque événement reçu via /api/v1/traces/stream/event
alimentera le ShadowObserver pour construire la compréhension en
temps réel.
"""
observer = _get_shadow_observer()
observer.start(request.session_id)
logger.info(f"Shadow mode démarré pour la session {request.session_id}")
return {
"status": "shadow_started",
"session_id": request.session_id,
"message": "Léa observe — fais ta tâche normalement.",
}
@app.post("/api/v1/shadow/stop")
async def shadow_stop(request: ShadowStartRequest):
"""Arrêter le mode Shadow (sans détruire l'état).
La compréhension reste accessible via /api/v1/shadow/{id}/understanding
jusqu'à ce que /api/v1/shadow/build soit appelé ou la session finalisée.
"""
observer = _get_shadow_observer()
observer.stop(request.session_id)
understanding = observer.get_understanding(request.session_id)
return {
"status": "shadow_stopped",
"session_id": request.session_id,
"steps_count": len(understanding),
"understanding": understanding,
}
@app.post("/api/v1/shadow/feedback")
async def shadow_feedback(request: ShadowFeedbackRequest):
"""Recevoir un feedback utilisateur pendant ou après l'enregistrement.
body : {session_id, action, step_index?, new_intent?, at_event_index?}
"""
observer = _get_shadow_observer()
if not observer.has_session(request.session_id):
raise HTTPException(
status_code=404,
detail=f"Aucune session Shadow active pour {request.session_id}",
)
validator = _get_shadow_validator(request.session_id)
# Recharger les étapes courantes depuis l'observer
validator.set_steps(observer.get_steps_internal(request.session_id))
feedback_dict: Dict[str, Any] = {"action": request.action}
if request.step_index is not None:
feedback_dict["step_index"] = request.step_index
if request.new_intent is not None:
feedback_dict["new_intent"] = request.new_intent
if request.at_event_index is not None:
feedback_dict["at_event_index"] = request.at_event_index
result = validator.apply_feedback(feedback_dict)
return {
"status": "feedback_applied" if result.ok else "feedback_rejected",
"session_id": request.session_id,
"result": result.to_dict(),
}
@app.get("/api/v1/shadow/{session_id}/understanding")
async def shadow_get_understanding(session_id: str, since_ts: float = 0.0):
"""Récupérer ce que Léa a compris jusqu'ici.
Returns:
{
"session_id": ...,
"steps": [
{"step": 1, "intent": "...", "confidence": 0.9, ...},
...
],
"current_step": {...} | None,
"notifications": [...] # Seulement celles depuis since_ts
}
"""
observer = _get_shadow_observer()
if not observer.has_session(session_id):
raise HTTPException(
status_code=404,
detail=f"Aucune session Shadow active pour {session_id}",
)
return {
"session_id": session_id,
"steps": observer.get_understanding(session_id, include_current=False),
"current_step": observer.get_current_step(session_id),
"notifications": observer.get_notifications(session_id, since_ts=since_ts),
}
@app.post("/api/v1/shadow/build")
async def shadow_build(request: ShadowBuildRequest):
"""Construire le WorkflowIR final à partir des étapes validées/corrigées.
Le WorkflowIR est retourné mais pas encore sauvegardé — c'est au
caller de décider s'il l'écrit sur disque ou le compile en
ExecutionPlan.
"""
observer = _get_shadow_observer()
if not observer.has_session(request.session_id):
raise HTTPException(
status_code=404,
detail=f"Aucune session Shadow active pour {request.session_id}",
)
validator = _get_shadow_validator(request.session_id)
# S'assurer que le validator voit les étapes finales de l'observer
validator.set_steps(observer.get_steps_internal(request.session_id))
# Réappliquer l'historique n'est PAS nécessaire : on s'attend à ce que
# les feedbacks aient été appliqués dans l'ordre via /api/v1/shadow/feedback
# et que le validator ait accumulé son état. Mais puisqu'on vient de
# recharger les étapes, on perd les corrections. Stratégie : conserver
# l'historique et le rejouer.
history = validator.history
validator.set_steps(observer.get_steps_internal(request.session_id))
for entry in history:
# Rejouer en reconstruisant un feedback depuis le résultat
data = entry.data or {}
fb: Dict[str, Any] = {"action": entry.action, "step_index": entry.step_index}
if "new_intent" in data:
fb["new_intent"] = data["new_intent"]
validator.apply_feedback(fb)
try:
ir = validator.build_workflow_ir(
session_id=request.session_id,
name=request.name,
domain=request.domain,
require_all_validated=request.require_all_validated,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if ir is None:
return {
"status": "cancelled",
"session_id": request.session_id,
"message": "Workflow annulé par l'utilisateur",
}
return {
"status": "workflow_built",
"session_id": request.session_id,
"workflow_ir": ir.to_dict(),
}
# =========================================================================
# Monitoring
# =========================================================================
@@ -3737,6 +3985,160 @@ def _extract_session_description(events_file) -> Dict[str, Any]:
return {"name": "?", "description": "", "event_count": 0}
# =========================================================================
# Chat conversationnel (Léa conversationnelle)
# =========================================================================
from .chat_interface import ChatManager # noqa: E402
def _chat_replay_callback(session_id="", machine_id="default", params=None, **kwargs):
"""Callback utilisé par ChatSession pour lancer un replay.
Appelle l'endpoint /replay-session en interne. On passe par HTTP pour
réutiliser la logique d'auth/rate-limit/enqueue existante.
"""
import requests as _req
if not session_id:
raise ValueError("session_id requis pour replay chat")
resp = _req.post(
f"http://localhost:5005/api/v1/traces/stream/replay-session"
f"?session_id={session_id}&machine_id={machine_id}",
headers={"Authorization": f"Bearer {API_TOKEN}"},
timeout=600,
)
if not resp.ok:
raise RuntimeError(f"Replay échoué: {resp.text[:200]}")
return resp.json().get("replay_id", "")
def _chat_status_provider(replay_id: str) -> Dict[str, Any]:
"""Callback pour lire l'état d'un replay depuis ChatSession.
Lit directement _replay_states en mémoire (pas de HTTP round-trip).
"""
if not replay_id:
return {}
with _replay_lock:
state = _replay_states.get(replay_id)
if not state:
return {}
# Filtrer les clés internes
return {k: v for k, v in state.items() if not k.startswith("_")}
_chat_manager = ChatManager(
task_planner=_task_planner,
workflows_provider=_list_available_workflows,
replay_callback=_chat_replay_callback,
status_provider=_chat_status_provider,
)
class ChatMessageRequest(BaseModel):
"""Message envoyé par l'utilisateur."""
message: str
class ChatConfirmRequest(BaseModel):
"""Confirmation (ou refus) d'un plan en attente."""
confirmed: bool = True
class ChatSessionCreateRequest(BaseModel):
"""Paramètres de création d'une session de chat."""
machine_id: str = "default"
@app.post("/api/v1/chat/session")
async def create_chat_session(request: ChatSessionCreateRequest = None):
"""Créer une nouvelle session de chat avec Léa."""
machine_id = request.machine_id if request else "default"
session = _chat_manager.create_session(machine_id=machine_id)
return {
"ok": True,
"session_id": session.session_id,
"state": session.state,
"history": session.get_history(),
}
@app.post("/api/v1/chat/{session_id}/message")
async def post_chat_message(session_id: str, request: ChatMessageRequest):
"""Envoyer un message dans une session de chat."""
import asyncio
session = _chat_manager.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Session chat '{session_id}' non trouvée")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: session.send_message(request.message),
)
# Toujours retourner l'historique + l'état courant pour que le client se mette à jour
return {
**result,
"session_id": session_id,
"state": session.state,
"history": session.get_history(),
}
@app.get("/api/v1/chat/{session_id}/history")
async def get_chat_history(session_id: str):
"""Récupérer l'historique d'une session de chat."""
session = _chat_manager.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Session chat '{session_id}' non trouvée")
# Rafraîchir la progression si en cours d'exécution
if session.state == "executing":
try:
session.refresh_progress()
except Exception as e:
logger.debug(f"chat refresh_progress erreur: {e}")
return {
"ok": True,
"session_id": session_id,
"snapshot": session.get_snapshot(),
}
@app.post("/api/v1/chat/{session_id}/confirm")
async def confirm_chat_plan(session_id: str, request: ChatConfirmRequest = None):
"""Confirmer (ou refuser) l'exécution du plan en attente."""
import asyncio
session = _chat_manager.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Session chat '{session_id}' non trouvée")
confirmed = request.confirmed if request else True
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: session.confirm(confirmed=confirmed),
)
return {
**result,
"session_id": session_id,
"state": session.state,
"history": session.get_history(),
}
@app.get("/api/v1/chat/sessions")
async def list_chat_sessions():
"""Lister toutes les sessions de chat actives."""
return {
"ok": True,
"sessions": _chat_manager.list_sessions(),
}
if __name__ == "__main__":
import uvicorn