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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user