1193 lines
45 KiB
Python
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
|