feat: Léa personnalité — langage métier multi-domaines

Aspect 4/4 Léa : Léa parle le langage du métier, pas du robot.

DomainContext enrichi avec 5 domaines :
- tim_codage : CIM-10, CCAM, GHM, DP/DAS (enrichi)
- comptabilite : factures HT/TVA/TTC, OCR, lettrage, PCG
- rh_paie : bulletins, DSN, brut/net, congés, IJSS
- stocks_logistique : BC/BL/BR, SKU, inventaires, picking
- generic : fallback

Nouvelle API DomainContext :
- summarize_action(action, params) — click "DP" → "saisir le diagnostic principal"
- pose_clarification_question(context) — question pertinente quand Léa bloque
- describe_workflow_outcome(...) — rapport final en langage métier

Exemples :
  TIM : "J'ai codé 14 dossiers sur 15. 1 en attente — codes CIM-10 ambigus."
  Compta : "Je ne trouve pas le champ montant de TVA. C'est bien la facture F2026-0145 ?"

Intégration ui/messages.py :
- Import lazy (pas de dépendance circulaire)
- formatter_cible_non_trouvee utilise les templates de clarification métier
- Rétro-compat : tous les anciens appels sans domain_id fonctionnent

47 nouveaux tests, 0 régression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 09:01:52 +02:00
parent f541bb8ce4
commit 42d49dd8bd
3 changed files with 1525 additions and 24 deletions

View File

@@ -19,7 +19,45 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from typing import Any, Mapping, Optional
# ----------------------------------------------------------------------------
# Accès paresseux au DomainContext
# ----------------------------------------------------------------------------
#
# On importe le module à l'appel pour éviter toute dépendance circulaire
# avec `agent_v0.server_v1.domain_context` (qui ne doit pas importer l'UI).
# Si l'import échoue (contexte client sans server_v1), on retombe sur None
# et les formatters gardent leur comportement générique historique.
def _get_domain_ctx(domain_id: Optional[str]):
"""Récupérer un DomainContext si possible, sinon None (fallback)."""
if not domain_id:
return None
try:
from agent_v0.server_v1.domain_context import get_domain_context # lazy
return get_domain_context(domain_id)
except Exception:
return None
def _friendly_target(description: str, domain_id: Optional[str] = None) -> str:
"""Transformer une description technique en langage métier si possible.
Ex (tim_codage) : "DP""diagnostic principal"
Ex (comptabilite) : "TVA""montant de TVA"
Retombe sur la description nettoyée si aucun domaine ne matche.
"""
base = _nettoyer_description_cible(description)
ctx = _get_domain_ctx(domain_id)
if ctx is None or not base:
return base
try:
return ctx._apply_synonyms(base)
except Exception:
return base
class NiveauMessage(Enum):
@@ -123,19 +161,57 @@ def _nettoyer_description_cible(description: str) -> str:
def formatter_cible_non_trouvee(
description_cible: str,
titre_fenetre: Optional[str] = None,
domain_id: Optional[str] = None,
params: Optional[Mapping[str, Any]] = None,
) -> MessageUtilisateur:
"""Message quand Léa ne trouve pas un élément à cliquer.
Si un domaine métier est fourni, la description de la cible est
transformée en langage métier via le DomainContext :
- tim_codage + "DP""diagnostic principal"
- comptabilite + "TVA""montant de TVA"
Exemple avant :
target_not_found: 'bonjour' dans *bonjour, Bloc-notes
Exemple après :
Léa a besoin d'aide
Je ne trouve pas "bonjour" dans le Bloc-notes. Peux-tu cliquer
dessus toi-même ? Je reprends ensuite.
Args:
description_cible: Description brute de la cible.
titre_fenetre: Titre de la fenêtre active (pour extraire l'app).
domain_id: Domaine métier pour enrichir la sortie (optionnel).
params: Paramètres du workflow (nom_patient, num_facture...)
utilisés par les templates de clarification métier.
"""
cible = _nettoyer_description_cible(description_cible) or "l'élément"
cible = _friendly_target(description_cible, domain_id) or "l'élément"
app = _extraire_nom_application(titre_fenetre or "")
# Si un domaine et un template de clarification existent, préférer la
# question métier (plus pertinente que le message générique).
ctx = _get_domain_ctx(domain_id)
if ctx is not None and ctx.clarification_templates:
try:
corps = ctx.pose_clarification_question(
{
"blocked_on": "target_not_found",
"target": description_cible or "",
"app": app,
"params": dict(params or {}),
}
)
except Exception:
corps = ""
if corps:
return MessageUtilisateur(
niveau=NiveauMessage.BLOCAGE,
titre="Léa a besoin d'aide",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
persistent=True,
)
if app:
corps = (
f"Je ne trouve pas « {cible} » dans {app}. "
@@ -312,8 +388,63 @@ def formatter_fin_workflow(
nom_workflow: str = "",
nb_etapes: int = 0,
duree_s: float = 0.0,
domain_id: Optional[str] = None,
items_count: int = 0,
failed_count: int = 0,
params: Optional[Mapping[str, Any]] = None,
) -> MessageUtilisateur:
"""Message à la fin d'un workflow."""
"""Message à la fin d'un workflow.
Si un domaine métier est fourni (et qu'il expose des summary_templates),
on utilise `DomainContext.describe_workflow_outcome` pour formuler un
rapport en langage métier (ex: "J'ai codé 14 dossiers sur 15").
Args:
succes: True si l'ensemble du workflow a réussi.
nom_workflow: Nom du workflow.
nb_etapes: Nombre d'étapes techniques (pour fallback générique).
duree_s: Durée totale en secondes.
domain_id: Domaine métier (optionnel).
items_count: Nombre d'items métier traités (ex: 15 dossiers).
failed_count: Nombre d'items en échec.
params: Infos supplémentaires passées aux templates.
"""
ctx = _get_domain_ctx(domain_id)
if ctx is not None and ctx.summary_templates:
try:
corps = ctx.describe_workflow_outcome(
workflow_name=nom_workflow,
success=succes,
items_count=items_count or max(1, nb_etapes),
failed_count=failed_count,
elapsed_s=duree_s,
extra=dict(params or {}),
)
except Exception:
corps = ""
if corps:
if succes and failed_count == 0:
return MessageUtilisateur(
niveau=NiveauMessage.INFO,
titre="Léa a terminé",
corps=corps,
duree_s=6,
)
if succes and failed_count > 0:
return MessageUtilisateur(
niveau=NiveauMessage.ATTENTION,
titre="Léa a terminé partiellement",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
)
return MessageUtilisateur(
niveau=NiveauMessage.BLOCAGE,
titre="Léa s'arrête",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
persistent=True,
)
if succes:
if nom_workflow and nb_etapes > 0:
corps = (
@@ -342,11 +473,17 @@ def formatter_fin_workflow(
)
def formatter_erreur_generique(message_technique: str) -> MessageUtilisateur:
def formatter_erreur_generique(
message_technique: str,
domain_id: Optional[str] = None,
params: Optional[Mapping[str, Any]] = None,
) -> MessageUtilisateur:
"""Formater un message d'erreur technique non catégorisé.
On essaie de détecter les motifs connus dans le message technique pour
le router vers le bon formatter spécialisé, sinon on emballe le message.
Si `domain_id` est fourni, il est propagé aux formatters spécialisés
pour produire un message en langage métier.
"""
if not message_technique:
return MessageUtilisateur(
@@ -363,7 +500,7 @@ def formatter_erreur_generique(message_technique: str) -> MessageUtilisateur:
# Essayer d'extraire la description après le ':'
match = re.match(r"target_not_found[:\s]*(.*)", message_technique, re.IGNORECASE)
desc = match.group(1).strip() if match else ""
return formatter_cible_non_trouvee(desc)
return formatter_cible_non_trouvee(desc, domain_id=domain_id, params=params)
# Fenêtre incorrecte: 'X' (attendu: 'Y')
if "fenêtre incorrecte" in msg_lower or "fenetre incorrecte" in msg_lower:
@@ -380,7 +517,9 @@ def formatter_erreur_generique(message_technique: str) -> MessageUtilisateur:
# Policy abort / supervise
if "policy_abort" in msg_lower or "visual_resolve_failed" in msg_lower:
return formatter_cible_non_trouvee(message_technique)
return formatter_cible_non_trouvee(
message_technique, domain_id=domain_id, params=params
)
# Fallback : message technique tronqué
msg_tronque = message_technique.strip()