feat(agent): add learn action flow and grounding guards
This commit is contained in:
@@ -9,6 +9,7 @@ Tourne dans son propre thread daemon pour ne pas bloquer pystray.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -121,7 +122,7 @@ def _tpl_done(payload: Dict[str, Any]) -> tuple:
|
||||
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
|
||||
action = payload.get("action") or {}
|
||||
desc = action.get("description") if isinstance(action, dict) else None
|
||||
title = desc or "Validation requise"
|
||||
title = desc or "J'attends ton accord avant de continuer"
|
||||
return ("?", ACTION_ICON_RUN, str(title))
|
||||
|
||||
|
||||
@@ -867,11 +868,19 @@ class ChatWindow:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("force-show chat_window silenced", exc_info=True)
|
||||
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
|
||||
# l'attention sur la question (clear visuel uniquement,
|
||||
# self._messages reste intact pour la traçabilité debug).
|
||||
self._clear_chat_history()
|
||||
self._render_paused_bubble(payload)
|
||||
try:
|
||||
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
|
||||
# l'attention sur la question (clear visuel uniquement,
|
||||
# self._messages reste intact pour la traçabilité debug).
|
||||
self._clear_chat_history()
|
||||
self._render_paused_bubble(payload)
|
||||
except Exception:
|
||||
logger.exception("render paused bubble failed; using fallback")
|
||||
try:
|
||||
self._clear_chat_history()
|
||||
self._render_paused_fallback_bubble(payload)
|
||||
except Exception:
|
||||
logger.debug("render paused fallback silenced", exc_info=True)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
@@ -895,7 +904,11 @@ class ChatWindow:
|
||||
logger.debug("clear chat history silenced", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _compute_paused_bubble_height(reason_str: str) -> tuple:
|
||||
def _compute_paused_bubble_height(
|
||||
reason_str: str,
|
||||
chars_per_line: int = 52,
|
||||
max_rows: int = 14,
|
||||
) -> tuple:
|
||||
"""Calcule la hauteur du Text (en lignes) + si une scrollbar est
|
||||
nécessaire pour le message d'une bulle paused.
|
||||
|
||||
@@ -910,11 +923,11 @@ class ChatWindow:
|
||||
if not reason_str:
|
||||
return 2, False
|
||||
text = str(reason_str)
|
||||
# Estimation : ~60 chars/ligne effectifs avec wraplength.
|
||||
wrapped_lines = (len(text) // 60) + 1
|
||||
explicit_lines = text.count("\n") + 1
|
||||
estimated = max(wrapped_lines, explicit_lines)
|
||||
cap = 12
|
||||
chars_per_line = max(24, int(chars_per_line or 52))
|
||||
estimated = 0
|
||||
for raw_line in text.splitlines() or [""]:
|
||||
estimated += max(1, math.ceil(len(raw_line) / chars_per_line))
|
||||
cap = max(2, int(max_rows or 14))
|
||||
height = max(2, min(cap, estimated))
|
||||
# Scrollbar dès que le cap est atteint OU contenu long (filet
|
||||
# textuel : ≥ 200 chars implique souvent un débordement visuel
|
||||
@@ -922,6 +935,46 @@ class ChatWindow:
|
||||
needs_scroll = (estimated >= cap) or (len(text) > 200)
|
||||
return height, needs_scroll
|
||||
|
||||
def _paused_text_layout(self) -> tuple:
|
||||
"""Retourne ``(wrap_px, chars_per_line, max_rows)`` pour la bulle pause.
|
||||
|
||||
La fenêtre Léa est souvent redimensionnée à ~380px de large sur le
|
||||
poste Windows. Les anciennes estimations fixes calculaient trop peu
|
||||
de lignes et tronquaient le message. On part donc des dimensions
|
||||
réelles du canvas et de la métrique de la police Tk.
|
||||
"""
|
||||
canvas_w = 0
|
||||
canvas_h = 0
|
||||
try:
|
||||
canvas_w = int(self._canvas.winfo_width()) if self._canvas is not None else 0
|
||||
canvas_h = int(self._canvas.winfo_height()) if self._canvas is not None else 0
|
||||
except Exception:
|
||||
canvas_w = canvas_h = 0
|
||||
|
||||
# Marges: container + padding inner + petite marge droite. La bulle
|
||||
# de pause est une alerte critique, elle utilise donc presque toute
|
||||
# la largeur disponible sur les fenêtres étroites.
|
||||
wrap_px = max(220, canvas_w - (2 * MARGIN) - 52) if canvas_w else 360
|
||||
|
||||
avg_char = 8
|
||||
line_px = 22
|
||||
try:
|
||||
from tkinter import font as tkfont
|
||||
font = tkfont.Font(font=FONT_MSG)
|
||||
avg_char = max(6, font.measure("n"))
|
||||
line_px = max(18, font.metrics("linespace"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
chars_per_line = max(24, int(wrap_px / avg_char))
|
||||
# Réserver titre, metadata, boutons, feedback et padding. Même sur
|
||||
# une petite fenêtre, on garde assez de lignes pour ne pas couper un
|
||||
# message d'erreur standard.
|
||||
max_rows = 14
|
||||
if canvas_h:
|
||||
max_rows = max(5, min(18, int((canvas_h - 145) / line_px)))
|
||||
return wrap_px, chars_per_line, max_rows
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
@@ -941,7 +994,7 @@ class ChatWindow:
|
||||
container, bg=PAUSED_BG, padx=14, pady=12,
|
||||
highlightbackground=PAUSED_BORDER, highlightthickness=2,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
|
||||
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"⏸ Pause supervisée • {now}",
|
||||
@@ -949,31 +1002,44 @@ class ChatWindow:
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
|
||||
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
|
||||
# Patch 22 mai 2026 : prendre en compte les \n explicites (titres
|
||||
# fenêtre / patterns) et activer la scrollbar dès que le cap de
|
||||
# hauteur est atteint — sinon les bulles de pause étaient
|
||||
# tronquées visuellement sans aucun ascenseur visible.
|
||||
# Message borné et scrollable : sur une fenêtre Léa étroite, une
|
||||
# bulle trop haute fait disparaître le début du diagnostic hors du
|
||||
# viewport. On garde donc la bulle compacte et on scrolle le texte.
|
||||
reason_str = str(reason)
|
||||
height_lines, needs_scroll = self._compute_paused_bubble_height(reason_str)
|
||||
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
reason_text = tk.Text(
|
||||
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wrap=tk.WORD, bd=0, height=height_lines,
|
||||
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
|
||||
_wrap_px, chars_per_line, max_rows = self._paused_text_layout()
|
||||
text_rows, needs_text_scroll = self._compute_paused_bubble_height(
|
||||
reason_str,
|
||||
chars_per_line=chars_per_line,
|
||||
max_rows=max_rows,
|
||||
)
|
||||
reason_text.insert("1.0", reason_str)
|
||||
reason_text.configure(state="disabled")
|
||||
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
if needs_scroll:
|
||||
reason_scroll = tk.Scrollbar(
|
||||
msg_frame, orient=tk.VERTICAL,
|
||||
command=reason_text.yview, width=8,
|
||||
text_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
text_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
reason_msg = tk.Text(
|
||||
text_frame,
|
||||
height=text_rows,
|
||||
wrap=tk.WORD,
|
||||
bg=PAUSED_BG,
|
||||
fg=PAUSED_FG,
|
||||
font=FONT_MSG,
|
||||
bd=0,
|
||||
highlightthickness=0,
|
||||
relief=tk.FLAT,
|
||||
padx=0,
|
||||
pady=0,
|
||||
cursor="arrow",
|
||||
)
|
||||
reason_msg.insert("1.0", reason_str)
|
||||
reason_msg.configure(state="disabled")
|
||||
reason_msg.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
if needs_text_scroll:
|
||||
scrollbar = tk.Scrollbar(
|
||||
text_frame,
|
||||
orient=tk.VERTICAL,
|
||||
command=reason_msg.yview,
|
||||
width=12,
|
||||
)
|
||||
reason_text.configure(yscrollcommand=reason_scroll.set)
|
||||
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
reason_msg.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y, padx=(6, 0))
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} — étape {completed}/{total}",
|
||||
@@ -1018,6 +1084,89 @@ class ChatWindow:
|
||||
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _render_paused_fallback_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
"""Rendu minimal de secours si la bulle riche echoue."""
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
|
||||
replay_id = str(payload.get("replay_id", "") or "")
|
||||
workflow = payload.get("workflow", "?")
|
||||
reason = str(
|
||||
payload.get("reason")
|
||||
or "Action incertaine - j'ai besoin de votre validation."
|
||||
)
|
||||
completed = payload.get("completed", 0)
|
||||
total = payload.get("total", "?")
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
|
||||
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
||||
container.pack(fill=tk.X, padx=MARGIN, pady=6)
|
||||
|
||||
inner = tk.Frame(
|
||||
container, bg=PAUSED_BG, padx=14, pady=12,
|
||||
highlightbackground=PAUSED_BORDER, highlightthickness=2,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"Pause supervisee - {now}",
|
||||
bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
wrap_px = 360
|
||||
try:
|
||||
if self._canvas is not None:
|
||||
wrap_px = max(220, int(self._canvas.winfo_width()) - 80)
|
||||
except Exception:
|
||||
pass
|
||||
tk.Label(
|
||||
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wraplength=wrap_px, justify=tk.LEFT,
|
||||
anchor=tk.W,
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} - etape {completed}/{total}",
|
||||
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
|
||||
|
||||
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
btn_frame.pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
btn_resume = tk.Button(
|
||||
btn_frame, text="Continuer",
|
||||
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_resume(replay_id),
|
||||
)
|
||||
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
|
||||
|
||||
btn_abort = tk.Button(
|
||||
btn_frame, text="Annuler",
|
||||
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_abort(replay_id),
|
||||
)
|
||||
btn_abort.pack(side=tk.LEFT)
|
||||
|
||||
feedback_label = tk.Label(
|
||||
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_TIMESTAMP, anchor="w",
|
||||
)
|
||||
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
|
||||
self._active_paused_bubble = {
|
||||
"container": container, "inner": inner,
|
||||
"btn_resume": btn_resume, "btn_abort": btn_abort,
|
||||
"feedback_label": feedback_label,
|
||||
"replay_id": replay_id,
|
||||
}
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _close_active_paused_bubble(self, reason: str) -> None:
|
||||
if self._active_paused_bubble is None or self._root is None:
|
||||
return
|
||||
@@ -1524,8 +1673,19 @@ class ChatWindow:
|
||||
self._add_lea_message(
|
||||
f"C'est parti ! Montrez-moi comment faire \u00ab {name} \u00bb."
|
||||
)
|
||||
|
||||
# --- P1-LEA-SHADOW : d\u00e9clencher d'abord l'orchestrateur L\u00e9a Linux ---
|
||||
# On contacte agent-chat AVANT la capture locale : si la session
|
||||
# serveur d\u00e9marre, on r\u00e9cup\u00e8re un session_id + un message d'accueil
|
||||
# de L\u00e9a qu'on affiche dans le chat. Si \u00e9chec : mode d\u00e9grad\u00e9
|
||||
# (capture locale uniquement, sans assistance conversationnelle).
|
||||
self._start_lea_orchestrator_session(name)
|
||||
|
||||
# --- Comportement historique pr\u00e9serv\u00e9 : capture locale ---
|
||||
# Le pipeline streaming (frames/\u00e9v\u00e9nements) reste pilot\u00e9 par
|
||||
# agent_v1 local. L'orchestrateur Linux ne touche PAS \u00e0 la
|
||||
# capture, il pilote uniquement le dialogue de fin de session.
|
||||
try:
|
||||
# Utiliser l'etat partage si disponible (synchronise le systray)
|
||||
if self._shared_state is not None:
|
||||
self._shared_state.start_recording(name)
|
||||
elif self._on_start_callback is not None:
|
||||
@@ -1533,6 +1693,60 @@ class ChatWindow:
|
||||
except Exception as e:
|
||||
self._add_lea_message(f"Oups, un probl\u00e8me : {e}")
|
||||
|
||||
def _start_lea_orchestrator_session(self, session_name: str) -> None:
|
||||
"""Appelle POST /api/learn/start c\u00f4t\u00e9 agent-chat Linux (P1-LEA-SHADOW).
|
||||
|
||||
Fail-safe : toute erreur (config absente, httpx manquant, timeout,
|
||||
500 serveur...) bascule en mode d\u00e9grad\u00e9 sans bloquer la capture
|
||||
locale. Un message clair est affich\u00e9 dans le chat.
|
||||
"""
|
||||
try:
|
||||
from ..config import AGENT_CHAT_URL, API_TOKEN, MACHINE_ID
|
||||
from ..network.lea_orchestrator_client import (
|
||||
LeaOrchestratorError,
|
||||
start_learning_session,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover (import-time)
|
||||
logger.error("Impossible de charger le client orchestrateur L\u00e9a : %s", exc)
|
||||
self._add_lea_message(
|
||||
"\u26a0 Impossible de joindre L\u00e9a serveur. "
|
||||
"L'apprentissage continue localement, mais sans assistance "
|
||||
"conversationnelle."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = start_learning_session(
|
||||
AGENT_CHAT_URL,
|
||||
machine_id=MACHINE_ID,
|
||||
session_name=session_name,
|
||||
api_token=API_TOKEN,
|
||||
trigger_source="windows_button",
|
||||
)
|
||||
except LeaOrchestratorError as exc:
|
||||
logger.error("Orchestrateur L\u00e9a injoignable : %s", exc)
|
||||
self._add_lea_message(
|
||||
"\u26a0 Impossible de joindre L\u00e9a serveur. "
|
||||
"L'apprentissage continue localement, mais sans assistance "
|
||||
"conversationnelle."
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 \u2014 d\u00e9fensif
|
||||
logger.exception("Erreur inattendue orchestrateur L\u00e9a")
|
||||
self._add_lea_message(
|
||||
f"\u26a0 Erreur orchestrateur L\u00e9a : {exc}. "
|
||||
"L'apprentissage continue localement."
|
||||
)
|
||||
return
|
||||
|
||||
# Affichage du message d'accueil renvoy\u00e9 par L\u00e9a (si pr\u00e9sent)
|
||||
if resp.message:
|
||||
self._add_lea_message(resp.message)
|
||||
logger.info(
|
||||
"Session orchestrateur L\u00e9a OK : id=%s state=%s",
|
||||
resp.session_id, resp.state,
|
||||
)
|
||||
|
||||
def _on_quick_tasks(self) -> None:
|
||||
"""Bouton Lancer — demande ce que L\u00e9a sait faire."""
|
||||
self._add_user_message("Qu'est-ce que vous savez faire ?")
|
||||
|
||||
484
agent_v0/agent_v1/ui/message_contract.py
Normal file
484
agent_v0/agent_v1/ui/message_contract.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""Contrat de lisibilite des messages visibles par l'humain.
|
||||
|
||||
Ce module ne branche encore aucun point runtime. Il fournit une brique pure et
|
||||
testable pour que les sorties UI de Lea puissent refuser les messages trop
|
||||
generiques ou trop techniques avant affichage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPERVISED_PAUSE_LABELS = (
|
||||
"J'essaie de",
|
||||
"J'attendais",
|
||||
"Je vois",
|
||||
"Peux-tu",
|
||||
)
|
||||
|
||||
MAX_VISIBLE_MESSAGE_CHARS = 720
|
||||
MAX_FIELD_CHARS = 180
|
||||
MIN_FIELD_CHARS = 4
|
||||
|
||||
_GENERIC_PHRASES = (
|
||||
"un element",
|
||||
"un élément",
|
||||
"l'element",
|
||||
"l'élément",
|
||||
"element inconnu",
|
||||
"élément inconnu",
|
||||
"cette action",
|
||||
"cette cible",
|
||||
"cible inconnue",
|
||||
"validation requise",
|
||||
"action requise",
|
||||
)
|
||||
|
||||
_ACTIONABLE_FRENCH_HINTS = (
|
||||
"peux-tu",
|
||||
"cliquer",
|
||||
"ouvrir",
|
||||
"selectionner",
|
||||
"sélectionner",
|
||||
"choisir",
|
||||
"saisir",
|
||||
"corriger",
|
||||
"montrer",
|
||||
"indiquer",
|
||||
"valider",
|
||||
"fermer",
|
||||
"placer",
|
||||
"mettre",
|
||||
"reprendre",
|
||||
)
|
||||
|
||||
_TECHNICAL_ENGLISH_TERMS = (
|
||||
"target_not_found",
|
||||
"target not found",
|
||||
"no_screen_change",
|
||||
"no screen change",
|
||||
"wrong_window",
|
||||
"wrong window",
|
||||
"validation required",
|
||||
"retry",
|
||||
"fallback",
|
||||
"timeout",
|
||||
"screenshot",
|
||||
"validator",
|
||||
"failure",
|
||||
"failed",
|
||||
"resolve target",
|
||||
"postcondition",
|
||||
"please",
|
||||
"click",
|
||||
"button",
|
||||
"target",
|
||||
"expected",
|
||||
"actual",
|
||||
"observed",
|
||||
)
|
||||
|
||||
_TECHNICAL_FIELD_RE = re.compile(
|
||||
r"\b(?:"
|
||||
r"action_id|replay_id|session_id|workflow_id|machine_id|target_spec|"
|
||||
r"vlm_description|resolution_method|resolution_score|retry_count|"
|
||||
r"x_pct|y_pct|screenshot_b64|expected_window_title|current_action_index"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TECHNICAL_IDENTIFIER_RE = re.compile(
|
||||
r"\b(?:action|replay|session|sess|workflow|node|edge|target|retry|"
|
||||
r"precheck|wait|trace|event|machine|run)_[A-Za-z0-9][A-Za-z0-9_.:-]{3,}\b"
|
||||
)
|
||||
_UUID_RE = re.compile(
|
||||
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LONG_HEX_RE = re.compile(r"\b[0-9a-f]{16,}\b", re.IGNORECASE)
|
||||
_PIXEL_TUPLE_RE = re.compile(r"\(\s*\d{2,5}\s*,\s*\d{2,5}\s*\)")
|
||||
_PIXEL_FIELD_RE = re.compile(
|
||||
r"\b(?:x|y|left|top|width|height|w|h|x_pct|y_pct)\s*[=:]\s*-?\d+(?:[.,]\d+)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PX_RE = re.compile(r"\b\d{2,5}\s*px\b", re.IGNORECASE)
|
||||
_SCORE_RE = re.compile(
|
||||
r"\b(?:score|confidence|confiance|similarit[eé]|threshold|seuil|"
|
||||
r"probabilit[eé])\s*[:=]\s*\d+(?:[.,]\d+)?%?\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageValidationIssue:
|
||||
"""Un probleme detecte dans un message visible par l'humain."""
|
||||
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageValidationResult:
|
||||
"""Resultat de validation d'un message utilisateur."""
|
||||
|
||||
issues: tuple[MessageValidationIssue, ...] = ()
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not self.issues
|
||||
|
||||
def raise_for_errors(self) -> None:
|
||||
if not self.valid:
|
||||
raise MessageContractError(self)
|
||||
|
||||
|
||||
class MessageContractError(ValueError):
|
||||
"""Erreur levee quand un message ne respecte pas le contrat humain."""
|
||||
|
||||
def __init__(self, result: MessageValidationResult):
|
||||
self.result = result
|
||||
details = "; ".join(f"{issue.code}: {issue.detail}" for issue in result.issues)
|
||||
super().__init__(f"Message humain invalide: {details}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisedPauseFields:
|
||||
"""Champs obligatoires pour expliquer une pause supervisee."""
|
||||
|
||||
intention: str
|
||||
attendu: str
|
||||
vu: str
|
||||
demande: str
|
||||
|
||||
|
||||
DEFAULT_SUPERVISED_PAUSE_FIELDS = SupervisedPauseFields(
|
||||
intention="continuer une etape supervisee",
|
||||
attendu="un accord humain clair avant de continuer",
|
||||
vu="je suis sur une etape qui demande une verification humaine",
|
||||
demande="indiquer si je peux continuer ou corriger l'action attendue",
|
||||
)
|
||||
|
||||
|
||||
def format_supervised_pause_message(
|
||||
*,
|
||||
intention: str,
|
||||
attendu: str,
|
||||
vu: str,
|
||||
demande: str,
|
||||
) -> str:
|
||||
"""Formatter une pause supervisee claire et actionnable.
|
||||
|
||||
Le message retourne exactement quatre lignes. Si un champ reste vague ou
|
||||
technique, la fonction leve ``MessageContractError`` au lieu de produire un
|
||||
message degradant pour l'utilisateur.
|
||||
"""
|
||||
|
||||
fields = SupervisedPauseFields(
|
||||
intention=_one_line(intention),
|
||||
attendu=_one_line(attendu),
|
||||
vu=_one_line(vu),
|
||||
demande=_one_line(demande),
|
||||
)
|
||||
message = "\n".join(
|
||||
(
|
||||
f"J'essaie de : {fields.intention}",
|
||||
f"J'attendais : {fields.attendu}",
|
||||
f"Je vois : {fields.vu}",
|
||||
f"Peux-tu : {fields.demande}",
|
||||
)
|
||||
)
|
||||
validate_supervised_pause_message(message).raise_for_errors()
|
||||
return message
|
||||
|
||||
|
||||
def format_supervised_pause_from_mapping(payload: Mapping[str, object]) -> str:
|
||||
"""Formatter depuis un mapping runtime avec noms de champs explicites.
|
||||
|
||||
Alias acceptes pour faciliter l'integration progressive:
|
||||
``intention|trying_to``, ``attendu|expected``, ``vu|observed``,
|
||||
``demande|request``.
|
||||
"""
|
||||
|
||||
return format_supervised_pause_message(
|
||||
intention=_mapping_text(payload, "intention", "trying_to"),
|
||||
attendu=_mapping_text(payload, "attendu", "expected"),
|
||||
vu=_mapping_text(payload, "vu", "observed"),
|
||||
demande=_mapping_text(payload, "demande", "request"),
|
||||
)
|
||||
|
||||
|
||||
def coerce_supervised_pause_message(
|
||||
message: object = "",
|
||||
*,
|
||||
intention: object = "",
|
||||
attendu: object = "",
|
||||
vu: object = "",
|
||||
demande: object = "",
|
||||
) -> str:
|
||||
"""Retourner une pause supervisee valide, meme depuis un ancien message.
|
||||
|
||||
Si ``message`` respecte deja le contrat strict, il est conserve. Sinon on
|
||||
compose les quatre champs avec les valeurs explicites disponibles. Les
|
||||
valeurs trop vagues ou techniques sont remplacees par des fallbacks clairs.
|
||||
"""
|
||||
|
||||
raw_message = _one_line(message)
|
||||
if raw_message and validate_supervised_pause_message(raw_message).valid:
|
||||
return raw_message
|
||||
|
||||
defaults = DEFAULT_SUPERVISED_PAUSE_FIELDS
|
||||
candidates = SupervisedPauseFields(
|
||||
intention=_safe_field_text(intention, defaults.intention),
|
||||
attendu=_safe_field_text(attendu, defaults.attendu),
|
||||
vu=_safe_field_text(vu, defaults.vu),
|
||||
demande=_safe_field_text(demande or raw_message, defaults.demande),
|
||||
)
|
||||
|
||||
try:
|
||||
return format_supervised_pause_message(
|
||||
intention=candidates.intention,
|
||||
attendu=candidates.attendu,
|
||||
vu=candidates.vu,
|
||||
demande=candidates.demande,
|
||||
)
|
||||
except MessageContractError:
|
||||
return format_supervised_pause_message(
|
||||
intention=defaults.intention,
|
||||
attendu=defaults.attendu,
|
||||
vu=defaults.vu,
|
||||
demande=defaults.demande,
|
||||
)
|
||||
|
||||
|
||||
def warn_visible_message(
|
||||
message: object,
|
||||
*,
|
||||
source: str,
|
||||
supervised_pause: bool = False,
|
||||
) -> str:
|
||||
"""Log contract violations without modifying the visible message."""
|
||||
|
||||
text = str(message or "")
|
||||
validator = validate_supervised_pause_message if supervised_pause else validate_visible_message
|
||||
result = validator(text)
|
||||
if not result.valid:
|
||||
logger.warning(
|
||||
"[message_contract] invalid_message source=%s codes=%s",
|
||||
source,
|
||||
[issue.code for issue in result.issues],
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def validate_supervised_pause_message(message: str) -> MessageValidationResult:
|
||||
"""Valider le contrat strict d'une pause supervisee."""
|
||||
|
||||
issues = list(validate_visible_message(message).issues)
|
||||
fields, structure_issues = _parse_supervised_pause(message)
|
||||
issues.extend(structure_issues)
|
||||
|
||||
if fields:
|
||||
for name, value in fields.items():
|
||||
if len(value) < MIN_FIELD_CHARS:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"field_too_short",
|
||||
f"{name} doit etre explicite",
|
||||
)
|
||||
)
|
||||
if len(value) > MAX_FIELD_CHARS:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"field_too_long",
|
||||
f"{name} depasse {MAX_FIELD_CHARS} caracteres",
|
||||
)
|
||||
)
|
||||
demande = fields.get("demande", "")
|
||||
if not _contains_actionable_french(demande) or len(demande.split()) < 4:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"not_actionable",
|
||||
"la demande doit contenir une action concrete en francais",
|
||||
)
|
||||
)
|
||||
|
||||
return _dedupe_issues(issues)
|
||||
|
||||
|
||||
def validate_visible_message(message: str) -> MessageValidationResult:
|
||||
"""Valider qu'un message visible n'est ni generique ni technique."""
|
||||
|
||||
text = str(message or "").strip()
|
||||
issues: list[MessageValidationIssue] = []
|
||||
|
||||
if not text:
|
||||
return MessageValidationResult(
|
||||
(MessageValidationIssue("empty_message", "message vide"),)
|
||||
)
|
||||
|
||||
if len(text) > MAX_VISIBLE_MESSAGE_CHARS:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"message_too_long",
|
||||
f"message au-dela de {MAX_VISIBLE_MESSAGE_CHARS} caracteres",
|
||||
)
|
||||
)
|
||||
|
||||
folded = _fold(text)
|
||||
seen_generic_phrases: set[str] = set()
|
||||
for phrase in _GENERIC_PHRASES:
|
||||
folded_phrase = _fold(phrase)
|
||||
if folded_phrase in seen_generic_phrases:
|
||||
continue
|
||||
seen_generic_phrases.add(folded_phrase)
|
||||
if folded_phrase in folded:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"generic_phrase",
|
||||
f"formulation trop generique: {phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
for term in _TECHNICAL_ENGLISH_TERMS:
|
||||
if _fold(term) in folded:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"technical_english",
|
||||
f"anglais technique visible: {term}",
|
||||
)
|
||||
)
|
||||
|
||||
for code, pattern, detail in (
|
||||
("technical_field", _TECHNICAL_FIELD_RE, "champ technique brut"),
|
||||
("technical_identifier", _TECHNICAL_IDENTIFIER_RE, "identifiant technique brut"),
|
||||
("technical_identifier", _UUID_RE, "UUID brut"),
|
||||
("technical_identifier", _LONG_HEX_RE, "hash technique brut"),
|
||||
("raw_coordinates", _PIXEL_TUPLE_RE, "coordonnees pixel brutes"),
|
||||
("raw_coordinates", _PIXEL_FIELD_RE, "coordonnees techniques brutes"),
|
||||
("raw_coordinates", _PX_RE, "coordonnees pixel brutes"),
|
||||
("raw_score", _SCORE_RE, "score ou confiance brut"),
|
||||
):
|
||||
if pattern.search(text):
|
||||
issues.append(MessageValidationIssue(code, detail))
|
||||
|
||||
return _dedupe_issues(issues)
|
||||
|
||||
|
||||
def is_valid_visible_message(message: str) -> bool:
|
||||
"""Raccourci booleen pour les points d'integration UI."""
|
||||
|
||||
return validate_visible_message(message).valid
|
||||
|
||||
|
||||
def is_valid_supervised_pause_message(message: str) -> bool:
|
||||
"""Raccourci booleen pour les pauses supervisees."""
|
||||
|
||||
return validate_supervised_pause_message(message).valid
|
||||
|
||||
|
||||
def _parse_supervised_pause(
|
||||
message: str,
|
||||
) -> tuple[dict[str, str], list[MessageValidationIssue]]:
|
||||
lines = [line.rstrip() for line in str(message or "").splitlines() if line.strip()]
|
||||
issues: list[MessageValidationIssue] = []
|
||||
|
||||
if len(lines) != 4:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"invalid_structure",
|
||||
"une pause supervisee doit contenir exactement 4 lignes",
|
||||
)
|
||||
)
|
||||
return {}, issues
|
||||
|
||||
specs = (
|
||||
("intention", r"^J'essaie de\s*:\s*(.+)$"),
|
||||
("attendu", r"^J'attendais\s*:\s*(.+)$"),
|
||||
("vu", r"^Je vois\s*:\s*(.+)$"),
|
||||
("demande", r"^Peux-tu\s*:\s*(.+)$"),
|
||||
)
|
||||
fields: dict[str, str] = {}
|
||||
for line, (name, pattern) in zip(lines, specs):
|
||||
match = re.match(pattern, line)
|
||||
if not match:
|
||||
issues.append(
|
||||
MessageValidationIssue(
|
||||
"invalid_structure",
|
||||
f"ligne {len(fields) + 1} doit commencer par {SUPERVISED_PAUSE_LABELS[len(fields)]}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
fields[name] = match.group(1).strip()
|
||||
|
||||
if len(fields) != 4:
|
||||
return {}, issues
|
||||
|
||||
return fields, issues
|
||||
|
||||
|
||||
def _contains_actionable_french(text: str) -> bool:
|
||||
folded = _fold(text)
|
||||
return any(_fold(hint) in folded for hint in _ACTIONABLE_FRENCH_HINTS)
|
||||
|
||||
|
||||
def _one_line(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def _mapping_text(payload: Mapping[str, object], *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_field_text(value: object, fallback: str) -> str:
|
||||
text = _one_line(value)
|
||||
if len(text) < MIN_FIELD_CHARS or len(text) > MAX_FIELD_CHARS:
|
||||
return fallback
|
||||
if not validate_visible_message(text).valid:
|
||||
return fallback
|
||||
return text
|
||||
|
||||
|
||||
def _fold(text: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", str(text or ""))
|
||||
ascii_text = "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||
return ascii_text.casefold()
|
||||
|
||||
|
||||
def _dedupe_issues(issues: Iterable[MessageValidationIssue]) -> MessageValidationResult:
|
||||
seen: set[tuple[str, str]] = set()
|
||||
deduped: list[MessageValidationIssue] = []
|
||||
for issue in issues:
|
||||
key = (issue.code, issue.detail)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(issue)
|
||||
return MessageValidationResult(tuple(deduped))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_FIELD_CHARS",
|
||||
"MAX_VISIBLE_MESSAGE_CHARS",
|
||||
"MessageContractError",
|
||||
"MessageValidationIssue",
|
||||
"MessageValidationResult",
|
||||
"SUPERVISED_PAUSE_LABELS",
|
||||
"SupervisedPauseFields",
|
||||
"coerce_supervised_pause_message",
|
||||
"format_supervised_pause_from_mapping",
|
||||
"format_supervised_pause_message",
|
||||
"is_valid_supervised_pause_message",
|
||||
"is_valid_visible_message",
|
||||
"validate_supervised_pause_message",
|
||||
"validate_visible_message",
|
||||
"warn_visible_message",
|
||||
]
|
||||
@@ -82,6 +82,12 @@ ICONE_PAR_NIVEAU: dict[NiveauMessage, str] = {
|
||||
NiveauMessage.BLOCAGE: "?",
|
||||
}
|
||||
|
||||
# Les pauses supervisees peuvent contenir une raison precise, parfois longue
|
||||
# (fenetre observee, fenetre attendue, action en cours). On garde l'information
|
||||
# utile et on laisse les widgets UI gerer le wrap/scroll.
|
||||
MAX_TARGET_DESCRIPTION_CHARS = 1024
|
||||
MAX_GENERIC_TECHNICAL_MESSAGE_CHARS = 1024
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageUtilisateur:
|
||||
@@ -147,9 +153,9 @@ def _nettoyer_description_cible(description: str) -> str:
|
||||
desc = description.strip()
|
||||
# Retirer les guillemets encapsulants
|
||||
desc = desc.strip("'\"`")
|
||||
# Limiter la longueur
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
# Limiter la longueur sans perdre les details utiles a la supervision.
|
||||
if len(desc) > MAX_TARGET_DESCRIPTION_CHARS:
|
||||
desc = desc[: MAX_TARGET_DESCRIPTION_CHARS - 3] + "..."
|
||||
return desc
|
||||
|
||||
|
||||
@@ -566,8 +572,8 @@ def formatter_erreur_generique(
|
||||
|
||||
# Fallback : message technique tronqué
|
||||
msg_tronque = message_technique.strip()
|
||||
if len(msg_tronque) > 120:
|
||||
msg_tronque = msg_tronque[:117] + "..."
|
||||
if len(msg_tronque) > MAX_GENERIC_TECHNICAL_MESSAGE_CHARS:
|
||||
msg_tronque = msg_tronque[: MAX_GENERIC_TECHNICAL_MESSAGE_CHARS - 3] + "..."
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
|
||||
@@ -371,7 +371,13 @@ class SmartTrayV1:
|
||||
)
|
||||
if name and name.strip():
|
||||
name = name.strip()
|
||||
# Utiliser l'etat partage si disponible
|
||||
|
||||
# --- P1-LEA-SHADOW : d\u00e9clencher d'abord l'orchestrateur L\u00e9a Linux ---
|
||||
# On contacte agent-chat AVANT la capture locale. Si \u00e9chec,
|
||||
# bascule en mode d\u00e9grad\u00e9 (capture locale sans assistance).
|
||||
self._start_lea_orchestrator_session(name)
|
||||
|
||||
# --- Comportement historique pr\u00e9serv\u00e9 : capture locale ---
|
||||
if self._shared_state is not None:
|
||||
try:
|
||||
self._shared_state.start_recording(name)
|
||||
@@ -393,6 +399,55 @@ class SmartTrayV1:
|
||||
|
||||
threading.Thread(target=_dialog, daemon=True).start()
|
||||
|
||||
def _start_lea_orchestrator_session(self, session_name: str) -> None:
|
||||
"""Appelle POST /api/learn/start côté agent-chat Linux (P1-LEA-SHADOW).
|
||||
|
||||
Fail-safe : toute erreur (config absente, httpx manquant, timeout,
|
||||
5xx serveur...) bascule en mode dégradé sans bloquer la capture
|
||||
locale. L'utilisateur est informé via le NotificationManager.
|
||||
"""
|
||||
try:
|
||||
from ..config import AGENT_CHAT_URL, API_TOKEN, MACHINE_ID
|
||||
from ..network.lea_orchestrator_client import (
|
||||
LeaOrchestratorError,
|
||||
start_learning_session,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover (import-time)
|
||||
logger.error("Impossible de charger le client orchestrateur Léa : %s", exc)
|
||||
self._notifier.notify(
|
||||
"Léa",
|
||||
"Serveur injoignable — apprentissage local uniquement.",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = start_learning_session(
|
||||
AGENT_CHAT_URL,
|
||||
machine_id=MACHINE_ID,
|
||||
session_name=session_name,
|
||||
api_token=API_TOKEN,
|
||||
trigger_source="tray_button",
|
||||
)
|
||||
except LeaOrchestratorError as exc:
|
||||
logger.error("Orchestrateur Léa injoignable : %s", exc)
|
||||
self._notifier.notify(
|
||||
"Léa",
|
||||
"Serveur injoignable — apprentissage local uniquement.",
|
||||
)
|
||||
return
|
||||
except Exception: # noqa: BLE001 — défensif
|
||||
logger.exception("Erreur inattendue orchestrateur Léa")
|
||||
self._notifier.notify(
|
||||
"Léa",
|
||||
"Erreur orchestrateur — apprentissage local uniquement.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Session orchestrateur Léa OK : id=%s state=%s",
|
||||
resp.session_id, resp.state,
|
||||
)
|
||||
|
||||
def _on_stop_session(self, _icon=None, _item=None) -> None:
|
||||
"""Termine la session en cours et envoie les donnees."""
|
||||
count = self.actions_count
|
||||
|
||||
Reference in New Issue
Block a user