From 41c1250c996a681758a7341fa7e49fd1edcc0d7f Mon Sep 17 00:00:00 2001 From: Dom Date: Tue, 28 Apr 2026 10:18:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(lea):=20bulles=20'L=C3=A9a=20ex=C3=A9cute'?= =?UTF-8?q?=20stylis=C3=A9es=20+=20templates=20par=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J3.4 — distinction visuelle entre : - Bulles chat normales (fond bleu clair, prefixe 💬, taille standard) - Bulles d'action Léa (fond gris clair, encadré subtil, icône sémantique en couleur, libellé court, métadonnées discrètes en pied) - Bulle paused supervisée (jaune, boutons interactifs — déjà en J3.5) Templates de libellés volontairement neutres : le contexte métier (UHCD, peakflow, J12.1, IPP 25003284…) provient des payloads émis par le pipeline côté serveur, pas de hardcoding dans le client. Mappage events → bulles : lea:action_started ▶ bleu "Démarrage : {workflow}" lea:action_progress ⋯ bleu "{step}" ou "Étape {current}/{total}" lea:done ✓ vert / ✗ rouge selon success lea:need_confirm ? bleu "{action.description}" lea:step_result ✓ / ✗ / · selon status lea:resumed → vert "Reprise" lea:resume_acked (silencieux côté UI) lea:abort_acked (silencieux côté UI) événement inconnu · gris fallback neutre 18 nouveaux tests pytest (templates + extract_meta). Total branche : 47/47 verts. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent_v0/agent_v1/ui/chat_window.py | 169 ++++++++++++++++-- .../integration/test_chat_window_templates.py | 129 +++++++++++++ 2 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 tests/integration/test_chat_window_templates.py diff --git a/agent_v0/agent_v1/ui/chat_window.py b/agent_v0/agent_v1/ui/chat_window.py index c35294fb8..3df9cbe58 100644 --- a/agent_v0/agent_v1/ui/chat_window.py +++ b/agent_v0/agent_v1/ui/chat_window.py @@ -60,6 +60,16 @@ PAUSED_BTN_RESUME_HOVER = "#16A34A" PAUSED_BTN_ABORT_BG = "#9CA3AF" # Gris neutre (pas dramatique) PAUSED_BTN_ABORT_HOVER = "#6B7280" +# Bulle "Léa exécute" (J3.4) — distincte des bulles chat normales +ACTION_BG = "#F1F5F9" # Gris très clair (différencie d'une réponse chat) +ACTION_BORDER = "#CBD5E1" # Gris pâle +ACTION_FG = "#1E293B" # Gris foncé +ACTION_META_FG = "#94A3B8" # Métadonnées en gris discret +ACTION_ICON_RUN = "#3B82F6" # Bleu (en cours) +ACTION_ICON_OK = "#22C55E" # Vert (succès) +ACTION_ICON_ERR = "#EF4444" # Rouge (échec) +ACTION_ICON_INFO = "#64748B" # Gris (neutre) + # Dimensions — confortables WIN_WIDTH = 600 WIN_HEIGHT = 800 @@ -80,6 +90,80 @@ FONT_SEND_BTN = ("Segoe UI", 13) FONT_RESIZE_GRIP = ("Segoe UI", 10) +# --------------------------------------------------------------------------- +# Templates de bulles "Léa exécute" (J3.4) +# Chaque template prend un payload et retourne (icon, icon_color, title). +# Les libellés sont volontairement neutres : le contexte métier vient du +# payload (workflow, action, message), pas de hardcoding. +# --------------------------------------------------------------------------- + +def _tpl_action_started(payload: Dict[str, Any]) -> tuple: + wf = payload.get("workflow") or "?" + return ("▶", ACTION_ICON_RUN, f"Démarrage : {wf}") + + +def _tpl_action_progress(payload: Dict[str, Any]) -> tuple: + cur = payload.get("current", "?") + tot = payload.get("total", "?") + step = payload.get("step") + title = step if step else f"Étape {cur}/{tot}" + return ("⋯", ACTION_ICON_RUN, str(title)) + + +def _tpl_done(payload: Dict[str, Any]) -> tuple: + success = bool(payload.get("success", True)) + msg = payload.get("message") or ("Terminé" if success else "Échec") + if success: + return ("✓", ACTION_ICON_OK, str(msg)) + return ("✗", ACTION_ICON_ERR, str(msg)) + + +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" + return ("?", ACTION_ICON_RUN, str(title)) + + +def _tpl_step_result(payload: Dict[str, Any]) -> tuple: + status = (payload.get("status") or "").lower() + msg = payload.get("message") or status or "Étape terminée" + if status in ("ok", "success", "approved"): + return ("✓", ACTION_ICON_OK, str(msg)) + if status in ("error", "failed"): + return ("✗", ACTION_ICON_ERR, str(msg)) + return ("·", ACTION_ICON_INFO, str(msg)) + + +def _tpl_resumed(payload: Dict[str, Any]) -> tuple: + return ("→", ACTION_ICON_OK, "Reprise") + + +_ACTION_TEMPLATES = { + "lea:action_started": _tpl_action_started, + "lea:action_progress": _tpl_action_progress, + "lea:done": _tpl_done, + "lea:need_confirm": _tpl_need_confirm, + "lea:step_result": _tpl_step_result, + "lea:resumed": _tpl_resumed, +} + + +def _extract_meta(payload: Dict[str, Any]) -> str: + """Métadonnées techniques en pied de bulle (workflow, étape, replay_id court).""" + parts = [] + wf = payload.get("workflow") + if wf: + parts.append(str(wf)) + cur, tot = payload.get("current"), payload.get("total") + if cur is not None and tot is not None: + parts.append(f"étape {cur}/{tot}") + rid = payload.get("replay_id") + if rid: + parts.append(f"#{str(rid)[-6:]}") + return " • ".join(parts) + + class ChatWindow: """Fenetre de chat Lea en tkinter natif. @@ -669,24 +753,85 @@ class ChatWindow: self._bus = None def _on_lea_event(self, event: str, payload: Dict[str, Any]) -> None: - """Callback bus → bulle Lea. Thread-safe : _add_lea_message utilise root.after.""" + """Callback bus → bulle Lea. Thread-safe : helpers utilisent root.after.""" + payload = payload or {} + # J3.5 : la pause supervisée a sa propre bulle interactive if event == "lea:paused": - self._add_paused_bubble(payload or {}) + self._add_paused_bubble(payload) return if event in ("lea:resumed", "lea:done"): self._close_active_paused_bubble(reason=event) - # ne pas return — on affiche aussi la bulle plate ci-dessous + # on continue pour afficher la bulle d'action (cf. dispatch ci-dessous) - # J3.3 : formatage minimal (J3.4 affinera avec le vocabulaire métier validé Amina) - short = event.removeprefix("lea:") if event.startswith("lea:") else event - parts = [] - for key in ("workflow", "step", "reason", "message", "failed_action"): - v = (payload or {}).get(key) - if v not in (None, ""): - parts.append(f"{key}={v}") - suffix = " — " + ", ".join(parts) if parts else "" - self._add_lea_message(f"[{short}]{suffix}") + # Acks bus (resume_acked, abort_acked) : silencieux côté UI + if event in ("lea:resume_acked", "lea:abort_acked"): + return + + # J3.4 : bulle "Léa exécute" stylisée (séparée des bulles chat normales) + rendered = _ACTION_TEMPLATES.get(event) + if rendered is None: + # Event inconnu : on affiche en bulle d'action neutre + self._add_action_bubble( + icon="·", icon_color=ACTION_ICON_INFO, + title=event.removeprefix("lea:"), + meta=_extract_meta(payload), + ) + return + icon, icon_color, title = rendered(payload) + self._add_action_bubble( + icon=icon, icon_color=icon_color, title=title, + meta=_extract_meta(payload), + ) + + # ------------------------------------------------------------------ + # Bulle "Léa exécute" stylisée (J3.4) + # ------------------------------------------------------------------ + + def _add_action_bubble( + self, icon: str, icon_color: str, title: str, meta: str = "", + ) -> None: + if self._root is None: + return + self._root.after(0, lambda: self._render_action_bubble(icon, icon_color, title, meta)) + + def _render_action_bubble( + self, icon: str, icon_color: str, title: str, meta: str, + ) -> None: + tk = self._tk + if getattr(self, "_msg_frame", None) is None: + return + now = datetime.now().strftime("%H:%M") + + container = tk.Frame(self._msg_frame, bg=BG_COLOR) + container.pack(fill=tk.X, padx=MARGIN, pady=3) + + inner = tk.Frame( + container, bg=ACTION_BG, padx=10, pady=6, + highlightbackground=ACTION_BORDER, highlightthickness=1, + ) + inner.pack(anchor=tk.W, padx=(0, 70), fill=tk.X) + + row = tk.Frame(inner, bg=ACTION_BG) + row.pack(fill=tk.X, anchor=tk.W) + + tk.Label( + row, text=icon, bg=ACTION_BG, fg=icon_color, + font=("Segoe UI", 13, "bold"), padx=4, + ).pack(side=tk.LEFT) + + tk.Label( + row, text=title, bg=ACTION_BG, fg=ACTION_FG, + font=FONT_MSG, anchor="w", justify=tk.LEFT, + wraplength=MSG_WRAP_WIDTH - 60, + ).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(2, 0)) + + if meta: + tk.Label( + inner, text=f"{meta} • {now}", + bg=ACTION_BG, fg=ACTION_META_FG, + font=FONT_TIMESTAMP, anchor="w", + ).pack(fill=tk.X, anchor=tk.W, pady=(2, 0)) # ------------------------------------------------------------------ # Bulle paused_need_help interactive (J3.5) diff --git a/tests/integration/test_chat_window_templates.py b/tests/integration/test_chat_window_templates.py new file mode 100644 index 000000000..1504a0090 --- /dev/null +++ b/tests/integration/test_chat_window_templates.py @@ -0,0 +1,129 @@ +"""Tests des templates de bulles 'Léa exécute' (J3.4). + +On teste les fonctions _tpl_* et _extract_meta de chat_window.py — elles sont +purement fonctionnelles (input payload → output tuple), aucune UI tkinter +nécessaire. +""" + +import pytest + +from agent_v0.agent_v1.ui import chat_window as cw + + +# ---------------------------------------------------------------------- +# Templates _tpl_* +# ---------------------------------------------------------------------- + +def test_tpl_action_started_uses_workflow_name(): + icon, color, title = cw._tpl_action_started({"workflow": "Demo Urgences UHCD"}) + assert icon == "▶" + assert color == cw.ACTION_ICON_RUN + assert "Demo Urgences UHCD" in title + + +def test_tpl_action_started_fallback_when_no_workflow(): + _, _, title = cw._tpl_action_started({}) + assert "?" in title + + +def test_tpl_action_progress_uses_step_when_provided(): + _, _, title = cw._tpl_action_progress({"step": "J'ouvre la fiche patient"}) + assert title == "J'ouvre la fiche patient" + + +def test_tpl_action_progress_fallback_to_counter(): + _, _, title = cw._tpl_action_progress({"current": 4, "total": 7}) + assert "4/7" in title + + +def test_tpl_done_success(): + icon, color, title = cw._tpl_done({"success": True, "message": "Codage terminé"}) + assert icon == "✓" + assert color == cw.ACTION_ICON_OK + assert title == "Codage terminé" + + +def test_tpl_done_failure(): + icon, color, title = cw._tpl_done({"success": False, "message": "Action échouée"}) + assert icon == "✗" + assert color == cw.ACTION_ICON_ERR + assert title == "Action échouée" + + +def test_tpl_done_default_success_when_unspecified(): + icon, _, _ = cw._tpl_done({}) + assert icon == "✓" # par défaut on suppose succès si non précisé + + +def test_tpl_need_confirm_extracts_action_description(): + icon, _, title = cw._tpl_need_confirm({ + "action": {"description": "Cliquer sur l'IPP 25003284"} + }) + assert icon == "?" + assert "25003284" in title + + +def test_tpl_need_confirm_fallback(): + _, _, title = cw._tpl_need_confirm({}) + assert "Validation" in title + + +def test_tpl_step_result_ok(): + icon, color, _ = cw._tpl_step_result({"status": "ok", "message": "ok"}) + assert icon == "✓" + assert color == cw.ACTION_ICON_OK + + +def test_tpl_step_result_failed(): + icon, color, _ = cw._tpl_step_result({"status": "failed", "message": "boom"}) + assert icon == "✗" + assert color == cw.ACTION_ICON_ERR + + +def test_tpl_step_result_neutral_status(): + icon, color, _ = cw._tpl_step_result({"status": "skipped", "message": "passé"}) + assert icon == "·" + assert color == cw.ACTION_ICON_INFO + + +def test_tpl_resumed(): + icon, color, title = cw._tpl_resumed({}) + assert icon == "→" + assert color == cw.ACTION_ICON_OK + assert "Reprise" in title + + +# ---------------------------------------------------------------------- +# Dispatch — chaque event lea:* (hors paused/acks) doit avoir un template +# ---------------------------------------------------------------------- + +def test_all_relevant_events_have_a_template(): + expected = { + "lea:action_started", "lea:action_progress", "lea:done", + "lea:need_confirm", "lea:step_result", "lea:resumed", + } + assert set(cw._ACTION_TEMPLATES.keys()) == expected + + +# ---------------------------------------------------------------------- +# _extract_meta +# ---------------------------------------------------------------------- + +def test_extract_meta_with_workflow(): + meta = cw._extract_meta({"workflow": "Demo Urgences"}) + assert meta == "Demo Urgences" + + +def test_extract_meta_with_progress(): + meta = cw._extract_meta({"workflow": "Demo Urgences", "current": 4, "total": 7}) + assert "Demo Urgences" in meta + assert "étape 4/7" in meta + + +def test_extract_meta_with_replay_id_truncated(): + meta = cw._extract_meta({"replay_id": "rep_abcdef0123456789"}) + assert "#789" in meta or "456789" in meta # 6 derniers caractères + + +def test_extract_meta_empty_payload(): + assert cw._extract_meta({}) == ""