# 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()]