emmixam/ai_workflow_discovery_agent
0
1from __future__ import annotations2 3from typing import Any, Iterable, Optional4 5from schemas import RapportAdvisor, WorkflowOptimise6 7 8class AdvisorValidationError(ValueError):9 """Erreur de validation métier sur la sortie Advisor."""10 11 12def _require_non_empty_str(value: Any, field_name: str) -> None:13 if not isinstance(value, str) or not value.strip():14 raise AdvisorValidationError(f"{field_name} vide ou invalide.")15 16 17def _normalize_catalog_names(tool_catalog: Iterable[Any]) -> set[str]:18 """19 Accepte soit :20 - un set/list de noms d'outils21 - une liste de dicts issus de tools_catalog.json avec clé 'name'22 """23 names: set[str] = set()24 25 for item in tool_catalog:26 if isinstance(item, str):27 name = item.strip()28 if name:29 names.add(name)30 elif isinstance(item, dict):31 raw_name = item.get("name")32 if isinstance(raw_name, str) and raw_name.strip():33 names.add(raw_name.strip())34 else:35 raise AdvisorValidationError(36 f"Entrée de catalogue non supportée: {type(item)!r}"37 )38 39 if not names:40 raise AdvisorValidationError("Le catalogue d'outils est vide ou illisible.")41 42 return names43 44 45def _validate_workflow_refs(report: RapportAdvisor, workflow: WorkflowOptimise) -> None:46 node_ids = {n.id for n in workflow.noeuds}47 node_labels = {n.label for n in workflow.noeuds}48 49 if not report.recommandations:50 raise AdvisorValidationError("Aucune recommandation retournée par l'Advisor.")51 52 seen_node_ids: set[str] = set()53 54 for rec in report.recommandations:55 _require_non_empty_str(rec.noeud_id, "recommandations[].noeud_id")56 _require_non_empty_str(rec.noeud_label, f"recommandation {rec.noeud_id}.noeud_label")57 58 if rec.noeud_id not in node_ids:59 raise AdvisorValidationError(60 f"Recommandation liée à un noeud inconnu: {rec.noeud_id}"61 )62 63 if rec.noeud_label not in node_labels:64 raise AdvisorValidationError(65 f"noeud_label inconnu ou incohérent avec le workflow: {rec.noeud_label}"66 )67 68 if rec.noeud_id in seen_node_ids:69 raise AdvisorValidationError(70 f"Recommandations dupliquées pour le noeud {rec.noeud_id}"71 )72 seen_node_ids.add(rec.noeud_id)73 74 75def _validate_tools_against_catalog(76 report: RapportAdvisor,77 catalog_names: set[str],78) -> None:79 for rec in report.recommandations:80 if not rec.outils:81 raise AdvisorValidationError(82 f"Aucun outil recommandé pour le noeud {rec.noeud_id}"83 )84 85 seen_tools_for_node: set[str] = set()86 87 for outil in rec.outils:88 _require_non_empty_str(outil.nom, f"{rec.noeud_id}.outils[].nom")89 _require_non_empty_str(outil.justification, f"{rec.noeud_id}.outils[{outil.nom}].justification")90 91 if outil.nom not in catalog_names:92 raise AdvisorValidationError(93 f"Outil hors catalogue détecté: {outil.nom}"94 )95 96 if outil.nom in seen_tools_for_node:97 raise AdvisorValidationError(98 f"Outil dupliqué pour le noeud {rec.noeud_id}: {outil.nom}"99 )100 seen_tools_for_node.add(outil.nom)101 102 103def _validate_stack_fields(report: RapportAdvisor, catalog_names: set[str]) -> None:104 if not report.stack_minimale:105 raise AdvisorValidationError("stack_minimale vide.")106 if not report.stack_complete:107 raise AdvisorValidationError("stack_complete vide.")108 109 for name in report.stack_minimale:110 _require_non_empty_str(name, "stack_minimale[]")111 if name not in catalog_names:112 raise AdvisorValidationError(f"Outil hors catalogue dans stack_minimale: {name}")113 114 for name in report.stack_complete:115 _require_non_empty_str(name, "stack_complete[]")116 if name not in catalog_names:117 raise AdvisorValidationError(f"Outil hors catalogue dans stack_complete: {name}")118 119 if len(report.stack_minimale) != len(set(report.stack_minimale)):120 raise AdvisorValidationError("Doublons détectés dans stack_minimale.")121 122 if len(report.stack_complete) != len(set(report.stack_complete)):123 raise AdvisorValidationError("Doublons détectés dans stack_complete.")124 125 if not set(report.stack_minimale).issubset(set(report.stack_complete)):126 raise AdvisorValidationError(127 "stack_minimale doit être incluse dans stack_complete."128 )129 130 131def _validate_implementation_guidance(report: RapportAdvisor) -> None:132 _require_non_empty_str(report.conseil_implementation, "conseil_implementation")133 134 135def _validate_recommendation_coverage(136 report: RapportAdvisor,137 workflow: WorkflowOptimise,138) -> None:139 """140 Version volontairement stricte :141 - on exige une recommandation pour chaque noeud non trivial du workflow142 - on ignore trigger et fin143 """144 expected_ids = {145 n.id for n in workflow.noeuds146 if n.type_noeud not in {"trigger", "fin"}147 }148 got_ids = {rec.noeud_id for rec in report.recommandations}149 150 missing = expected_ids - got_ids151 if missing:152 raise AdvisorValidationError(153 f"Certains noeuds du workflow n'ont aucune recommandation: {sorted(missing)}"154 )155 156 157def validate_advisor_report_or_raise(158 report: RapportAdvisor,159 workflow: WorkflowOptimise,160 tool_catalog: Iterable[Any],161) -> None:162 """163 Validation métier complète de la sortie Advisor.164 165 Vérifie :166 - cohérence des références vers le workflow167 - conformité des outils au catalogue autorisé168 - cohérence de stack_minimale / stack_complete169 - présence de recommandations pour les noeuds utiles170 """171 if not isinstance(report, RapportAdvisor):172 raise AdvisorValidationError(173 f"RapportAdvisor attendu, reçu: {type(report)!r}"174 )175 176 if not isinstance(workflow, WorkflowOptimise):177 raise AdvisorValidationError(178 f"WorkflowOptimise attendu, reçu: {type(workflow)!r}"179 )180 181 catalog_names = _normalize_catalog_names(tool_catalog)182 183 _validate_workflow_refs(report, workflow)184 _validate_tools_against_catalog(report, catalog_names)185 _validate_stack_fields(report, catalog_names)186 _validate_implementation_guidance(report)187 _validate_recommendation_coverage(report, workflow)188 189 190def audit_advisor_report(191 report: RapportAdvisor,192 workflow: WorkflowOptimise,193 tool_catalog: Optional[Iterable[Any]] = None,194) -> list[str]:195 """196 Audit non bloquant : signale les zones fragiles sans rejeter la sortie.197 """198 warnings: list[str] = []199 200 if len(report.stack_complete) > 8:201 warnings.append("stack_complete volumineuse (>8 outils) : risque de sur-architecture.")202 203 if len(report.stack_minimale) == len(report.stack_complete):204 warnings.append("stack_minimale et stack_complete sont identiques : peu de différenciation de rollout.")205 206 # Détection simple de redondance conceptuelle207 orchestration_keywords = ("hubspot", "notion", "jira", "linear", "airtable", "monday", "clickup")208 orchestration_hits = [209 name for name in report.stack_complete210 if any(k in name.lower() for k in orchestration_keywords)211 ]212 if len(orchestration_hits) > 2:213 warnings.append(214 f"Plusieurs outils centraux potentiels détectés dans la stack_complete : {orchestration_hits}"215 )216 217 if tool_catalog is not None:218 catalog_names = _normalize_catalog_names(tool_catalog)219 unknown_in_complete = [x for x in report.stack_complete if x not in catalog_names]220 if unknown_in_complete:221 warnings.append(222 f"Outils non reconnus dans stack_complete : {unknown_in_complete}"223 )224 225 non_terminal_nodes = [226 n for n in workflow.noeuds if n.type_noeud not in {"trigger", "fin"}227 ]228 if len(report.recommandations) < max(1, len(non_terminal_nodes) // 2):229 warnings.append(230 "Couverture de recommandations faible par rapport au nombre de noeuds du workflow."231 )232 233 if len(report.conseil_implementation.split()) < 12:234 warnings.append("conseil_implementation très court : justification potentiellement insuffisante.")235 236 return warnings237 