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>
1021 lines
40 KiB
Python
1021 lines
40 KiB
Python
# agent_v0/server_v1/domain_context.py
|
|
"""
|
|
Contexte métier pour les appels VLM — rend Léa experte du domaine.
|
|
|
|
Chaque workflow est associé à un domaine métier (médical, comptable, etc.)
|
|
qui enrichit TOUS les prompts VLM (Observer, Critic, acteur, enrichissement)
|
|
ET la personnalité de Léa (résumés, questions de clarification, rapports).
|
|
|
|
Un gemma4 qui sait qu'il regarde un DPI et que l'utilisateur fait du codage
|
|
CIM-10 prend des décisions bien meilleures qu'un VLM générique. Et Léa qui
|
|
dit "J'ai codé 14 dossiers sur 15" plutôt que "J'ai exécuté 112 clics" est
|
|
bien plus utile pour un TIM.
|
|
|
|
Domaines pré-configurés :
|
|
- tim_codage : TIM, codage CIM-10 / CCAM / PMSI, DPI
|
|
- comptabilite : factures, TVA, OCR, plans comptables
|
|
- rh_paie : fiches de paie, employés, charges sociales
|
|
- stocks_logistique : bons, commandes, réceptions, inventaires
|
|
- generic : fallback bureautique
|
|
|
|
Usage basique :
|
|
ctx = get_domain_context("tim_codage")
|
|
prompt = ctx.enrich_prompt(user_prompt, role="actor")
|
|
|
|
Usage langage métier :
|
|
ctx = get_domain_context("tim_codage")
|
|
phrase = ctx.summarize_action("click", {"target": "DP"})
|
|
# → "saisir le diagnostic principal"
|
|
|
|
question = ctx.pose_clarification_question(
|
|
{"blocked_on": "target_not_found", "target": "Fichier patient",
|
|
"params": {"nom_patient": "Mme Durand"}}
|
|
)
|
|
# → "Je ne trouve pas le dossier de Mme Durand..."
|
|
|
|
rapport = ctx.describe_workflow_outcome(
|
|
workflow_name="Codage séjours janvier",
|
|
success=True,
|
|
items_count=15,
|
|
failed_count=1,
|
|
)
|
|
# → "J'ai codé 14 dossiers sur 15..."
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Mapping, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _strip_accents(s: str) -> str:
|
|
"""Supprimer les accents pour les comparaisons insensibles aux diacritiques."""
|
|
if not s:
|
|
return ""
|
|
nkfd = unicodedata.normalize("NFKD", s)
|
|
return "".join(c for c in nkfd if not unicodedata.combining(c))
|
|
|
|
|
|
# =========================================================================
|
|
# Data class
|
|
# =========================================================================
|
|
|
|
|
|
@dataclass
|
|
class DomainContext:
|
|
"""Contexte métier pour un domaine spécifique.
|
|
|
|
Contient à la fois les hints pour les prompts VLM et les éléments de
|
|
personnalité de Léa (langage métier, questions, rapports).
|
|
"""
|
|
|
|
domain_id: str # tim_codage, comptabilite, ...
|
|
name: str # Nom lisible
|
|
description: str # Description courte
|
|
|
|
# Prompt système injecté dans TOUS les appels VLM
|
|
system_prompt: str = ""
|
|
|
|
# Vocabulaire métier (termes que le VLM doit connaître)
|
|
vocabulary: List[str] = field(default_factory=list)
|
|
|
|
# Applications connues
|
|
known_apps: List[str] = field(default_factory=list)
|
|
|
|
# Écrans types
|
|
screen_patterns: Dict[str, str] = field(default_factory=dict)
|
|
|
|
# --- Personnalité Léa -------------------------------------------------
|
|
|
|
# Mapping d'actions techniques (click/type/key_combo) vers description métier,
|
|
# indexé par un mot-clé lisible trouvé dans la cible/texte.
|
|
# Format : { (action_type, keyword_lower) : "description métier" }
|
|
# Exemple : ("click", "dp") → "saisir le diagnostic principal"
|
|
common_actions: Dict[str, str] = field(default_factory=dict)
|
|
|
|
# Synonymes métier : technique → forme lisible
|
|
# Exemple : {"dp": "diagnostic principal", "das": "diagnostics associés"}
|
|
vocabulary_synonyms: Dict[str, str] = field(default_factory=dict)
|
|
|
|
# Templates de questions de clarification (selon la raison de blocage).
|
|
# Clé = identifiant de blocage ("target_not_found", "ambiguous_field", ...)
|
|
# Valeur = template f-string (champs: {target}, {app}, {nom_patient}, ...)
|
|
clarification_templates: Dict[str, str] = field(default_factory=dict)
|
|
|
|
# Templates de résumés de fin de workflow.
|
|
# Clés attendues :
|
|
# - "success" : tout a marché
|
|
# - "partial" : succès partiel (failed_count > 0)
|
|
# - "failure" : échec complet
|
|
# - "success_one" : cas 1 élément (pour éviter "1 dossiers")
|
|
# - "item_singular" : libellé d'un item ("dossier")
|
|
# - "item_plural" : libellé au pluriel ("dossiers")
|
|
summary_templates: Dict[str, str] = field(default_factory=dict)
|
|
|
|
# ------------------------------------------------------------------ API
|
|
|
|
def enrich_prompt(self, prompt: str, role: str = "") -> str:
|
|
"""Enrichir un prompt avec le contexte métier.
|
|
|
|
Args:
|
|
prompt: Le prompt original
|
|
role: Le rôle du VLM (observer, critic, actor, enrichment)
|
|
"""
|
|
parts = []
|
|
|
|
if self.system_prompt:
|
|
parts.append(self.system_prompt)
|
|
|
|
if role:
|
|
role_hint = _ROLE_HINTS.get(role, "")
|
|
if role_hint:
|
|
parts.append(role_hint.format(domain=self.name))
|
|
|
|
parts.append(prompt)
|
|
return "\n\n".join(parts)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Personnalité : résumé d'action en langage métier
|
|
# ------------------------------------------------------------------
|
|
|
|
def summarize_action(
|
|
self,
|
|
action: str,
|
|
params: Optional[Mapping[str, Any]] = None,
|
|
) -> str:
|
|
"""Résumer une action technique en langage métier.
|
|
|
|
Args:
|
|
action: Type d'action ("click", "type", "key_combo", "wait", "scroll")
|
|
params: Paramètres de l'action (target, text, keys, ...)
|
|
|
|
Returns:
|
|
Phrase en français orientée métier. Fallback générique si aucun
|
|
mapping ne correspond.
|
|
|
|
Exemples (domaine tim_codage) :
|
|
click sur "DP" → "saisir le diagnostic principal"
|
|
type "E11.9" → "saisir le code CIM-10 E11.9"
|
|
click sur "Valider" → "valider le codage"
|
|
"""
|
|
params = dict(params or {})
|
|
target = str(params.get("target") or params.get("description") or "").strip()
|
|
text = str(params.get("text") or "").strip()
|
|
keys = params.get("keys") or []
|
|
|
|
haystack = _strip_accents(f"{target} {text}".lower())
|
|
|
|
# 1) Essayer un match mot-clé dans common_actions.
|
|
# Clés sous la forme "click:mot" ou "type:mot".
|
|
# Comparaison insensible à la casse ET aux accents.
|
|
for key, label in self.common_actions.items():
|
|
if ":" not in key:
|
|
continue
|
|
k_action, k_word = key.split(":", 1)
|
|
if k_action != action:
|
|
continue
|
|
k_word_norm = _strip_accents(k_word.lower())
|
|
if k_word_norm and k_word_norm in haystack:
|
|
return label
|
|
|
|
# 2) Essayer une substitution via vocabulary_synonyms dans la cible.
|
|
friendly_target = self._apply_synonyms(target)
|
|
|
|
if action == "click":
|
|
if friendly_target:
|
|
return f"cliquer sur {friendly_target}"
|
|
return "cliquer"
|
|
|
|
if action == "type":
|
|
if text and friendly_target:
|
|
return f"saisir « {text} » dans {friendly_target}"
|
|
if text:
|
|
return f"saisir « {text} »"
|
|
return "saisir du texte"
|
|
|
|
if action == "key_combo":
|
|
if isinstance(keys, (list, tuple)) and keys:
|
|
return f"utiliser le raccourci {'+'.join(str(k) for k in keys)}"
|
|
return "utiliser un raccourci clavier"
|
|
|
|
if action == "wait":
|
|
return "attendre le chargement de l'écran"
|
|
|
|
if action == "scroll":
|
|
return "faire défiler l'écran"
|
|
|
|
# Fallback ultime
|
|
return f"effectuer l'action {action}"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Personnalité : question de clarification
|
|
# ------------------------------------------------------------------
|
|
|
|
def pose_clarification_question(
|
|
self,
|
|
context: Optional[Mapping[str, Any]] = None,
|
|
) -> str:
|
|
"""Générer une question pertinente quand Léa bloque.
|
|
|
|
Cherche un template dans clarification_templates selon :
|
|
- context["blocked_on"] (ex: "target_not_found", "ambiguous_field")
|
|
- context["target"] (la cible visée)
|
|
- paramètres du workflow (params) disponibles pour substitution
|
|
|
|
Args:
|
|
context: Dictionnaire libre contenant au minimum 'blocked_on' ou
|
|
'target'. Peut contenir 'params' pour la substitution.
|
|
|
|
Returns:
|
|
Question en français. Fallback générique si aucun template ne
|
|
correspond.
|
|
"""
|
|
ctx = dict(context or {})
|
|
blocked_on = str(ctx.get("blocked_on") or "").strip()
|
|
target = str(ctx.get("target") or "").strip()
|
|
params = dict(ctx.get("params") or {})
|
|
|
|
# Dictionnaire de substitution : champs du context + params + target
|
|
subs: Dict[str, Any] = {
|
|
"target": target,
|
|
"target_friendly": self._apply_synonyms(target) or target or "cet élément",
|
|
"app": ctx.get("app", ""),
|
|
}
|
|
subs.update(params)
|
|
|
|
# 1) Essai par clé exacte de blocage
|
|
template = self.clarification_templates.get(blocked_on, "")
|
|
|
|
# 2) Essai par cible (si la cible matche un mot-clé connu)
|
|
if not template and target:
|
|
low = target.lower()
|
|
for key, tpl in self.clarification_templates.items():
|
|
if key.startswith("target:") and key.split(":", 1)[1].lower() in low:
|
|
template = tpl
|
|
break
|
|
|
|
# 3) Template générique du domaine
|
|
if not template:
|
|
template = self.clarification_templates.get("default", "")
|
|
|
|
if template:
|
|
try:
|
|
return template.format_map(_SafeDict(subs))
|
|
except Exception as e: # pragma: no cover - format inattendu
|
|
logger.warning("clarification template format error: %s", e)
|
|
|
|
# 4) Fallback ultime cross-domaine
|
|
friendly = subs["target_friendly"]
|
|
return (
|
|
f"Je ne trouve pas {friendly}. "
|
|
f"Peux-tu me le montrer ou me confirmer que c'est le bon écran ?"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Personnalité : rapport final
|
|
# ------------------------------------------------------------------
|
|
|
|
def describe_workflow_outcome(
|
|
self,
|
|
workflow_name: str = "",
|
|
success: bool = True,
|
|
items_count: int = 1,
|
|
failed_count: int = 0,
|
|
elapsed_s: float = 0.0,
|
|
extra: Optional[Mapping[str, Any]] = None,
|
|
use_llm: bool = False,
|
|
) -> str:
|
|
"""Générer un rapport de fin de workflow en langage métier.
|
|
|
|
Args:
|
|
workflow_name: Nom du workflow ("Codage janvier").
|
|
success: True si le workflow a globalement réussi.
|
|
items_count: Nombre d'items traités (ex: 15 dossiers). 1 par défaut.
|
|
failed_count: Nombre d'items en échec.
|
|
elapsed_s: Durée totale (secondes).
|
|
extra: Infos supplémentaires (hint pour le LLM).
|
|
use_llm: Si True, tenter un appel à gemma4 pour produire le
|
|
rapport. Fallback sur les templates en cas d'échec.
|
|
|
|
Returns:
|
|
Rapport en français. Toujours une chaîne, jamais None.
|
|
"""
|
|
extra = dict(extra or {})
|
|
done = max(0, items_count - failed_count)
|
|
|
|
item_sg = self.summary_templates.get("item_singular", "élément")
|
|
item_pl = self.summary_templates.get("item_plural", "éléments")
|
|
item_word = item_sg if done <= 1 else item_pl
|
|
|
|
# Données disponibles pour les templates
|
|
subs = {
|
|
"workflow_name": workflow_name or "le workflow",
|
|
"items_count": items_count,
|
|
"done": done,
|
|
"failed": failed_count,
|
|
"item_singular": item_sg,
|
|
"item_plural": item_pl,
|
|
"item_word": item_word,
|
|
"elapsed_s": int(elapsed_s),
|
|
}
|
|
subs.update(extra)
|
|
|
|
# Choisir le template adéquat
|
|
if not success and failed_count >= items_count:
|
|
key = "failure"
|
|
elif failed_count > 0:
|
|
key = "partial"
|
|
elif items_count == 1:
|
|
key = "success_one" if "success_one" in self.summary_templates else "success"
|
|
else:
|
|
key = "success"
|
|
|
|
template = self.summary_templates.get(key, "")
|
|
|
|
# Optionnel : raffiner via gemma4
|
|
if use_llm:
|
|
llm_text = self._llm_refine_summary(template, subs, success)
|
|
if llm_text:
|
|
return llm_text
|
|
|
|
if template:
|
|
try:
|
|
return template.format_map(_SafeDict(subs))
|
|
except Exception as e: # pragma: no cover
|
|
logger.warning("summary template format error: %s", e)
|
|
|
|
# Fallback générique
|
|
if success:
|
|
if items_count <= 1:
|
|
return f"C'est fait, j'ai terminé « {workflow_name or 'le workflow'} »."
|
|
return (
|
|
f"J'ai traité {done} {item_word} sur {items_count}"
|
|
+ (f", {failed_count} en échec." if failed_count else ".")
|
|
)
|
|
return (
|
|
f"Je n'ai pas pu terminer « {workflow_name or 'le workflow'} ». "
|
|
f"Je te rends la main."
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers internes
|
|
# ------------------------------------------------------------------
|
|
|
|
def _apply_synonyms(self, text: str) -> str:
|
|
"""Remplacer les sigles/termes techniques par leur forme métier.
|
|
|
|
Cherche mots entiers (word boundaries) en insensible à la casse.
|
|
"""
|
|
if not text or not self.vocabulary_synonyms:
|
|
return text
|
|
result = text
|
|
for short, full in self.vocabulary_synonyms.items():
|
|
if not short:
|
|
continue
|
|
pattern = r"\b" + re.escape(short) + r"\b"
|
|
result = re.sub(pattern, full, result, flags=re.IGNORECASE)
|
|
return result
|
|
|
|
def _llm_refine_summary(
|
|
self,
|
|
template: str,
|
|
subs: Dict[str, Any],
|
|
success: bool,
|
|
) -> str:
|
|
"""Tenter un raffinement du rapport via gemma4.
|
|
|
|
Appel best-effort : toute erreur retourne "" et le caller retombe sur
|
|
le template brut. Isolé dans une méthode pour pouvoir le monkey-patcher
|
|
dans les tests.
|
|
"""
|
|
try:
|
|
import requests as _requests
|
|
except Exception:
|
|
return ""
|
|
|
|
port = os.environ.get("GEMMA4_PORT", "11435")
|
|
url = f"http://localhost:{port}/api/chat"
|
|
|
|
base = ""
|
|
if template:
|
|
try:
|
|
base = template.format_map(_SafeDict(subs))
|
|
except Exception:
|
|
base = ""
|
|
|
|
prompt = (
|
|
f"Tu es Léa, une assistante RPA dans le domaine : {self.name}.\n"
|
|
f"Tu viens de terminer un workflow. Résume en UNE à DEUX phrases "
|
|
f"en langage métier, chaleureux mais professionnel, en français.\n\n"
|
|
f"Données :\n"
|
|
f"- workflow : {subs.get('workflow_name', '')}\n"
|
|
f"- items traités : {subs.get('done', 0)} / {subs.get('items_count', 0)}\n"
|
|
f"- échecs : {subs.get('failed', 0)}\n"
|
|
f"- succès global : {'oui' if success else 'non'}\n"
|
|
f"- durée : {subs.get('elapsed_s', 0)}s\n\n"
|
|
f"Base suggérée (tu peux la reformuler) : {base or '(aucune)'}\n\n"
|
|
f"Ta phrase :"
|
|
)
|
|
|
|
try:
|
|
resp = _requests.post(
|
|
url,
|
|
json={
|
|
"model": "gemma4:e4b",
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"stream": False,
|
|
"options": {"temperature": 0.3, "num_predict": 200},
|
|
},
|
|
timeout=30,
|
|
)
|
|
if not resp.ok:
|
|
return ""
|
|
content = resp.json().get("message", {}).get("content", "").strip()
|
|
# Nettoyage basique : supprimer guillemets typographiques en bord
|
|
content = content.strip("\"' \n")
|
|
return content
|
|
except Exception as e:
|
|
logger.debug("gemma4 refine summary failed: %s", e)
|
|
return ""
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"domain_id": self.domain_id,
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"known_apps": self.known_apps,
|
|
"vocabulary_count": len(self.vocabulary),
|
|
"common_actions_count": len(self.common_actions),
|
|
"has_clarification_templates": bool(self.clarification_templates),
|
|
"has_summary_templates": bool(self.summary_templates),
|
|
}
|
|
|
|
|
|
# =========================================================================
|
|
# Utilitaires
|
|
# =========================================================================
|
|
|
|
|
|
class _SafeDict(dict):
|
|
"""dict pour str.format_map qui retourne "" pour les clés manquantes."""
|
|
|
|
def __missing__(self, key): # type: ignore[override]
|
|
return ""
|
|
|
|
|
|
# Hints par rôle VLM — adaptés au contexte métier
|
|
_ROLE_HINTS = {
|
|
"observer": (
|
|
"Tu observes un écran utilisé dans le domaine '{domain}'. "
|
|
"Cherche les popups, erreurs, ou états incohérents avec ce métier."
|
|
),
|
|
"critic": (
|
|
"Tu vérifies qu'une action dans le domaine '{domain}' a produit "
|
|
"le bon résultat. Sois précis sur ce que tu vois à l'écran."
|
|
),
|
|
"actor": (
|
|
"Tu décides si une action est nécessaire dans le contexte '{domain}'. "
|
|
"Utilise ta connaissance du métier pour juger si l'état est cohérent."
|
|
),
|
|
"enrichment": (
|
|
"Tu analyses un enregistrement de workflow dans le domaine '{domain}'. "
|
|
"Décris les intentions métier, pas juste les clics."
|
|
),
|
|
}
|
|
|
|
|
|
# =========================================================================
|
|
# Domaines pré-configurés
|
|
# =========================================================================
|
|
|
|
|
|
_TIM_CODAGE = DomainContext(
|
|
domain_id="tim_codage",
|
|
name="Codage médical TIM",
|
|
description=(
|
|
"Technicien d'Information Médicale : lecture de comptes rendus médicaux, "
|
|
"codage des diagnostics en CIM-10, codage des actes en CCAM, "
|
|
"validation des groupes homogènes de malades (GHM), "
|
|
"gestion des résumés de sortie standardisés (RSS/RSA)."
|
|
),
|
|
system_prompt=(
|
|
"Tu es un assistant expert en codage médical hospitalier. "
|
|
"L'utilisateur est un TIM (Technicien d'Information Médicale) qui utilise "
|
|
"un logiciel DPI (Dossier Patient Informatisé) ou DIM (Département d'Information Médicale). "
|
|
"Son travail : lire les comptes rendus médicaux des patients et coder les diagnostics "
|
|
"en CIM-10, les actes en CCAM, et valider les séjours pour le PMSI.\n\n"
|
|
"Vocabulaire du métier :\n"
|
|
"- DPI/DMS : logiciel de dossier patient (ex: Orbis, DxCare, Crossway, Easily, Hopital Manager)\n"
|
|
"- CIM-10 : Classification Internationale des Maladies, 10ème révision (codes diagnostics)\n"
|
|
"- CCAM : Classification Commune des Actes Médicaux (codes actes chirurgicaux/médicaux)\n"
|
|
"- GHM : Groupe Homogène de Malades (regroupement tarifaire)\n"
|
|
"- RSS : Résumé de Sortie Standardisé (données du séjour)\n"
|
|
"- RSA : Résumé de Sortie Anonyme (RSS anonymisé pour la T2A)\n"
|
|
"- DP : Diagnostic Principal (le code CIM-10 principal du séjour)\n"
|
|
"- DAS : Diagnostics Associés Significatifs\n"
|
|
"- CMA : Complication ou Morbidité Associée (augmente la sévérité)\n"
|
|
"- T2A : Tarification À l'Activité (financement des hôpitaux)\n"
|
|
"- PMSI : Programme de Médicalisation des Systèmes d'Information\n"
|
|
"- UM : Unité Médicale (service hospitalier)\n"
|
|
"- CR : Compte Rendu (document médical)\n\n"
|
|
"Écrans courants :\n"
|
|
"- Liste de patients / dossiers à coder\n"
|
|
"- Fiche patient (identité, séjour, UM)\n"
|
|
"- Écran de codage CIM-10 (recherche de codes, saisie DP/DAS)\n"
|
|
"- Visualiseur de comptes rendus médicaux\n"
|
|
"- Écran de validation / groupage GHM\n"
|
|
"- Recherche de codes (arborescence CIM-10 ou recherche textuelle)"
|
|
),
|
|
vocabulary=[
|
|
"CIM-10", "CCAM", "GHM", "RSS", "RSA", "PMSI", "T2A",
|
|
"diagnostic principal", "DAS", "CMA", "compte rendu",
|
|
"dossier patient", "séjour", "unité médicale", "codage",
|
|
"groupage", "valorisation", "exhaustivité",
|
|
],
|
|
known_apps=[
|
|
"Orbis", "DxCare", "Crossway", "Easily", "Hopital Manager",
|
|
"CORA", "AGFA", "Dedalus", "Maincare", "Softway Medical",
|
|
"WebPIMS", "CEPAGE", "Medimust",
|
|
],
|
|
screen_patterns={
|
|
"liste_patients": "Liste de dossiers patients avec colonnes (nom, prénom, date entrée, UM, statut codage)",
|
|
"fiche_patient": "Fiche d'identité patient avec numéro IPP, séjour, dates, UM",
|
|
"codage_cim10": "Écran de saisie des codes CIM-10 avec diagnostic principal et DAS",
|
|
"compte_rendu": "Visualiseur de compte rendu médical (texte libre, souvent PDF intégré)",
|
|
"recherche_code": "Recherche de code CIM-10 ou CCAM (champ de recherche + arborescence)",
|
|
"validation_ghm": "Écran de validation du groupage avec GHM calculé et valorisation",
|
|
},
|
|
vocabulary_synonyms={
|
|
"DP": "diagnostic principal",
|
|
"DAS": "diagnostics associés",
|
|
"CMA": "complication associée",
|
|
"UM": "unité médicale",
|
|
"CR": "compte rendu",
|
|
"RSS": "résumé de sortie",
|
|
"RSA": "résumé anonymisé",
|
|
"GHM": "groupe homogène de malades",
|
|
"IPP": "identifiant patient",
|
|
},
|
|
common_actions={
|
|
"click:dp": "saisir le diagnostic principal",
|
|
"click:diagnostic principal": "saisir le diagnostic principal",
|
|
"click:das": "ajouter un diagnostic associé",
|
|
"click:ccam": "saisir un acte CCAM",
|
|
"click:valider": "valider le codage",
|
|
"click:valider le codage": "valider le codage",
|
|
"click:grouper": "calculer le GHM",
|
|
"click:ghm": "consulter le groupage GHM",
|
|
"click:dossier patient": "ouvrir le dossier patient",
|
|
"click:fiche patient": "ouvrir la fiche patient",
|
|
"click:compte rendu": "consulter le compte rendu",
|
|
"click:cr": "consulter le compte rendu",
|
|
"click:rechercher": "rechercher un code CIM-10",
|
|
"type:cim": "saisir un code CIM-10",
|
|
},
|
|
clarification_templates={
|
|
"default": (
|
|
"Je ne trouve pas {target_friendly}. "
|
|
"Tu peux me montrer où il se trouve dans le dossier ?"
|
|
),
|
|
"target_not_found": (
|
|
"Je ne trouve pas {target_friendly}. "
|
|
"Le dossier de {nom_patient} est peut-être déjà codé ou archivé ?"
|
|
),
|
|
"target:fichier patient": (
|
|
"Je ne trouve pas le dossier de {nom_patient}. "
|
|
"Il est peut-être archivé ? Tu peux me le montrer ?"
|
|
),
|
|
"target:dossier": (
|
|
"Je ne trouve pas le dossier de {nom_patient}. "
|
|
"Il est peut-être archivé ? Tu peux me le montrer ?"
|
|
),
|
|
"ambiguous_code": (
|
|
"Le compte rendu mentionne plusieurs codes possibles. "
|
|
"Est-ce le code CIM-10 {code_a} ou {code_b} que tu préfères ?"
|
|
),
|
|
"no_cr": (
|
|
"Je ne trouve pas de compte rendu pour {nom_patient}. "
|
|
"Tu veux que je saute ce dossier ou que je continue sans ?"
|
|
),
|
|
},
|
|
summary_templates={
|
|
"item_singular": "dossier",
|
|
"item_plural": "dossiers",
|
|
"success_one": (
|
|
"J'ai codé le dossier de {nom_patient} en {elapsed_s}s. "
|
|
"Tu peux vérifier le groupage GHM."
|
|
),
|
|
"success": (
|
|
"J'ai codé {done} dossiers sur {items_count}. "
|
|
"Tout est passé sans erreur, tu peux valider le groupage."
|
|
),
|
|
"partial": (
|
|
"J'ai codé {done} dossiers sur {items_count}. "
|
|
"{failed} sont en attente — codes CIM-10 ambigus, à valider manuellement."
|
|
),
|
|
"failure": (
|
|
"Je n'ai pas pu coder les dossiers de {workflow_name}. "
|
|
"Je te rends la main, les comptes rendus sont peut-être inaccessibles."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
_COMPTABILITE = DomainContext(
|
|
domain_id="comptabilite",
|
|
name="Comptabilité",
|
|
description=(
|
|
"Comptable : saisie de factures fournisseurs et clients, lettrage, "
|
|
"rapprochement bancaire, déclarations de TVA, bilans, immobilisations."
|
|
),
|
|
system_prompt=(
|
|
"Tu es un assistant expert en comptabilité d'entreprise. "
|
|
"L'utilisateur est un comptable qui utilise un logiciel de saisie comptable "
|
|
"(Sage, Cegid, EBP, Quadra, Isacompta) pour saisir des factures, faire "
|
|
"les rapprochements bancaires, préparer la TVA et les bilans.\n\n"
|
|
"Vocabulaire du métier :\n"
|
|
"- Facture : justificatif de vente ou d'achat (numéro, date, HT, TVA, TTC)\n"
|
|
"- HT/TVA/TTC : montants hors taxes, taxe, toutes taxes\n"
|
|
"- Compte comptable : numéro du plan comptable général (PCG), ex 401 (fournisseurs), 411 (clients)\n"
|
|
"- Journal : journal de saisie (achats, ventes, banque, OD)\n"
|
|
"- Lettrage : association d'une facture avec son paiement\n"
|
|
"- Rapprochement : comparaison compte comptable / relevé bancaire\n"
|
|
"- OCR / LAD : reconnaissance automatique des factures scannées\n"
|
|
"- Écriture : ligne comptable (débit/crédit)\n"
|
|
"- Exercice : période comptable annuelle\n"
|
|
"- Bilan / compte de résultat : états financiers\n"
|
|
"- CA : chiffre d'affaires\n\n"
|
|
"Écrans courants :\n"
|
|
"- Saisie d'écritures (numéro de compte, libellé, débit, crédit)\n"
|
|
"- Import OCR de factures fournisseurs\n"
|
|
"- Lettrage / rapprochement\n"
|
|
"- Brouillard / journal\n"
|
|
"- Balance / grand livre"
|
|
),
|
|
vocabulary=[
|
|
"facture", "HT", "TVA", "TTC", "compte", "journal", "lettrage",
|
|
"rapprochement", "OCR", "LAD", "écriture", "débit", "crédit",
|
|
"exercice", "bilan", "compte de résultat", "CA", "immobilisation",
|
|
"fournisseur", "client", "PCG", "plan comptable",
|
|
],
|
|
known_apps=[
|
|
"Sage", "Cegid", "EBP", "Quadra", "Isacompta", "Ciel Compta",
|
|
"Odoo", "Pennylane", "Dext", "Agicap",
|
|
],
|
|
screen_patterns={
|
|
"saisie_ecriture": "Saisie d'écriture comptable (compte, libellé, débit, crédit)",
|
|
"ocr_facture": "Import OCR : zone image + champs extraits (numéro, date, HT, TVA, TTC, fournisseur)",
|
|
"lettrage": "Liste d'écritures à lettrer (débit vs crédit)",
|
|
"rapprochement": "Comparaison compte banque / relevé",
|
|
"balance": "Balance comptable (comptes agrégés avec soldes)",
|
|
},
|
|
vocabulary_synonyms={
|
|
"HT": "montant hors taxes",
|
|
"TVA": "montant de TVA",
|
|
"TTC": "montant toutes taxes",
|
|
"CA": "chiffre d'affaires",
|
|
"PCG": "plan comptable général",
|
|
"OD": "opération diverse",
|
|
},
|
|
common_actions={
|
|
"click:valider": "valider l'écriture",
|
|
"click:enregistrer": "enregistrer la saisie",
|
|
"click:lettrer": "lettrer les écritures",
|
|
"click:rapprocher": "rapprocher avec la banque",
|
|
"click:ocr": "lancer la reconnaissance OCR",
|
|
"click:facture": "ouvrir la facture",
|
|
"click:compte": "sélectionner le compte comptable",
|
|
"type:ht": "saisir le montant hors taxes",
|
|
"type:tva": "saisir le montant de TVA",
|
|
"type:ttc": "saisir le montant toutes taxes",
|
|
},
|
|
clarification_templates={
|
|
"default": (
|
|
"Je ne trouve pas {target_friendly}. "
|
|
"C'est bien la facture {num_facture} que tu veux saisir ?"
|
|
),
|
|
"target_not_found": (
|
|
"Je ne trouve pas le champ {target_friendly}. "
|
|
"C'est bien la facture {num_facture} qui doit être saisie ?"
|
|
),
|
|
"target:montant": (
|
|
"Je ne trouve pas le champ « Montant HT ». "
|
|
"C'est bien la facture {num_facture} que tu veux saisir ?"
|
|
),
|
|
"target:tva": (
|
|
"Je ne trouve pas le champ TVA. Est-ce une facture à taux {taux_tva} % ?"
|
|
),
|
|
"ambiguous_account": (
|
|
"Je ne sais pas sur quel compte imputer : {compte_a} ou {compte_b} ?"
|
|
),
|
|
},
|
|
summary_templates={
|
|
"item_singular": "facture",
|
|
"item_plural": "factures",
|
|
"success_one": (
|
|
"J'ai saisi la facture {num_facture} en {elapsed_s}s."
|
|
),
|
|
"success": (
|
|
"J'ai saisi {done} factures sur {items_count}. "
|
|
"Tout est en brouillard, tu peux valider."
|
|
),
|
|
"partial": (
|
|
"J'ai saisi {done} factures sur {items_count}. "
|
|
"{failed} factures sont en attente — imputation comptable à vérifier."
|
|
),
|
|
"failure": (
|
|
"Je n'ai pas pu saisir les factures de {workflow_name}. "
|
|
"L'OCR n'a peut-être pas fonctionné, je te rends la main."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
_RH_PAIE = DomainContext(
|
|
domain_id="rh_paie",
|
|
name="Ressources humaines et paie",
|
|
description=(
|
|
"Gestionnaire RH / paie : fiches employés, contrats, bulletins de salaire, "
|
|
"déclarations sociales (DSN), charges, congés, absences."
|
|
),
|
|
system_prompt=(
|
|
"Tu es un assistant expert en gestion RH et paie française. "
|
|
"L'utilisateur est un gestionnaire RH ou de paie qui utilise un logiciel "
|
|
"(Silae, Sage Paie, Cegid, ADP, PayFit) pour éditer des bulletins de salaire, "
|
|
"gérer les contrats, les absences, et envoyer les DSN.\n\n"
|
|
"Vocabulaire du métier :\n"
|
|
"- Bulletin de paie : fiche de salaire mensuelle\n"
|
|
"- DSN : Déclaration Sociale Nominative (mensuelle, transmise à l'URSSAF)\n"
|
|
"- Brut / Net : salaire avant et après charges\n"
|
|
"- Charges sociales / patronales : cotisations employeur et salarié\n"
|
|
"- CDI / CDD : types de contrats\n"
|
|
"- Période de paie : mois concerné par le bulletin\n"
|
|
"- SMIC : salaire minimum\n"
|
|
"- IJSS : indemnités journalières sécurité sociale\n"
|
|
"- Congés payés : solde de congés\n"
|
|
"- RTT : réduction du temps de travail\n"
|
|
"- Saisie sur salaire : retenue judiciaire\n"
|
|
"- Solde de tout compte : dernier bulletin d'un salarié qui part\n\n"
|
|
"Écrans courants :\n"
|
|
"- Fiche employé (identité, contrat, poste, salaire)\n"
|
|
"- Saisie des variables (heures, absences, primes)\n"
|
|
"- Bulletin de paie (aperçu avant validation)\n"
|
|
"- Déclaration DSN\n"
|
|
"- Gestion des absences / congés"
|
|
),
|
|
vocabulary=[
|
|
"bulletin", "salaire", "brut", "net", "charges sociales", "DSN",
|
|
"CDI", "CDD", "congés", "RTT", "SMIC", "IJSS", "URSSAF",
|
|
"employé", "salarié", "contrat", "prime", "heures supplémentaires",
|
|
"absence", "solde de tout compte", "STC",
|
|
],
|
|
known_apps=[
|
|
"Silae", "Sage Paie", "Cegid Paie", "ADP", "PayFit", "Nibelis",
|
|
"Cegedim SRH", "Lucca", "HR Access",
|
|
],
|
|
screen_patterns={
|
|
"fiche_employe": "Fiche employé avec identité, contrat, poste",
|
|
"saisie_variables": "Saisie des variables de paie (heures, absences, primes)",
|
|
"apercu_bulletin": "Aperçu du bulletin de paie avant validation",
|
|
"dsn": "Écran DSN (déclaration sociale nominative)",
|
|
"conges": "Gestion des absences et congés",
|
|
},
|
|
vocabulary_synonyms={
|
|
"DSN": "déclaration sociale",
|
|
"RTT": "réduction du temps de travail",
|
|
"STC": "solde de tout compte",
|
|
"IJSS": "indemnités journalières",
|
|
"CP": "congés payés",
|
|
},
|
|
common_actions={
|
|
"click:valider": "valider le bulletin",
|
|
"click:editer": "éditer le bulletin",
|
|
"click:bulletin": "ouvrir le bulletin de paie",
|
|
"click:employe": "ouvrir la fiche employé",
|
|
"click:dsn": "lancer la DSN",
|
|
"click:conges": "gérer les congés",
|
|
"click:absence": "saisir une absence",
|
|
"type:heures": "saisir les heures travaillées",
|
|
"type:prime": "saisir une prime",
|
|
},
|
|
clarification_templates={
|
|
"default": (
|
|
"Je ne trouve pas {target_friendly} pour {nom_employe}. "
|
|
"Tu peux me confirmer la période de paie ?"
|
|
),
|
|
"target_not_found": (
|
|
"Je ne trouve pas {target_friendly} dans la fiche de {nom_employe}. "
|
|
"Le contrat est peut-être clôturé ?"
|
|
),
|
|
"target:employe": (
|
|
"Je ne trouve pas {nom_employe} dans la liste. "
|
|
"Est-il encore actif dans l'entreprise ?"
|
|
),
|
|
"ambiguous_period": (
|
|
"Est-ce la période {periode_a} ou {periode_b} que tu veux traiter ?"
|
|
),
|
|
},
|
|
summary_templates={
|
|
"item_singular": "bulletin",
|
|
"item_plural": "bulletins",
|
|
"success_one": (
|
|
"J'ai édité le bulletin de {nom_employe} en {elapsed_s}s."
|
|
),
|
|
"success": (
|
|
"J'ai édité {done} bulletins sur {items_count}. "
|
|
"La paie est prête pour validation."
|
|
),
|
|
"partial": (
|
|
"J'ai édité {done} bulletins sur {items_count}. "
|
|
"{failed} sont en attente — variables de paie à compléter."
|
|
),
|
|
"failure": (
|
|
"Je n'ai pas pu éditer les bulletins de {workflow_name}. "
|
|
"Il y a peut-être un blocage côté logiciel de paie."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
_STOCKS_LOGISTIQUE = DomainContext(
|
|
domain_id="stocks_logistique",
|
|
name="Stocks et logistique",
|
|
description=(
|
|
"Gestionnaire de stocks / logistique : bons de commande, bons de livraison, "
|
|
"réceptions, inventaires, mouvements de stock, expéditions."
|
|
),
|
|
system_prompt=(
|
|
"Tu es un assistant expert en gestion de stocks et logistique. "
|
|
"L'utilisateur utilise un ERP ou WMS (SAP, Dynamics, Odoo, Sage, Divalto) "
|
|
"pour gérer les commandes, les réceptions, les expéditions et les inventaires.\n\n"
|
|
"Vocabulaire du métier :\n"
|
|
"- BC : Bon de Commande (achat ou vente)\n"
|
|
"- BL : Bon de Livraison\n"
|
|
"- BR : Bon de Réception\n"
|
|
"- Article / Référence / SKU : produit en stock\n"
|
|
"- Emplacement : localisation physique (allée, rayon, emplacement)\n"
|
|
"- Mouvement de stock : entrée, sortie, transfert\n"
|
|
"- Inventaire : comptage physique pour recaler le stock théorique\n"
|
|
"- FIFO / LIFO : ordre de sortie des stocks\n"
|
|
"- ERP : progiciel de gestion intégré\n"
|
|
"- WMS : Warehouse Management System\n"
|
|
"- Picking : préparation de commande\n"
|
|
"- Quantité en stock / disponible / réservée\n\n"
|
|
"Écrans courants :\n"
|
|
"- Saisie de bon de commande / réception\n"
|
|
"- Liste des articles (avec photo, quantité, emplacement)\n"
|
|
"- Inventaire (comptage)\n"
|
|
"- Mouvements de stock\n"
|
|
"- Picking list (liste de préparation)"
|
|
),
|
|
vocabulary=[
|
|
"bon de commande", "BC", "bon de livraison", "BL", "bon de réception", "BR",
|
|
"article", "référence", "SKU", "emplacement", "stock", "inventaire",
|
|
"mouvement", "entrée", "sortie", "picking", "FIFO", "LIFO", "ERP", "WMS",
|
|
"fournisseur", "client", "quantité", "disponible", "réservé",
|
|
],
|
|
known_apps=[
|
|
"SAP", "Dynamics", "Odoo", "Sage X3", "Divalto", "Cegid",
|
|
"Oracle NetSuite", "Reflex WMS", "Infolog",
|
|
],
|
|
screen_patterns={
|
|
"bon_commande": "Saisie de bon de commande (fournisseur, lignes d'articles, quantités)",
|
|
"reception": "Bon de réception (rapprochement avec la commande)",
|
|
"inventaire": "Saisie d'inventaire (article, emplacement, quantité comptée)",
|
|
"picking": "Liste de préparation avec articles et emplacements",
|
|
"mouvement": "Mouvement de stock (entrée/sortie/transfert)",
|
|
},
|
|
vocabulary_synonyms={
|
|
"BC": "bon de commande",
|
|
"BL": "bon de livraison",
|
|
"BR": "bon de réception",
|
|
"SKU": "référence produit",
|
|
"WMS": "gestion d'entrepôt",
|
|
"ERP": "progiciel de gestion",
|
|
},
|
|
common_actions={
|
|
"click:valider": "valider le bon",
|
|
"click:commande": "ouvrir le bon de commande",
|
|
"click:livraison": "ouvrir le bon de livraison",
|
|
"click:reception": "saisir la réception",
|
|
"click:inventaire": "démarrer l'inventaire",
|
|
"click:article": "sélectionner un article",
|
|
"click:picking": "démarrer la préparation",
|
|
"type:quantite": "saisir la quantité",
|
|
"type:reference": "saisir la référence article",
|
|
},
|
|
clarification_templates={
|
|
"default": (
|
|
"Je ne trouve pas {target_friendly}. "
|
|
"C'est bien la commande {num_bc} qu'on traite ?"
|
|
),
|
|
"target_not_found": (
|
|
"Je ne trouve pas {target_friendly}. "
|
|
"La commande {num_bc} est peut-être déjà clôturée ?"
|
|
),
|
|
"target:article": (
|
|
"Je ne trouve pas l'article {ref_article}. "
|
|
"Il est peut-être archivé ou mal référencé ?"
|
|
),
|
|
"quantity_mismatch": (
|
|
"La quantité reçue ({qte_recue}) ne correspond pas à la commande "
|
|
"({qte_commandee}). Je saisis un écart ou tu vérifies ?"
|
|
),
|
|
},
|
|
summary_templates={
|
|
"item_singular": "bon",
|
|
"item_plural": "bons",
|
|
"success_one": (
|
|
"J'ai traité le bon {num_bc} en {elapsed_s}s."
|
|
),
|
|
"success": (
|
|
"J'ai traité {done} bons sur {items_count}. "
|
|
"Les mouvements de stock sont validés."
|
|
),
|
|
"partial": (
|
|
"J'ai traité {done} bons sur {items_count}. "
|
|
"{failed} bons sont en attente — écarts de quantité à vérifier."
|
|
),
|
|
"failure": (
|
|
"Je n'ai pas pu traiter les bons de {workflow_name}. "
|
|
"L'ERP a peut-être refusé une ligne, je te rends la main."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
_GENERIC = DomainContext(
|
|
domain_id="generic",
|
|
name="Bureautique générale",
|
|
description="Automatisation bureautique générale (Office, navigateur, etc.)",
|
|
system_prompt=(
|
|
"Tu es un assistant RPA qui observe des applications bureautiques. "
|
|
"Décris précisément ce que tu vois à l'écran."
|
|
),
|
|
summary_templates={
|
|
"item_singular": "action",
|
|
"item_plural": "actions",
|
|
"success_one": "C'est fait, j'ai terminé « {workflow_name} » en {elapsed_s}s.",
|
|
"success": (
|
|
"J'ai terminé « {workflow_name} » : {done} {item_word} exécutées "
|
|
"sur {items_count}."
|
|
),
|
|
"partial": (
|
|
"J'ai terminé « {workflow_name} » partiellement : "
|
|
"{done} {item_word} sur {items_count} ({failed} en échec)."
|
|
),
|
|
"failure": (
|
|
"Je n'ai pas pu terminer « {workflow_name} ». Je te rends la main."
|
|
),
|
|
},
|
|
clarification_templates={
|
|
"default": (
|
|
"Je ne trouve pas {target_friendly} à l'écran. "
|
|
"Tu peux me le montrer ?"
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
# Registre des domaines disponibles
|
|
_DOMAINS: Dict[str, DomainContext] = {
|
|
"tim_codage": _TIM_CODAGE,
|
|
"comptabilite": _COMPTABILITE,
|
|
"rh_paie": _RH_PAIE,
|
|
"stocks_logistique": _STOCKS_LOGISTIQUE,
|
|
"generic": _GENERIC,
|
|
}
|
|
|
|
|
|
def get_domain_context(domain_id: str = "generic") -> DomainContext:
|
|
"""Récupérer le contexte métier par ID.
|
|
|
|
Args:
|
|
domain_id: Identifiant du domaine (tim_codage, comptabilite, rh_paie,
|
|
stocks_logistique, generic, etc.)
|
|
|
|
Returns:
|
|
DomainContext correspondant, ou generic si non trouvé.
|
|
"""
|
|
ctx = _DOMAINS.get(domain_id, _GENERIC)
|
|
if ctx is _GENERIC and domain_id != "generic":
|
|
logger.warning(f"Domaine '{domain_id}' non trouvé, utilisation de 'generic'")
|
|
return ctx
|
|
|
|
|
|
def register_domain(context: DomainContext) -> None:
|
|
"""Enregistrer un nouveau domaine métier."""
|
|
_DOMAINS[context.domain_id] = context
|
|
logger.info(f"Domaine '{context.domain_id}' enregistré ({context.name})")
|
|
|
|
|
|
def list_domains() -> List[Dict[str, Any]]:
|
|
"""Lister tous les domaines disponibles."""
|
|
return [ctx.to_dict() for ctx in _DOMAINS.values()]
|