feat(lea): bulles 'Léa exécute' stylisées + templates par event
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
129
tests/integration/test_chat_window_templates.py
Normal file
129
tests/integration/test_chat_window_templates.py
Normal file
@@ -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({}) == ""
|
||||
Reference in New Issue
Block a user