Files
rpa_vision_v3/agent_chat/handlers/learn_action.py

1193 lines
45 KiB
Python

"""
LearnActionOrchestrator — pilote du dialogue d'apprentissage Léa-first.
Orchestre la machine d'état 8 phases (IDLE → LISTENING → WAITING_USER_STOP →
ANALYZING → PRESENTING → ITERATING_FEEDBACK → NAMING → PERSISTING → DONE /
ABORTED) en s'adossant au cycle Shadow exposé par le streaming server
(`agent_v0/server_v1/api_stream.py`) sur le port 5005, et au endpoint
`/api/v1/lea/competences/candidate/persist` livré en parallèle.
Spec : `docs/POC/SPECS_AGENT_CHAT_LEARN_ACTION_2026-06-01.md`.
Périmètre :
- Aucune dépendance importée à `agent_chat.app` (évite la dépendance circulaire).
- Appels HTTP via httpx sync (déjà disponible dans .venv).
- Persistance par session dans `agent_chat/state/<session_id>.json`.
- Émission d'événements socket.io via callback `emit` injecté.
- Intent recognition hybride : regex → fallback Ollama `qwen2.5:0.5b`.
Auteur : Claude — 2026-06-01 (P1-LEA SHADOW)
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
import unicodedata
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ============================================================
# Enums + dataclasses
# ============================================================
class LearnState(str, Enum):
"""Machine d'état de l'orchestrateur d'apprentissage."""
IDLE = "idle"
LISTENING = "listening"
WAITING_USER_STOP = "waiting_user_stop"
ANALYZING = "analyzing"
PRESENTING = "presenting"
ITERATING_FEEDBACK = "iterating_feedback"
NAMING = "naming"
PERSISTING = "persisting"
DONE = "done"
ABORTED = "aborted"
# Transitions autorisées (état_source -> {états_cibles}).
_ALLOWED_TRANSITIONS: Dict[LearnState, set] = {
LearnState.IDLE: {LearnState.LISTENING, LearnState.ABORTED},
LearnState.LISTENING: {LearnState.WAITING_USER_STOP, LearnState.ABORTED},
LearnState.WAITING_USER_STOP: {LearnState.ANALYZING, LearnState.ABORTED},
LearnState.ANALYZING: {LearnState.PRESENTING, LearnState.ABORTED},
LearnState.PRESENTING: {LearnState.ITERATING_FEEDBACK, LearnState.NAMING, LearnState.ABORTED},
LearnState.ITERATING_FEEDBACK: {LearnState.ITERATING_FEEDBACK, LearnState.NAMING, LearnState.ABORTED},
LearnState.NAMING: {LearnState.NAMING, LearnState.PERSISTING, LearnState.ABORTED},
LearnState.PERSISTING: {LearnState.DONE, LearnState.ABORTED},
LearnState.DONE: set(),
LearnState.ABORTED: set(),
}
class LearnIntent(str, Enum):
"""Intents reconnus pendant une session d'apprentissage."""
START_OBSERVE = "start_observe"
USER_STOP_OBSERVE = "user_stop_observe"
VALIDATE_STEP = "validate_step"
CORRECT_STEP = "correct_step"
UNDO_STEP = "undo_step"
MERGE_NEXT = "merge_next"
SPLIT_STEP = "split_step"
NAME_COMPETENCE = "name_competence"
MARK_PARAMETER = "mark_parameter"
PERSIST = "persist"
CANCEL = "cancel"
CONFIRM = "confirm"
DENY = "deny"
UNKNOWN = "unknown"
@dataclass
class ParsedLearnIntent:
"""Sortie du parser d'intents d'apprentissage."""
intent: LearnIntent
confidence: float
step_index: Optional[int] = None
raw_text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SessionState:
"""État sérialisable d'une session d'apprentissage."""
session_id: str
user_id: Optional[str] = None
machine_id: Optional[str] = None # requis par /api/v1/lea/competences/candidate/persist
trigger_source: str = "button" # button | magic_phrase | proactive | windows_button
state: LearnState = LearnState.IDLE
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
last_transition_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
shadow_understanding: List[Dict[str, Any]] = field(default_factory=list)
pending_feedbacks: List[Dict[str, Any]] = field(default_factory=list)
correction_counters: Dict[str, int] = field(default_factory=dict)
competence_name: Optional[str] = None
parameters_marked: List[Dict[str, Any]] = field(default_factory=list)
abort_reason: Optional[str] = None
last_message_at: Optional[str] = None
last_recent_feedbacks: List[Dict[str, Any]] = field(default_factory=list) # pour détection boucle doute
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["state"] = self.state.value if isinstance(self.state, LearnState) else self.state
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionState":
# Reconstruire LearnState
state_val = data.get("state", "idle")
try:
state = LearnState(state_val)
except ValueError:
state = LearnState.IDLE
kwargs = {k: v for k, v in data.items() if k != "state"}
return cls(state=state, **kwargs)
# ============================================================
# StateStore — persistance JSON atomique
# ============================================================
class StateStore:
"""Persistance JSON par session — écriture atomique tmp + os.replace."""
def __init__(self, state_dir: Path):
self.state_dir = Path(state_dir)
self.state_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
def _path(self, session_id: str) -> Path:
# Sanitize session_id pour éviter directory traversal.
safe = re.sub(r"[^A-Za-z0-9_\-]", "_", session_id)[:64]
return self.state_dir / f"{safe}.json"
def save(self, state: SessionState) -> None:
with self._lock:
path = self._path(state.session_id)
tmp = path.with_suffix(".json.tmp")
data = state.to_dict()
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def load(self, session_id: str) -> Optional[SessionState]:
with self._lock:
path = self._path(session_id)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return SessionState.from_dict(data)
except Exception:
logger.exception("StateStore.load failed for %s", session_id)
return None
def delete(self, session_id: str) -> None:
with self._lock:
path = self._path(session_id)
try:
path.unlink()
except FileNotFoundError:
pass
def list_active(self) -> List[SessionState]:
out: List[SessionState] = []
for p in self.state_dir.glob("*.json"):
try:
data = json.loads(p.read_text(encoding="utf-8"))
st = SessionState.from_dict(data)
if st.state not in (LearnState.DONE, LearnState.ABORTED):
out.append(st)
except Exception:
continue
return out
# ============================================================
# StreamingClient — wrapper httpx vers streaming server (5005)
# ============================================================
class StreamingClient:
"""Client HTTP sync vers le streaming server.
Timeout 5s par appel, retry x2 sur erreur connexion. Auth Bearer via
`RPA_API_TOKEN` env var.
"""
DEFAULT_TIMEOUT = 5.0
DEFAULT_RETRIES = 2
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
retries: int = DEFAULT_RETRIES,
http_client: Any = None, # pour tests, injection httpx.Client mock
):
self.base_url = (
base_url
or os.environ.get("RPA_STREAMING_URL", "http://localhost:5005")
).rstrip("/")
self.token = token if token is not None else os.environ.get("RPA_API_TOKEN", "")
self.timeout = timeout
self.retries = retries
self._http = http_client # si None on import httpx à la volée
def _headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
"""Wrapper avec retry x2 sur ConnectError / TimeoutException."""
url = f"{self.base_url}{path}"
kwargs.setdefault("timeout", self.timeout)
kwargs.setdefault("headers", {}).update(self._headers())
last_exc: Optional[Exception] = None
for attempt in range(self.retries + 1):
try:
if self._http is not None:
resp = self._http.request(method, url, **kwargs)
else:
import httpx # import paresseux
with httpx.Client() as client:
resp = client.request(method, url, **kwargs)
# Levée sur 5xx, mais on lit la réponse aussi sur 4xx pour le caller.
if resp.status_code >= 500:
raise RuntimeError(
f"streaming {method} {path} -> HTTP {resp.status_code}: "
f"{resp.text[:200]}"
)
try:
return resp.json()
except Exception:
return {"status_code": resp.status_code, "text": resp.text}
except Exception as exc:
last_exc = exc
logger.warning(
"StreamingClient %s %s attempt %d/%d failed: %s",
method, path, attempt + 1, self.retries + 1, exc,
)
if attempt < self.retries:
time.sleep(0.25 * (attempt + 1))
else:
break
raise RuntimeError(f"streaming {method} {path} unreachable: {last_exc}")
# ---- API Shadow ----
def shadow_start(self, session_id: str, **extra) -> Dict[str, Any]:
payload = {"session_id": session_id, **extra}
return self._request("POST", "/api/v1/shadow/start", json=payload)
def shadow_stop(self, session_id: str) -> Dict[str, Any]:
return self._request(
"POST", "/api/v1/shadow/stop", json={"session_id": session_id}
)
def shadow_understanding(self, session_id: str) -> Dict[str, Any]:
return self._request("GET", f"/api/v1/shadow/{session_id}/understanding")
def shadow_feedback(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/api/v1/shadow/feedback", json=payload)
def shadow_build(self, session_id: str) -> Dict[str, Any]:
return self._request(
"POST", "/api/v1/shadow/build", json={"session_id": session_id}
)
def competence_persist(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return self._request(
"POST",
"/api/v1/lea/competences/candidate/persist",
json=payload,
)
# ============================================================
# Intent parser (regex hybride + fallback Ollama)
# ============================================================
def _strip_accents(text: str) -> str:
nf = unicodedata.normalize("NFKD", text)
return "".join(c for c in nf if not unicodedata.combining(c))
class LearnIntentParser:
"""Parser hybride d'intents d'apprentissage.
Approche : regex déterministe sur ~80 % des cas, fallback Ollama
`qwen2.5:0.5b` au-delà. Sans Ollama, retombe gracieusement sur UNKNOWN.
"""
# Regex magiques (texte sans accents, lowercase)
_RE_START = re.compile(
r"\b(apprends?[ -]?moi|montre[ -]?moi|regarde[ -]?moi faire|"
r"observe|enregistre|on apprend|tu vas apprendre|"
r"lea apprends?|lea regarde)\b"
)
_RE_STOP = re.compile(
r"\b(stop|arrete|c[' ]?est bon|c[' ]?est fini|j[' ]?ai fini|"
r"voila c[' ]?est tout|fin|termine|fini)\b"
)
_RE_VALIDATE = re.compile(
r"\b(ok|oui|c[' ]?est ca|exact|parfait|valide?e?|bon|tout est bon|"
r"c[' ]?est correct|c[' ]?est juste|impeccable)\b"
)
_RE_DENY = re.compile(r"\b(non|pas du tout|negatif|nan)\b")
_RE_CANCEL = re.compile(
r"\b(annule|annuler|abandonne|laisse tomber|jette|oublie|cancel)\b"
)
_RE_STEP_NUM = re.compile(
r"\b(?:etape|numero|step|ligne|le|la|l[' ])?\s*([1-9]\d?)\b"
)
_RE_CORRECT = re.compile(
r"\b(corrige|change|modifie|en fait|plutot|au lieu)\b"
)
_RE_UNDO = re.compile(r"\b(retire|enleve|supprime|undo|annule l[' ]?etape)\b")
_RE_MERGE = re.compile(r"\b(fusionne|merge|regroupe|colle)\b")
_RE_SPLIT = re.compile(r"\b(coupe|split|separe|divise)\b")
_RE_PARAM_VAR = re.compile(
r"\b(ca change|c[' ]?est l[' ]?exemple|exemple|variable|parametre|"
r"a chaque fois different)\b"
)
_RE_PARAM_CONST = re.compile(
r"\b(toujours|constante|fixe|ne change pas|toujours pareil)\b"
)
def __init__(
self,
use_llm_fallback: bool = True,
llm_model: str = "qwen2.5:0.5b",
ollama_url: Optional[str] = None,
confidence_threshold: float = 0.7,
):
self.use_llm_fallback = use_llm_fallback
self.llm_model = llm_model
self.ollama_url = (
ollama_url
or os.environ.get("OLLAMA_URL", "http://localhost:11434")
).rstrip("/")
self.confidence_threshold = confidence_threshold
self._llm_disabled = False # bascule définitive si Ollama down
# --- API publique ---
def parse(
self,
message: str,
current_state: LearnState = LearnState.IDLE,
) -> ParsedLearnIntent:
"""Reconnaît l'intent à partir du message utilisateur + état courant."""
if not message or not message.strip():
return ParsedLearnIntent(LearnIntent.UNKNOWN, 0.0, raw_text=message)
raw = message.strip()
norm = _strip_accents(raw.lower())
regex_result = self._parse_regex(norm, current_state)
if regex_result.confidence >= 0.9:
regex_result.raw_text = raw
return regex_result
# Fallback LLM si activé + dispo
if self.use_llm_fallback and not self._llm_disabled:
llm_result = self._parse_llm(raw, current_state)
if llm_result is not None:
llm_result.raw_text = raw
if llm_result.confidence >= self.confidence_threshold:
return llm_result
# Sinon on retourne le meilleur effort regex (même faible) ou UNKNOWN
regex_result.raw_text = raw
return regex_result
# --- Regex ---
def _extract_step_index(self, norm: str) -> Optional[int]:
m = self._RE_STEP_NUM.search(norm)
if m:
try:
return int(m.group(1))
except (TypeError, ValueError):
return None
return None
def _parse_regex(self, norm: str, state: LearnState) -> ParsedLearnIntent:
step_idx = self._extract_step_index(norm)
# Annulation
if self._RE_CANCEL.search(norm):
return ParsedLearnIntent(LearnIntent.CANCEL, 0.95)
# Démarrage observation (uniquement quand IDLE)
if state == LearnState.IDLE and self._RE_START.search(norm):
return ParsedLearnIntent(LearnIntent.START_OBSERVE, 0.95)
# Stop observation (uniquement quand on observe)
if state in (LearnState.LISTENING, LearnState.WAITING_USER_STOP):
if self._RE_STOP.search(norm):
return ParsedLearnIntent(LearnIntent.USER_STOP_OBSERVE, 0.95)
# Pendant itération feedback
if state in (LearnState.PRESENTING, LearnState.ITERATING_FEEDBACK):
if self._RE_UNDO.search(norm):
return ParsedLearnIntent(
LearnIntent.UNDO_STEP, 0.92, step_index=step_idx
)
if self._RE_MERGE.search(norm):
return ParsedLearnIntent(
LearnIntent.MERGE_NEXT, 0.92, step_index=step_idx
)
if self._RE_SPLIT.search(norm):
return ParsedLearnIntent(
LearnIntent.SPLIT_STEP, 0.92, step_index=step_idx
)
if self._RE_CORRECT.search(norm):
# Extraire la nouvelle intent : reste de phrase après le verbe correctif
new_intent_text = self._extract_correction_payload(norm)
return ParsedLearnIntent(
LearnIntent.CORRECT_STEP,
0.9,
step_index=step_idx,
extra={"new_intent": new_intent_text},
)
if self._RE_VALIDATE.search(norm) and not self._RE_DENY.search(norm):
return ParsedLearnIntent(
LearnIntent.VALIDATE_STEP,
0.92,
step_index=step_idx,
)
if self._RE_DENY.search(norm):
return ParsedLearnIntent(LearnIntent.DENY, 0.9)
# Pendant nomination
if state == LearnState.NAMING:
if self._RE_PARAM_VAR.search(norm):
return ParsedLearnIntent(
LearnIntent.MARK_PARAMETER,
0.9,
extra={"is_parameter": True},
)
if self._RE_PARAM_CONST.search(norm):
return ParsedLearnIntent(
LearnIntent.MARK_PARAMETER,
0.9,
extra={"is_parameter": False},
)
if self._RE_VALIDATE.search(norm):
return ParsedLearnIntent(LearnIntent.CONFIRM, 0.9)
# Sinon le message est probablement un nom de compétence.
if 1 <= len(norm) <= 80:
return ParsedLearnIntent(
LearnIntent.NAME_COMPETENCE,
0.85,
extra={"name": norm.strip()},
)
# Acceptation / refus génériques
if self._RE_VALIDATE.search(norm):
return ParsedLearnIntent(LearnIntent.CONFIRM, 0.85)
if self._RE_DENY.search(norm):
return ParsedLearnIntent(LearnIntent.DENY, 0.85)
return ParsedLearnIntent(LearnIntent.UNKNOWN, 0.3)
def _extract_correction_payload(self, norm: str) -> str:
"""Extrait le contenu après un verbe correctif ("corrige", "plutot", ...)."""
m = re.search(
r"\b(?:corrige|change|modifie|en fait|plutot|au lieu)\b[: ]+(.+)$",
norm,
)
if m:
return m.group(1).strip()
return ""
# --- LLM fallback ---
def _parse_llm(
self, raw: str, state: LearnState
) -> Optional[ParsedLearnIntent]:
"""Fallback Ollama `qwen2.5:0.5b`. Retourne None si Ollama down."""
try:
import httpx
allowed = [i.value for i in LearnIntent if i != LearnIntent.UNKNOWN]
prompt = (
"Tu es un classifieur d'intents pour un assistant RPA. "
f"Etat courant : {state.value}. "
f"Message utilisateur : {raw!r}. "
"Choisis UN intent parmi cette liste : "
f"{allowed}. Réponds en JSON strict "
'{"intent": "...", "confidence": 0.0-1.0, "step_index": null|int, '
'"new_intent": null|str}.'
)
with httpx.Client(timeout=4.0) as client:
resp = client.post(
f"{self.ollama_url}/api/generate",
json={
"model": self.llm_model,
"prompt": prompt,
"format": "json",
"stream": False,
},
)
if resp.status_code != 200:
logger.warning("Ollama %s -> HTTP %s", self.llm_model, resp.status_code)
return None
data = resp.json().get("response", "")
try:
parsed = json.loads(data)
except Exception:
return None
intent_str = parsed.get("intent", "unknown")
try:
intent_e = LearnIntent(intent_str)
except ValueError:
intent_e = LearnIntent.UNKNOWN
return ParsedLearnIntent(
intent=intent_e,
confidence=float(parsed.get("confidence") or 0.0),
step_index=parsed.get("step_index"),
extra={"new_intent": parsed.get("new_intent")},
)
except Exception as exc:
logger.warning("Ollama fallback unavailable, going regex-only: %s", exc)
self._llm_disabled = True
return None
# ============================================================
# OptionCFormatter — restitution texte naturel + libellés OCR
# ============================================================
class OptionCFormatter:
"""Transforme `understanding[]` en texte naturel français."""
# Mapping action_type → verbe passé composé
_VERB_MAP: Dict[str, str] = {
"click": "cliqué",
"double_click": "double-cliqué",
"right_click": "clic droit",
"type": "saisi",
"type_text": "saisi",
"input": "saisi",
"open": "ouverte",
"open_window": "ouverte",
"close": "fermée",
"validate": "validé",
"submit": "validé",
"focus": "ouvert",
"select": "sélectionné",
"scroll": "scrollé",
"key_press": "appuyé",
"shortcut": "raccourci utilisé",
}
LOW_CONF_SUFFIX = " (à confirmer)"
def format(self, understanding: List[Dict[str, Any]]) -> str:
"""Retourne le texte multi-ligne 'Option C'."""
if not understanding:
return "(aucune étape comprise)"
lines: List[str] = []
for idx, step in enumerate(understanding, start=1):
lines.append(self._format_step(idx, step))
return "\n".join(lines)
def _format_step(self, idx: int, step: Dict[str, Any]) -> str:
action_type = (
step.get("action_type")
or step.get("intent")
or step.get("type")
or ""
)
action_type_norm = str(action_type).lower()
verbe = self._VERB_MAP.get(action_type_norm, "effectuée")
# Récupérer label OCR (priorité target_label > target > label)
label = (
step.get("target_label")
or step.get("target")
or step.get("label")
or step.get("element_text")
or ""
)
widget = step.get("widget_type") or step.get("element_type") or ""
# Valeur saisie le cas échéant
value = step.get("value") or step.get("typed_text") or step.get("text") or ""
# Confidence OCR
confidence_ocr = step.get("confidence_ocr")
if confidence_ocr is None:
confidence_ocr = step.get("ocr_confidence")
try:
low_conf = (
confidence_ocr is not None and float(confidence_ocr) < 0.6
)
except (TypeError, ValueError):
low_conf = False
# Construire la phrase
widget_label = widget.capitalize() if widget else "Élément"
if label:
base = f"{idx}. {widget_label} « {label} »"
else:
base = f"{idx}. {widget_label}"
if value:
line = f"{base}{verbe} : « {value} »"
else:
line = f"{base}{verbe}"
if low_conf:
line += self.LOW_CONF_SUFFIX
return line
def closing_question(self) -> str:
return "C'est bien ça ou je me suis trompée quelque part ?"
# ============================================================
# PersistPayloadBuilder
# ============================================================
class PersistPayloadBuilder:
"""Construit le payload de persistance compétence."""
def build(
self,
session_state: SessionState,
) -> Dict[str, Any]:
parameters = []
for p in session_state.parameters_marked:
if p.get("is_parameter"):
parameters.append(
{
"step_index": p.get("step_index"),
"name": p.get("name") or f"param_{p.get('step_index')}",
"example_value": p.get("example_value"),
"field_label": p.get("field_label"),
}
)
return {
"session_id": session_state.session_id,
"machine_id": session_state.machine_id,
"name": session_state.competence_name or "",
"parameters": parameters,
"trigger_source": session_state.trigger_source,
"user_id": session_state.user_id,
}
# ============================================================
# Orchestrator
# ============================================================
EmitFn = Callable[[str, Dict[str, Any]], None]
class LearnActionOrchestrator:
"""Pilote la conversation Léa-first d'apprentissage d'une compétence.
Le module ne touche jamais directement à `app.py`. Il expose une API
Python (`start_session`, `handle_chat_message`, `handle_proactive_signal`,
`resume_sessions`) qu'`app.py` appelle quand l'état n'est pas IDLE.
"""
MAX_CORRECTIONS_PER_STEP = 3
LOOP_DOUBT_WINDOW = 4 # garde les 4 derniers feedbacks pour détection oscillation
def __init__(
self,
streaming_client: Optional[StreamingClient] = None,
intent_parser: Optional[LearnIntentParser] = None,
formatter: Optional[OptionCFormatter] = None,
state_store: Optional[StateStore] = None,
emit: Optional[EmitFn] = None,
state_dir: Optional[Path] = None,
):
self.streaming = streaming_client or StreamingClient()
self.parser = intent_parser or LearnIntentParser()
self.formatter = formatter or OptionCFormatter()
if state_store is not None:
self.store = state_store
else:
default_dir = (
state_dir
if state_dir is not None
else Path(__file__).resolve().parent.parent / "state"
)
self.store = StateStore(default_dir)
self.emit: EmitFn = emit or (lambda evt, payload: None)
self.payload_builder = PersistPayloadBuilder()
# Sessions en mémoire (clé : session_id).
self._sessions: Dict[str, SessionState] = {}
self._lock = threading.RLock()
# --- API publique ---
def start_session(
self,
user_id: Optional[str] = None,
trigger_source: str = "button",
session_id: Optional[str] = None,
machine_id: Optional[str] = None,
) -> Tuple[SessionState, str]:
"""Démarre une session d'apprentissage. Retourne (state, reply_text).
`machine_id` est requis pour pouvoir persister la compétence en fin de
cycle (cf. /api/v1/lea/competences/candidate/persist). Stocké dans
`state.machine_id` et propagé au payload persist via
`PersistPayloadBuilder`.
"""
sid = session_id or f"learn_{uuid.uuid4().hex[:12]}"
st = SessionState(
session_id=sid,
user_id=user_id,
machine_id=machine_id,
trigger_source=trigger_source,
state=LearnState.IDLE,
)
with self._lock:
self._sessions[sid] = st
# Appel shadow_start + transition LISTENING
try:
self.streaming.shadow_start(sid, user_id=user_id)
except Exception as exc:
logger.error("shadow_start failed: %s", exc)
self._transition(st, LearnState.ABORTED, abort_reason=f"shadow_start_failed: {exc}")
return st, (
"Je n'arrive pas à démarrer l'observation côté Windows. "
"On réessaie dans un instant ?"
)
self._transition(st, LearnState.LISTENING)
# Le streaming server crée WAITING_USER_STOP côté chat dès qu'il est OK
self._transition(st, LearnState.WAITING_USER_STOP)
return st, (
"Je te regarde. Fais ce que tu veux m'apprendre, et dis-moi "
"« stop » ou « j'ai fini » quand c'est terminé."
)
def handle_chat_message(
self, session_id: str, message: str
) -> Optional[str]:
"""Traite un message utilisateur dans une session active.
Retourne le texte de réponse Léa, ou None si l'orchestrateur ne gère
pas le message (à charge de l'appelant de router vers le flux normal).
"""
with self._lock:
st = self._sessions.get(session_id) or self.store.load(session_id)
if st is None or st.state in (LearnState.IDLE, LearnState.DONE, LearnState.ABORTED):
return None
self._sessions[session_id] = st
st.last_message_at = datetime.now(timezone.utc).isoformat()
parsed = self.parser.parse(message, current_state=st.state)
logger.info(
"LearnOrchestrator [%s] state=%s intent=%s conf=%.2f",
session_id, st.state.value, parsed.intent.value, parsed.confidence,
)
if parsed.intent == LearnIntent.CANCEL:
return self._handle_cancel(st, reason="user_cancel")
try:
if st.state == LearnState.WAITING_USER_STOP:
return self._handle_waiting_stop(st, parsed)
if st.state in (LearnState.PRESENTING, LearnState.ITERATING_FEEDBACK):
return self._handle_iterating(st, parsed)
if st.state == LearnState.NAMING:
return self._handle_naming(st, parsed)
except Exception as exc:
logger.exception("Orchestrator exception in state %s", st.state)
return (
f"Désolée, j'ai eu un souci ({exc}). On garde tout pour reprendre."
)
return "Je n'ai pas bien compris. Tu peux reformuler ?"
def handle_proactive_signal(
self,
signal_type: str,
payload: Dict[str, Any],
) -> Optional[str]:
"""Hook proactif (`screen_static`, `action_repeat`, `retry_threshold`)."""
# Garde-fou cooldown global (5 min)
now = time.time()
last = getattr(self, "_last_proactive_ts", 0.0)
if now - last < 300:
return None
self._last_proactive_ts = now
if signal_type == "action_repeat":
return (
"J'ai remarqué que tu fais souvent la même séquence. "
"Tu veux m'apprendre à la faire pour toi ?"
)
if signal_type == "retry_threshold":
step = payload.get("step_index", "?")
return (
f"Je n'arrive pas à reproduire l'étape n°{step}. "
"Tu peux me re-montrer ?"
)
return None
def resume_sessions(self) -> List[str]:
"""Au démarrage : tente de reprendre les sessions non finalisées."""
resumed: List[str] = []
for st in self.store.list_active():
self._sessions[st.session_id] = st
resumed.append(st.session_id)
logger.info(
"Resumed session %s in state %s", st.session_id, st.state.value
)
return resumed
# --- Handlers par état ---
def _handle_waiting_stop(
self, st: SessionState, parsed: ParsedLearnIntent
) -> str:
if parsed.intent != LearnIntent.USER_STOP_OBSERVE:
return (
"Je continue à observer. Dis-moi « stop » quand tu auras fini."
)
# Transition vers ANALYZING
self._transition(st, LearnState.ANALYZING)
try:
self.streaming.shadow_stop(st.session_id)
understanding_resp = self.streaming.shadow_understanding(st.session_id)
except Exception as exc:
logger.error("shadow_stop/understanding failed: %s", exc)
return (
"Je n'arrive pas à clôturer l'observation côté Windows. "
"On réessaie ?"
)
understanding = understanding_resp.get("understanding") or understanding_resp.get("steps") or []
st.shadow_understanding = understanding
self.store.save(st)
# Restitution Option C
self._transition(st, LearnState.PRESENTING)
text = self.formatter.format(understanding)
question = self.formatter.closing_question()
self._transition(st, LearnState.ITERATING_FEEDBACK)
return f"Voilà ce que j'ai compris :\n\n{text}\n\n{question}"
def _handle_iterating(
self, st: SessionState, parsed: ParsedLearnIntent
) -> str:
# Si l'utilisateur valide globalement → passage NAMING
if parsed.intent == LearnIntent.VALIDATE_STEP and parsed.step_index is None:
return self._enter_naming(st)
if parsed.intent == LearnIntent.CONFIRM:
return self._enter_naming(st)
step_idx = parsed.step_index
# Suivi compteur correction
if parsed.intent in (
LearnIntent.CORRECT_STEP,
LearnIntent.UNDO_STEP,
LearnIntent.MERGE_NEXT,
LearnIntent.SPLIT_STEP,
):
if step_idx is None:
return (
"Quelle étape je dois corriger ? Dis-moi le numéro "
"(ex : « étape 3 »)."
)
key = str(step_idx)
st.correction_counters[key] = st.correction_counters.get(key, 0) + 1
# Sortie d'urgence si > 3 corrections
if st.correction_counters[key] > self.MAX_CORRECTIONS_PER_STEP:
return self._handle_emergency_exit(st, step_idx)
# Détection boucle correct/undo
st.last_recent_feedbacks.append(
{"step": step_idx, "intent": parsed.intent.value}
)
st.last_recent_feedbacks = st.last_recent_feedbacks[-self.LOOP_DOUBT_WINDOW:]
if self._detect_doubt_loop(st.last_recent_feedbacks, step_idx):
self.store.save(st)
return (
f"On tourne en rond sur l'étape n°{step_idx}. Tu veux "
"qu'on relance l'enregistrement de cette étape seulement ?"
)
# Construire payload feedback
action_map = {
LearnIntent.CORRECT_STEP: "correct",
LearnIntent.UNDO_STEP: "undo",
LearnIntent.MERGE_NEXT: "merge_next",
LearnIntent.SPLIT_STEP: "split",
LearnIntent.VALIDATE_STEP: "validate",
}
if parsed.intent not in action_map:
return "Je n'ai pas bien compris. Tu peux préciser l'étape ?"
payload: Dict[str, Any] = {
"session_id": st.session_id,
"action": action_map[parsed.intent],
"step_index": step_idx,
}
if parsed.intent == LearnIntent.CORRECT_STEP:
payload["new_intent"] = parsed.extra.get("new_intent") or ""
try:
self.streaming.shadow_feedback(payload)
new_understanding = self.streaming.shadow_understanding(st.session_id)
except Exception as exc:
logger.error("shadow_feedback failed: %s", exc)
return (
"Je n'arrive pas à appliquer ta correction côté Windows. "
"On réessaie ?"
)
st.shadow_understanding = (
new_understanding.get("understanding")
or new_understanding.get("steps")
or []
)
st.pending_feedbacks.append(payload)
self.store.save(st)
# Recap complet (pas de diff)
text = self.formatter.format(st.shadow_understanding)
return (
f"OK, j'ai mis à jour :\n\n{text}\n\n"
"C'est bon ou il reste à corriger ?"
)
def _handle_naming(
self, st: SessionState, parsed: ParsedLearnIntent
) -> str:
# 5.1 Nommage
if st.competence_name is None and parsed.intent == LearnIntent.NAME_COMPETENCE:
name = (parsed.extra.get("name") or parsed.raw_text or "").strip()
if not name or len(name) > 80:
return (
"Le nom doit faire entre 1 et 80 caractères. "
"Tu peux me redonner un nom plus court ?"
)
st.competence_name = name
self.store.save(st)
# 5.2 Parameters
return self._next_parameter_question(st) or self._persist_competence(st)
# 5.2 Marquage paramètre
if parsed.intent == LearnIntent.MARK_PARAMETER:
# Trouve la prochaine question paramètre en attente
pending = self._pending_param(st)
if pending is not None:
step_idx, value, field_label = pending
st.parameters_marked.append(
{
"step_index": step_idx,
"is_parameter": bool(parsed.extra.get("is_parameter")),
"example_value": value,
"field_label": field_label,
"name": self._slugify(field_label or f"param_{step_idx}"),
}
)
self.store.save(st)
nxt = self._next_parameter_question(st)
if nxt is not None:
return nxt
return self._persist_competence(st)
return self._persist_competence(st)
# Confirmation finale du résumé → persist
# Garde anti-CONFIRM prématuré : on refuse de persister sans nom valide.
# Risque sinon = persistance d'une compétence avec name="" / None
# (rejetée 4xx par /api/v1/lea/competences/candidate/persist ou polluant
# le store sémantique).
if parsed.intent == LearnIntent.CONFIRM:
if not st.competence_name or not str(st.competence_name).strip():
return (
"Tu n'as pas encore donné de nom à cette compétence. "
"Comment veux-tu l'appeler ?"
)
return self._persist_competence(st)
# Pas encore de nom → demander
if st.competence_name is None:
return (
"Comment on appelle cette tâche ? Tu peux la nommer simplement, "
"en français."
)
return self._next_parameter_question(st) or self._persist_competence(st)
# --- Transitions ---
def _transition(
self,
st: SessionState,
target: LearnState,
abort_reason: Optional[str] = None,
) -> None:
if target not in _ALLOWED_TRANSITIONS.get(st.state, set()):
logger.warning(
"Illegal transition %s -> %s on session %s",
st.state.value, target.value, st.session_id,
)
return
st.state = target
st.last_transition_at = datetime.now(timezone.utc).isoformat()
if abort_reason:
st.abort_reason = abort_reason
self.store.save(st)
try:
self.emit(
"lea:learn_state_changed",
{
"session_id": st.session_id,
"state": target.value,
"abort_reason": st.abort_reason,
},
)
except Exception:
logger.debug("emit failed", exc_info=True)
# --- Helpers ---
def _handle_cancel(self, st: SessionState, reason: str) -> str:
try:
self.streaming.shadow_stop(st.session_id)
except Exception:
pass
self._transition(st, LearnState.ABORTED, abort_reason=reason)
return "OK, j'annule. Je garde tout au cas où tu reviennes plus tard."
def _handle_emergency_exit(self, st: SessionState, step_idx: int) -> str:
self._transition(st, LearnState.ABORTED, abort_reason="too_many_corrections")
try:
self.streaming.shadow_stop(st.session_id)
except Exception:
pass
return (
f"Je n'arrive pas à comprendre l'étape n°{step_idx}. "
"Je préfère qu'on reprenne plus tard. Je garde tout."
)
def _detect_doubt_loop(
self, recent: List[Dict[str, Any]], step_idx: int
) -> bool:
"""Détecte alternance correct/undo sur même step (≥ 2 fois)."""
relevant = [f for f in recent if f.get("step") == step_idx]
if len(relevant) < 4:
return False
intents = [f["intent"] for f in relevant[-4:]]
seen = set(intents)
return "correct_step" in seen and "undo_step" in seen
def _enter_naming(self, st: SessionState) -> str:
self._transition(st, LearnState.NAMING)
return (
"Super. Comment on appelle cette tâche ? Tu peux la nommer "
"simplement, en français."
)
def _pending_param(
self, st: SessionState
) -> Optional[Tuple[int, str, str]]:
"""Retourne (step_index, valeur saisie, field_label) du prochain step
avec valeur non encore marqué."""
marked = {p["step_index"] for p in st.parameters_marked}
for idx, step in enumerate(st.shadow_understanding, start=1):
value = step.get("value") or step.get("typed_text") or step.get("text")
if value and idx not in marked:
field_label = (
step.get("target_label")
or step.get("target")
or step.get("label")
or ""
)
return idx, value, field_label
return None
def _next_parameter_question(self, st: SessionState) -> Optional[str]:
pending = self._pending_param(st)
if pending is None:
return None
_, value, label = pending
return (
f"La valeur « {value} » pour le champ « {label} » — c'est "
"l'exemple du jour ou ça doit toujours être ça ?"
)
def _slugify(self, text: str) -> str:
norm = _strip_accents(text or "").lower()
slug = re.sub(r"[^a-z0-9]+", "_", norm).strip("_")
return slug or "param"
def _persist_competence(self, st: SessionState) -> str:
# Pré-requis : pas de paramètres en attente.
if self._pending_param(st) is not None:
return self._next_parameter_question(st) or "(...)"
# Garde anti-persist sans machine_id (requis par
# /api/v1/lea/competences/candidate/persist — sinon 400). On préfère
# une erreur métier conversationnelle plutôt qu'une exception non gérée.
if not st.machine_id:
logger.error(
"persist refusé : machine_id manquant pour session %s",
st.session_id,
)
return (
"Je ne peux pas enregistrer la compétence : "
"je ne sais pas sur quelle machine elle a été apprise. "
"On reprend en redémarrant l'apprentissage depuis Windows ?"
)
# Garde anti-persist sans nom (cohérent avec la garde CONFIRM).
if not st.competence_name or not str(st.competence_name).strip():
logger.error(
"persist refusé : competence_name manquant pour session %s",
st.session_id,
)
return (
"Tu n'as pas encore donné de nom à cette compétence. "
"Comment veux-tu l'appeler ?"
)
# shadow_build avant /persist
try:
self.streaming.shadow_build(st.session_id)
except Exception as exc:
logger.error("shadow_build failed: %s", exc)
return (
"Je n'arrive pas à figer le workflow avant de l'enregistrer. "
"On réessaie ?"
)
self._transition(st, LearnState.PERSISTING)
payload = self.payload_builder.build(st)
try:
resp = self.streaming.competence_persist(payload)
except Exception as exc:
logger.error("competence_persist failed: %s", exc)
return (
"Je n'ai pas pu enregistrer la compétence pour l'instant. "
"Je garde tout, on pourra réessayer."
)
self._transition(st, LearnState.DONE)
slug = resp.get("slug") or self._slugify(st.competence_name or "")
names = ", ".join(p["name"] for p in payload["parameters"]) or "aucun"
return (
f"C'est enregistré sous « {st.competence_name} » "
f"(slug `{slug}`). Paramètres : {names}."
)
# ============================================================
# Singleton accessor
# ============================================================
_orchestrator_singleton: Optional[LearnActionOrchestrator] = None
_orchestrator_lock = threading.Lock()
def get_learn_action_orchestrator(
emit: Optional[EmitFn] = None,
force_new: bool = False,
) -> LearnActionOrchestrator:
"""Retourne le singleton orchestrateur (à appeler depuis `app.py`)."""
global _orchestrator_singleton
with _orchestrator_lock:
if _orchestrator_singleton is None or force_new:
_orchestrator_singleton = LearnActionOrchestrator(emit=emit)
elif emit is not None:
_orchestrator_singleton.emit = emit
return _orchestrator_singleton