Python 3.10+ ; aucune dépendance requise (stdlib uniquement, zero appel LLM, zero submodule)
Bases d’algorithme (parcours de graphe, dictionnaire)
Note anti-theatre : les orchestrateurs de production EPITA (vrais workflow_dsl.py en DAG et conversational_orchestrator.py en AgentGroupChat) vivent dans un submodule de 1.1 Go et exigent semantic_kernel + un service LLM. Ce rung distille les deux paradigmes dans des implementations auto-contenues qui s’executent reellement (Python pur, déterministe) sur un texte synthetique neutre. Aucune sortie n’est simulee : le tri topologique, le routage par etat et les handlers tournent vraiment. C’est le même geste qu’au rung 1 (vrai CSV + detecteur simplifie, LLM differe).
Confidentialite : tous les exemples sont synthetiques et neutres (phrases abstraites sur des “éléments A”, un “professeur X”). Aucun corpus EPITA, aucun nom reel, aucun texte sensible. Le depot est public sur GitHub.
1. Introduction : pourquoi orchestrer ?
Le rung 1-informal extrait le tissu argumentatif d’un texte. Le rung 2-formal verifie la validite logique d’un argument encode. Mais un analyseur realiste enchaine ces briques : extraire, detecter les sophismes, synthetiser un rapport. Cette composition est l’orchestration.
Il existe deux grands paradigmes d’orchestration, et la production EPITA les implemente tous les deux :
Paradigme
Idee
Implementation EPITA
A - Pipeline DAG
Les phases sont fixees a l’avance, ordonnees par dependances, executees en tri topologique
workflow_dsl.py (DSL declaratif -> DAG)
B - Conversationnel
Un “Project Manager” choisit dynamiquement le prochain agent selon l’etat courant
conversational_orchestrator.py (AgentGroupChat de Semantic Kernel)
Ce que ce rung fait (et ne fait pas)
Ce notebook ne charge pas le submodule EPITA (1.1 Go) et n’appelle pas de LLM. Il construit a la place deux mini-implementations des paradigmes A et B, qui s’executent reellement sur un texte synthetique. La comparaison pedagogique (determinisme, cout, forme de sortie, terminaison) est exactement celle qui se pose entre les versions de production - simplement sur un jouet déterministe.
Couche
Rung 1
Rung 2
Rung 3 (ce notebook)
Extraction informelle
Detecteur mots-cles
(donnee en entree)
Handler déterministe
Verification formelle
(n/a)
Tweety (JVM)
Differree (rung 2)
Composition
(n/a)
(n/a)
DAG + conversationnel
2. Les briques : handlers déterministes reutilises
Les deux paradigmes orchestrent les mêmes briques elementaires (mini-agents). On les définit une fois, puis on les reutilise. Chaque handler est volontairement simple et déterministe : son but est de faire avancer un etat (dictionnaire Python), pas d’atteindre une haute precision.
extract_arguments(text) : decoupe le texte en phrases (sur le separateur .) et retourne la liste des phrases non vides.
detect_fallacies(sentences) : reapplique l’idee du detecteur mots-cles du rung 1 sur chaque phrase, via une petite table.
synthesize_report(arguments, fallacies) : agrege en un dictionnaire de comptes.
Ces handlers sont les ancetres déterministes des agents LLM de la production. Dans le vrai pipeline, extract_arguments serait un appel LLM ; ici c’est un split('.'). La forme de l’orchestration (DAG vs conversationnel) est independante de la nature de la brique - c’est tout l’intérêt de la comparaison.
# Definition des handlers deterministes + smoke test.# Chaque handler prend / augmente un "etat" (dict). Aucun LLM, aucun reseau.# Petite table mots-cles (heritee du rung 1, raccourcie pour ce rung).FALLACY_KEYWORDS = {"Generalisation abusive": ["tous les", "toujours", "jamais", "aucun"],"Ad hominem": ["vous etes", "incompetent", "malhonnete"],"Appel a l autorite": ["expert", "professeur", "autorite"],"Faux dilemme": ["soit ... soit", "le seul choix"],}# Texte synthetique neutre : pas de personne reelle, pas d opinion politique.TEXTE_SYNTHETIQUE = ("Tous les elements A presentent le meme defaut. ""Le professeur X confirme cette conclusion. ""Vous etes incompetent pour contester cette position.")def extract_arguments(text):"""Decoupe le texte en phrases sur le separateur '.' (handler deterministe).""" parts = [p.strip() for p in text.split('.')]return [p for p in parts if p]def detect_fallacies(sentences):"""Matching mots-cles insensible a la casse sur chaque phrase.""" matches = []for sent in sentences: sl = sent.lower()for fallacy, keywords in FALLACY_KEYWORDS.items():for kw in keywords:if kw in sl: matches.append({"sentence": sent,"fallacy": fallacy,"keyword": kw, })break# un seul trigger par sophisme par phrasereturn matchesdef synthesize_report(arguments, fallacies):"""Agrege les comptes (handler deterministe)."""return {"n_arguments": len(arguments),"n_fallacies": len(fallacies),"fallacy_types": sorted({f["fallacy"] for f in fallacies}), }# Smoke test : chaque handler tourne isolement sur le texte synthetique.args = extract_arguments(TEXTE_SYNTHETIQUE)falls = detect_fallacies(args)report = synthesize_report(args, falls)print("--- Smoke test des handlers ---")print(f"TEXTE : {TEXTE_SYNTHETIQUE}")print(f"arguments : {len(args)} phrase(s)")for a in args:print(f" - {a}")print(f"sophismes : {len(falls)} detection(s)")for f in falls:print(f" - {f['fallacy']:<25} trigger='{f['keyword']}'")print(f"rapport : {report}")
--- Smoke test des handlers ---
TEXTE : Tous les elements A presentent le meme defaut. Le professeur X confirme cette conclusion. Vous etes incompetent pour contester cette position.
arguments : 3 phrase(s)
- Tous les elements A presentent le meme defaut
- Le professeur X confirme cette conclusion
- Vous etes incompetent pour contester cette position
sophismes : 3 detection(s)
- Generalisation abusive trigger='tous les'
- Appel a l autorite trigger='professeur'
- Ad hominem trigger='vous etes'
rapport : {'n_arguments': 3, 'n_fallacies': 3, 'fallacy_types': ['Ad hominem', 'Appel a l autorite', 'Generalisation abusive']}
3 detections (Generalisation, Appel autorite, Ad hominem)
synthesize_report
Listes arguments + sophismes
Dict de comptes
Points cles :
Les handlers sont purs et déterministes : même entree donne même sortie, aucun alea.
Ils communiquent uniquement par leurs entrees/sorties, pas par effet de bord global. C’est ce qui permet de les orchestrer aussi bien dans un DAG que dans une conversation.
La table mots-cles porte la même limite qu’au rung 1 (pas de contexte, faible precision). L’orchestration n’y change rien : elle compose, elle n’ameliorait pas la precision d’une brique.
3. Paradigme A - Pipeline DAG (déterministe)
Un DAG (Directed Acyclic Graph) d’orchestration declare les phases et leurs dependances, puis un executeur les lance dans un ordre topologiquement valide : une phase ne demarre qu’une fois toutes ses dependances terminees. C’est le modèle de workflow_dsl.py en production.
On ne peut pas se contenter de l’ordre de declaration : une phase peut etre declaree avant une autre tout en dependent d’elle. L’algorithme de Kahn produit un ordre valide : on part des phases sans dépendance (degré entrant nul), on les “consomme”, on decremente le degré entrant de leurs successeurs, et on recommence. Si a la fin il reste des phases non consommees, c’est qu’il y a un cycle - on echoue bruyamment (un DAG cyclique n’est pas un DAG).
# Tri topologique (algorithme de Kahn) sur la liste de phases.from collections import dequedef topological_order(phases):"""Retourne un ordre d'execution valide d'une liste de phases (Kahn). Chaque phase est un dict avec 'name' et 'depends_on' (liste de noms). Leve ValueError si une dependance est inconnue ou si un cycle est detecte. """ names = [p["name"] for p in phases] name_set =set(names)# Verification : toute dependance doit exister.for p in phases:for dep in p.get("depends_on", []):if dep notin name_set:raiseValueError(f"Phase '{p['name']}' depend de '{dep}' qui n'existe pas." )# Degre entrant + graphe d'adjacence (dependant -> dependants). indeg = {n: 0for n in names} adj = {n: [] for n in names}for p in phases:for dep in p.get("depends_on", []): adj[dep].append(p["name"]) indeg[p["name"]] +=1# File des phases de degre entrant nul (pretes a demarrer). q = deque([n for n in names if indeg[n] ==0]) order = []while q: n = q.popleft() order.append(n)for m in adj[n]: indeg[m] -=1if indeg[m] ==0: q.append(m)iflen(order) !=len(names): bloquees = [n for n in names if n notin order]raiseValueError(f"Cycle detecte dans le DAG ; phases bloquees : {bloquees}")return order# Demo : phases declarees dans un ordre volontairement non topologique._DEMO_PHASES = [ {"name": "detect_fallacies", "depends_on": ["extract_arguments"]}, {"name": "extract_arguments", "depends_on": []}, {"name": "synthesize_report", "depends_on": ["extract_arguments", "detect_fallacies"]},]print("--- Tri topologique (demo) ---")print("Ordre de declaration : detect_fallacies, extract_arguments, synthesize_report")ordre = topological_order(_DEMO_PHASES)print(f"Ordre d'execution : {ordre}")print("Attendu : extract_arguments en premier (pas de dependance).")
--- Tri topologique (demo) ---
Ordre de declaration : detect_fallacies, extract_arguments, synthesize_report
Ordre d'execution : ['extract_arguments', 'detect_fallacies', 'synthesize_report']
Attendu : extract_arguments en premier (pas de dependance).
Interpretation : ordre topologique
Sortie obtenue : ['extract_arguments', 'detect_fallacies', 'synthesize_report'] - un ordre valide bien que les phases aient ete declarees dans un autre ordre.
Phase
Depend de
Peut demarrer après
extract_arguments
(rien)
immediatement
detect_fallacies
extract_arguments
extraction terminee
synthesize_report
extraction + detection
les deux terminees
Point cle : le tri topologique reordonnance automatiquement. Si vous ajoutez demain une phase formal_check qui depend de detect_fallacies, vous n’avez pas a reordonner a la main - l’algorithme s’en charge. C’est toute la valeur d’un executeur DAG par rapport a une suite d’appels ecrits en dur.
3.2 L’executeur DAG
L’executeur applique l’ordre topologique : pour chaque phase, il appelle son handler en lui passant l’etat accumule (un dictionnaire partage que chaque handler enrichit), mesure la duree, et enregistre un résultat par phase. La convention est qu’un handler ecrit sa sortie dans state[nom_de_la_phase].
# Executeur DAG + execution sur le texte synthetique.import timedef handler_extract(state):"""Phase d'extraction : ecrit la liste des phrases dans state['extract_arguments'].""" state["extract_arguments"] = extract_arguments(state["text"])def handler_detect(state):"""Phase de detection : ecrit les sophismes dans state['detect_fallacies'].""" state["detect_fallacies"] = detect_fallacies(state.get("extract_arguments", []))def handler_synthesize(state):"""Phase de synthese : ecrit le rapport agrege dans state['synthesize_report'].""" state["synthesize_report"] = synthesize_report( state.get("extract_arguments", []), state.get("detect_fallacies", []), )def execute_dag(phases, text):"""Execute un DAG de phases sur un texte. Retourne un resultat structure. Convention : chaque handler ecrit state['<nom_phase>']. """ t0 = time.perf_counter() order = topological_order(phases) name_to_phase = {p["name"]: p for p in phases} state = {"text": text} results = {}for name in order: handler = name_to_phase[name]["handler"] pt0 = time.perf_counter() handler(state) dt = time.perf_counter() - pt0 results[name] = {"status": "COMPLETED","output": state.get(name),"duration_s": round(dt, 6), } total = time.perf_counter() - t0 summary = synthesize_report( state.get("extract_arguments", []), state.get("detect_fallacies", []), )return {"workflow_name": "dag","execution_order": order,"phases": results,"summary": summary,"duration_s": round(total, 6), }# Definition du DAG : 3 phases lineaires par dependances.PHASES = [ {"name": "extract_arguments", "depends_on": [], "handler": handler_extract}, {"name": "detect_fallacies", "depends_on": ["extract_arguments"], "handler": handler_detect}, {"name": "synthesize_report","depends_on": ["extract_arguments", "detect_fallacies"],"handler": handler_synthesize, },]result_dag = execute_dag(PHASES, TEXTE_SYNTHETIQUE)print("--- Paradigme A : Pipeline DAG ---")print(f"Ordre d'execution : {result_dag['execution_order']}")print(f"Duree totale : {result_dag['duration_s']} s")print("Phases :")for name, res in result_dag["phases"].items():print(f" - {name:<22}{res['status']:<10} duree={res['duration_s']}s")print(f"Resume : {result_dag['summary']}")
--- Paradigme A : Pipeline DAG ---
Ordre d'execution : ['extract_arguments', 'detect_fallacies', 'synthesize_report']
Duree totale : 2.5e-05 s
Phases :
- extract_arguments COMPLETED duree=2e-06s
- detect_fallacies COMPLETED duree=6e-06s
- synthesize_report COMPLETED duree=2e-06s
Resume : {'n_arguments': 3, 'n_fallacies': 3, 'fallacy_types': ['Ad hominem', 'Appel a l autorite', 'Generalisation abusive']}
Interpretation : sortie du DAG
Sortie obtenue : un dictionnaire phases cale par nom de phase, chaque entree portant status, output et duration_s, plus un summary agrege.
Cle du résultat
Type
Contenu
workflow_name
str
"dag"
execution_order
list[str]
Ordre topologique effectif
phases
dict[str, PhaseResult]
Une entree par phase (cle = nom)
summary
dict
Comptes (arguments, sophismes, types)
duration_s
float
Duree totale
Forme de sortie a retenir : le DAG retourne phases: Dict[str, PhaseResult]. On indexe par nom de phase. C’est la forme canonique de workflow_dsl.py en production. Cette forme convient quand on sait a l’avance quelles phases existent.
Exercice 1 : ajouter une phase conditionnelle au DAG
Contexte : en production, certaines phases ne se declenchent que si une condition est remplie. Par exemple, une phase formal_check (verification formelle via Tweety, rung 2) n’a de sens que si des sophismes ont ete detectes. On veut donc une phase conditionnelle.
Objectif : etendez l’executeur pour supporter une cle "condition" facultative sur chaque phase (un callable state -> bool). Si la condition existe et renvoie False, la phase est marquee "SKIPPED" et son handler n’est pas appele. Ajoutez ensuite une phase formal_check qui ne s’execute que si state['detect_fallacies'] est non vide, puis relancez le DAG.
Indices : - Étape 1 : définir handler_formal_check(state) qui ecrit un mini-rapport dans state['formal_check'] (ex : {"checked": True, "n_fallacies": len(...)}) - Étape 2 : copier execute_dag en execute_dag_cond(phases, text) qui teste phase.get("condition", lambda s: True)(state) avant chaque handler ; si False, results[name] = {"status": "SKIPPED", "output": None, "duration_s": 0.0} - Étape 3 : construire PHASES_COND avec la nouvelle phase formal_check (depends_on: ["detect_fallacies"], condition = lambda s: len(s.get("detect_fallacies", [])) > 0) - Étape 4 : executer et verifier que formal_check est COMPLETED (sophismes presents) ; tester aussi un texte sans sophisme pour obtenir SKIPPED
# Exercice 1 : phase conditionnelle formal_check dans le DAG.# Etape 1 : handler de la phase formelle (ne s'execute que si des sophismes existent).def handler_formal_check(state):# TODO etudiant : ecrire un mini-rapport dans state['formal_check'].pass# TODO etudiant# Etape 2 : executeur qui honore une cle "condition" facultative.def execute_dag_cond(phases, text):# TODO etudiant : reprendre execute_dag, mais avant chaque handler :# cond = phase.get("condition", lambda s: True)# if not cond(state): results[name] = {"status": "SKIPPED", "output": None, "duration_s": 0.0}# sinon appeler le handler comme dans execute_dag. result =None# TODO etudiantreturn result# Etape 3 : DAG etendu avec formal_check conditionnel.PHASES_COND =None# TODO etudiant : liste de phases incluant formal_check# Etape 4 : execution sur TEXTE_SYNTHETIQUE (sophismes presents -> COMPLETED).result_ex1 =None# TODO etudiant : execute_dag_cond(PHASES_COND, TEXTE_SYNTHETIQUE)print("Exercice 1 : non complete (statut de formal_check a verifier)")print("Exercice a completer")
Exercice 1 : non complete (statut de formal_check a verifier)
Exercice a completer
4. Paradigme B - Orchestrateur conversationnel (state-driven)
Le paradigme conversationnel ne fixe pas l’ordre des phases a l’avance. A chaque tour, un Project Manager (PM) inspecte l’etat courant et choisit le prochain agent a invoquer. C’est le modele de conversational_orchestrator.py (AgentGroupChat de Semantic Kernel) en production.
Ce rung ne simule plus ce paradigme : il l’execute. La shim argumentation_lib de ce dossier vendore deja l’organe reel — agents ChatCompletionAgent (PM, Informal, Logic), AgentGroupChat, StateManagerPlugin — et le carnet 08b-Executor le fait tourner sur la chaine complete. Le paragraphe qui disait « le PM est une regle deterministe qui joue le meme role » est caduc : la comparaison des deux paradigmes ne compare plus une sequence a elle-meme.
Kernel LLM injecte : aucun secret dans ce carnet. Le service de chat est construit depuis l’environnement (OPENAI_* via .env du dossier, gitignore) — meme mecanique que 08b-Executor. C’est un vrai appel reseau : le routage affiche ci-dessous a ete decide par le PM a l’execution, pas ecrit a l’avance.
4.1 Les agents reels de la shim
# Vrais agents conversationnels : la shim argumentation_lib fournit l'organe# (ChatCompletionAgent + AgentGroupChat + StateManagerPlugin) -- meme machine# que 08b-Executor et que conversational_orchestrator.py en production.import osimport sysfrom pathlib import Pathfrom dotenv import load_dotenvdef _find_aa_dir(start, max_up=6):# Localise Argument_Analysis/ (porteur de argumentation_lib) par walk-up. candidate = start.resolve()for _ inrange(max_up):if (candidate /"argumentation_lib").is_dir():return candidateif candidate.parent == candidate:break candidate = candidate.parentraiseRuntimeError(f"Argument_Analysis introuvable depuis {start.resolve()}")AA_DIR = _find_aa_dir(Path.cwd())os.chdir(AA_DIR) # chemins relatifs de la shim resolus quel que soit le CWDsys.path.insert(0, str(AA_DIR))load_dotenv(AA_DIR /".env", override=True)from semantic_kernel import Kernelfrom semantic_kernel.connectors.ai.open_ai import AzureChatCompletion, OpenAIChatCompletionfrom argumentation_lib import UnifiedAnalysisState, get_analysis_runnerfrom argumentation_lib._runner import create_informal_agent, create_logic_agent, create_pm_agent_api_key = os.getenv("OPENAI_API_KEY")_model_id = os.getenv("OPENAI_CHAT_MODEL_ID")_endpoint = os.getenv("OPENAI_ENDPOINT")ifnot _api_key ornot _model_id:raiseRuntimeError("Cles LLM absentes : OPENAI_API_KEY / OPENAI_CHAT_MODEL_ID attendus dans ""Argument_Analysis/.env (gitignore) -- meme mecanique que 08b-Executor." )llm_kernel = Kernel()if _endpoint: llm_kernel.add_service(AzureChatCompletion( service_id="global_llm_service", deployment_name=_model_id, endpoint=_endpoint, api_key=_api_key, ))print(f"[OK] Service LLM Azure configure (deploiement: {_model_id}).")else: llm_kernel.add_service(OpenAIChatCompletion( service_id="global_llm_service", ai_model_id=_model_id, api_key=_api_key, ))print(f"[OK] Service LLM OpenAI configure (modele: {_model_id}).")# L'etat partage reel : le PM lit cet objet via StateManagerPlugin (29 kernel_functions).state_conv = UnifiedAnalysisState(TEXTE_SYNTHETIQUE)pm_agent = create_pm_agent(llm_kernel, "global_llm_service", state_conv)informal_agent = create_informal_agent(llm_kernel, "global_llm_service", state_conv)logic_agent = create_logic_agent(llm_kernel, "global_llm_service", state_conv)AnalysisRunner = get_analysis_runner()print("--- Paradigme B : vrais agents Semantic Kernel ---")for a in (pm_agent, informal_agent, logic_agent):print(f"Agent charge : {a.name}")
AgentGroupChat.invoke() fait parler les agents a tour de role ; chaque agent ChatCompletionAgent produit sa reponse en appelant le service LLM, et le PM emet une decision de routage JSON (task_description, assigned_agent) lue sur l’etat partagé. En production (conversational_orchestrator.py), une strategie de selection deleguee consomme ce JSON pour choisir le prochain agent ; ici la boucle instrumentee journalise chaque tour (orateur + extrait), puis le pipeline complet AnalysisRunner enchaine les trois phases (informel, formel, synthese) chacune sur son propre AgentGroupChat. Un plafond de tours protege contre les conversations infinies.
# Boucle conversationnelle REELLE : group-chat instrumente (journal par tour)# puis pipeline complet AnalysisRunner (3 phases x AgentGroupChat).import asyncioimport timeimport nest_asynciofrom semantic_kernel.agents.group_chat.agent_group_chat import AgentGroupChatfrom semantic_kernel.contents.chat_history import ChatHistoryfrom semantic_kernel.contents.chat_message_content import ChatMessageContentfrom semantic_kernel.contents.utils.author_role import AuthorRole# ipykernel a deja une boucle active : nest_asyncio autorise asyncio.run (patron 08b).nest_asyncio.apply()asyncdef _run_real_conversational(text, max_group_turns=6, max_pipeline_turns=6):# Phase instrumentee [PM, Informal] + pipeline complet AnalysisRunner.# --- 1) Group-chat instrumente : chaque tour est journalise. history = ChatHistory() history.add_message(ChatMessageContent( role=AuthorRole.USER, content=f"Analysez le texte suivant:\n\n{text}", )) group_chat = AgentGroupChat(agents=[pm_agent, informal_agent], chat_history=history) journal = [] turn =0 t0 = time.perf_counter()asyncfor response in group_chat.invoke(): turn +=1 content = (response.content or"").strip().replace("\n", " ") journal.append({"turn": turn,"speaker": response.name,"excerpt": content[:140], })if turn >= max_group_turns:break group_duration = time.perf_counter() - t0# --- 2) Pipeline complet sur un etat frais : 3 phases, chacune son group-chat. state_pipeline = UnifiedAnalysisState(text) runner = AnalysisRunner(llm_kernel, "global_llm_service", state_pipeline, max_turns=max_pipeline_turns) t1 = time.perf_counter() pipeline_result =await runner.run() pipeline_duration = time.perf_counter() - t1return journal, group_duration, pipeline_result, pipeline_durationjournal_conv, group_dur, result_conv, pipeline_dur = asyncio.run( _run_real_conversational(TEXTE_SYNTHETIQUE))print("--- Paradigme B : orchestrateur conversationnel REEL ---")print(f"Modele : {os.getenv('OPENAI_CHAT_MODEL_ID')}")print(f"Journal du group-chat : {len(journal_conv)} tours en {group_dur:.1f}s")for e in journal_conv:print(f" tour {e['turn']} : {e['speaker']:<20}{e['excerpt']}")print()print("Pipeline AnalysisRunner (3 phases, chacune sur son AgentGroupChat) :")for ph in result_conv.get("phases", []):print(f" phase {ph.get('name'):<10} tours={ph.get('turns', '-')} statut={ph.get('status')}")print(f"Duree pipeline : {pipeline_dur:.1f}s")conclusion = result_conv.get("conclusion")if conclusion: extrait =str(conclusion)[:220].replace("\n", " ")print(f"Conclusion (extrait) : {extrait}")
--- Paradigme B : orchestrateur conversationnel REEL ---
Modele : glm-5.2
Journal du group-chat : 5 tours en 145.8s
tour 1 : ProjectManagerAgent ```json { "task_description": "Identifier les arguments présents dans le texte : 'Tous les elements A presentent le meme defaut. Le profes
tour 2 : InformalAgent ## 📋 Analyse informelle du texte ### Arguments identifiés (3) | ID | Argument | Qualité globale | |---|---|---| | **arg_1** | « Tous les e
tour 3 : ProjectManagerAgent ```json { "task_description": "Traduire le texte en logique propositionnelle : formaliser les trois énoncés ("Tous les elements A presente
tour 4 : InformalAgent ## 🔣 Traduction logique du texte ### Formalisation propositionnelle (`propositional_bs_1`) **Variables :** - **P** = « tous les éléments A
tour 5 : ProjectManagerAgent --- # 🏁 Conclusion Finale de l'Analyse Rhétorique **Texte analysé :** *« Tous les elements A presentent le meme defaut. Le professeur X co
Pipeline AnalysisRunner (3 phases, chacune sur son AgentGroupChat) :
phase informal tours=3 statut=completed
phase formal tours=3 statut=completed
phase synthesis tours=3 statut=completed
Duree pipeline : 352.3s
Conclusion (extrait) : SYNTHÈSE FINALE — Analyse rhétorique du texte : « Tous les éléments A présentent le même défaut. Le professeur X confirme cette conclusion. Vous êtes incompétent pour contester cette position. » I. STRUCTURE ARGUMENTATI
Interpretation : sortie de l’orchestrateur conversationnel
Sortie obtenue (execution reelle, modele glm-5.2) : un journal de conversation — une entree par tour, dans l’ordre temporel — puis le resultat du pipeline complet AnalysisRunner.
Ce que le journal ci-dessus montre, tour par tour :
Tour
Orateur
Decision / production
1
ProjectManagerAgent
Routage JSON : « Identifier les arguments presents dans le texte »
2
InformalAgent
Analyse informelle : tableau de 3 arguments identifies, avec qualite globale
3
ProjectManagerAgent
Routage JSON : « Traduire le texte en logique propositionnelle »
4
InformalAgent
Formalisation propositionnelle (propositional_bs_1) avec ses variables
5
ProjectManagerAgent
Conclusion finale de l’analyse rhetorique
Deux choses a retenir de cette sortie :
Le PM decide vraiment. Les tours 1 et 3 ne sont pas un script : ce sont des decisions de routage JSON produites par le LLM en lisant l’etat partagé (via StateManagerPlugin). Le pipeline AnalysisRunner a enchaine ses 3 phases (informel, formel, synthese), chacune bouclee en 3 tours sur son propre AgentGroupChat, toutes marquees completed.
Un orchestrateur LLM produit de la variance. Le tableau du tour 2 annonce 3 arguments, mais l’etat n’enregistre que arg_1 et arg_2 — l’avertissement ID argument cible 'arg_3' ... non trouve est une vraie trace d’execution, pas une erreur du carnet. C’est exactement la difference avec l’extracteur deterministe du paradigme A, qui coupe sur les points et retourne toujours 3 arguments. Cette variance (comptes qui divergent, IDs non alignes) est le prix ordinaire d’une analyse rhetorique faite par un modele — et la raison d’etre des strategies de selection et des plafonds de tours en production.
Forme de sortie a retenir : le conversationnel retourne un journal ordonne par position dans le temps (liste de tours), pas un dictionnaire de phases nommees. On indexe par position, pas par nom. Le pipeline AnalysisRunner, lui, agrege en phases nommees (informel / formel / synthese) avec un state_snapshot — c’est la forme canonique de conversational_orchestrator.py en production.
Exercice 2 : coder en deterministe la strategie de selection (croissance validee)
Contexte : en production, le PM est un LLM qui lit l’etat et decide du prochain agent — une strategie de selection. Ci-dessus, ce routage est reel et non deterministe. Cet exercice vous demande d’en ecrire vous-meme la version deterministe, avec une regle de production absente du pipeline par defaut : la croissance validee (si le resultat d’un agent est juge insuffisant, le PM le re-invoque).
Objectif : implementez pm_route_v2(state, min_arguments=3) qui, sur les handlers deterministes de la section 2 (les memes que le paradigme A), route l’extracteur / detecteur / synthetiseur et re-invoque l’extracteur une seule fois si len(state["arguments"]) < min_arguments.
Indices : - Etape 1 : utiliser un compteur state.get('_extract_count', 0) pour limiter a une re-extraction - Etape 2 : regle : si "arguments" not in state -> “extractor” ; si len(state["arguments"]) < min_arguments ET extraction faite une seule fois -> “extractor” ; sinon continuer vers fallacy_checker / synthesizer - Etape 3 : attention a la terminaison : garantir qu’on ne re-extrait qu’une fois (sinon boucle infinie), par ex. incrementer state['_extract_count'] a chaque appel extracteur - Etape 4 : ecrire run_conversational_v2 (boucle PM deterministe sur le registre ci-dessous) et comparer total_messages a la sequence lineaire de reference
# Exercice 2 : strategie de selection deterministe avec croissance validee.# (C'est la version codee-a-la-main de ce que le PM-LLM decide ci-dessus.)def agent_extractor(state): state["arguments"] = extract_arguments(state["text"]) state["_extract_count"] = state.get("_extract_count", 0) +1return state, {"agent": "extractor", "action": "extraction","result_count": len(state["arguments"])}def agent_fallacy_checker(state): state["fallacies"] = detect_fallacies(state.get("arguments", []))return state, {"agent": "fallacy_checker", "action": "detection","result_count": len(state["fallacies"])}def agent_synthesizer(state): state["report"] = synthesize_report( state.get("arguments", []), state.get("fallacies", []))return state, {"agent": "synthesizer", "action": "synthesis", "result_count": 1}AGENTS_EX2 = {"extractor": agent_extractor,"fallacy_checker": agent_fallacy_checker,"synthesizer": agent_synthesizer,}def pm_route_v2(state, min_arguments=3):# Etape 1-2 : regle etendue (penser a eviter la boucle infinie).# Indice : utiliser state.get('_extract_count', 0) pour limiter a une re-extraction.returnNone# TODO etudiant : retourner le nom du prochain agent ou Nonedef run_conversational_v2(text, max_turns=10, min_arguments=3):# TODO etudiant : boucle PM deterministe utilisant pm_route_v2 et AGENTS_EX2# (journal d'un tour = entree {"agent", "action", "result_count", "turn"}). result =None# TODO etudiantreturn result# Etape 4 : test (le nombre de messages attendu differe de 3 si re-extraction declenchee).result_ex2 =None# TODO etudiant : run_conversational_v2(TEXTE_SYNTHETIQUE, min_arguments=3)print("Exercice a completer")print("Exercice 2 : comparer total_messages avec la sequence lineaire extractor -> fallacy_checker -> synthesizer")
Exercice a completer
Exercice 2 : comparer total_messages avec la sequence lineaire extractor -> fallacy_checker -> synthesizer
5. Comparaison cote-a-cote : le coeur pedagogique
On execute maintenant les deux paradigmes sur le même texte et on compare leurs résultats selon cinq axes : determinisme, modèle de cout, forme de sortie, terminaison, et cas d’usage privilegie.
# Execution des deux paradigmes sur le MEME texte + affichage croise.# Cote A : DAG deterministe (handlers cles). Cote B : orchestrateur REEL# (agents LLM, journal du group-chat + pipeline AnalysisRunner).result_dag_cmp = execute_dag(PHASES, TEXTE_SYNTHETIQUE)print("--- Comparaison cote-a-cote sur le meme texte synthetique ---\n")print("A. Pipeline DAG (deterministe, handlers cles)")print(f" workflow_name : {result_dag_cmp['workflow_name']}")print(f" phases (type) : dict[str, PhaseResult] ({len(result_dag_cmp['phases'])} cles)")print(f" ordre / nombre : {result_dag_cmp['execution_order']}")print(f" duree : {result_dag_cmp['duration_s']} s")print(f" resume : {result_dag_cmp['summary']}")print()print("B. Orchestrateur conversationnel REEL (agents LLM)")print(f" journal : {len(journal_conv)} tours du group-chat [PM, Informal]")for e in journal_conv:print(f" tour {e['turn']} : {e['speaker']:<20}{e['excerpt'][:80]}")phases_b = result_conv.get("phases", [])print(f" pipeline : {len(phases_b)} phase(s) AnalysisRunner")for ph in phases_b:print(f" phase {ph.get('name'):<10} tours={ph.get('turns', '-')} statut={ph.get('status')}")print(f" durees : group-chat {group_dur:.1f}s + pipeline {pipeline_dur:.1f}s")print()print("Divergence des analyses (les moteurs ne sont plus les memes) :")print(f" DAG n_arguments = {result_dag_cmp['summary']['n_arguments']} (decoupe sur '.')")n_args_llm =Nonefor ph in phases_b:passstate_snap = result_conv.get("state_snapshot")ifisinstance(state_snap, dict):for key in ("arguments", "informal_arguments", "identified_arguments"):if key in state_snap andisinstance(state_snap[key], (list, str)): n_args_llm =len(state_snap[key]) ifisinstance(state_snap[key], list) else state_snap[key]breakprint(f" LLM etat informel = {n_args_llm if n_args_llm isnotNoneelse'(voir state_snapshot)'}")
--- Comparaison cote-a-cote sur le meme texte synthetique ---
A. Pipeline DAG (deterministe, handlers cles)
workflow_name : dag
phases (type) : dict[str, PhaseResult] (3 cles)
ordre / nombre : ['extract_arguments', 'detect_fallacies', 'synthesize_report']
duree : 2.6e-05 s
resume : {'n_arguments': 3, 'n_fallacies': 3, 'fallacy_types': ['Ad hominem', 'Appel a l autorite', 'Generalisation abusive']}
B. Orchestrateur conversationnel REEL (agents LLM)
journal : 5 tours du group-chat [PM, Informal]
tour 1 : ProjectManagerAgent ```json { "task_description": "Identifier les arguments présents dans le texte
tour 2 : InformalAgent ## 📋 Analyse informelle du texte ### Arguments identifiés (3) | ID | Argument
tour 3 : ProjectManagerAgent ```json { "task_description": "Traduire le texte en logique propositionnelle :
tour 4 : InformalAgent ## 🔣 Traduction logique du texte ### Formalisation propositionnelle (`propositi
tour 5 : ProjectManagerAgent --- # 🏁 Conclusion Finale de l'Analyse Rhétorique **Texte analysé :** *« Tous
pipeline : 3 phase(s) AnalysisRunner
phase informal tours=3 statut=completed
phase formal tours=3 statut=completed
phase synthesis tours=3 statut=completed
durees : group-chat 145.8s + pipeline 352.3s
Divergence des analyses (les moteurs ne sont plus les memes) :
DAG n_arguments = 3 (decoupe sur '.')
LLM etat informel = (voir state_snapshot)
Interpretation : les cinq axes de comparaison
Avec l’orchestrateur conversationnel reel (agents LLM glm-5.2), les deux paradigmes ne partagent plus leurs briques : le DAG execute des handlers Python deterministes, l’orchestrateur execute des agents qui appellent un service de chat a chaque tour. Les comptes divergent maintenant — et c’est precisement ce que la comparaison devait mettre en lumiere.
Axe
Paradigme A - DAG
Paradigme B - Conversationnel (reel)
Determinisme
Reproductible : memes phases, meme ordre topologique a chaque run
Non deterministe : le PM est un LLM, le routage affiche a ete decide a l’execution
Cout
Handlers locaux : la run ci-dessus a pris 2.6e-05 s
Vrais appels reseau : 145.8 s de group-chat + 352.3 s de pipeline — cinq ordres de grandeur
Decoupe mecanique : 3 arguments (decoupe sur .), 3 sophismes par motifs fixes
Analyse rhetorique : qualite des arguments, formalisation propositionnelle, synthese structuree — mais comptes variables (2 arguments enregistres quand le tableau en annonce 3)
Quand le choisir
Batch, CI, analyse reproductible, pipelines dont les etapes sont connues
Analyse riche, interaction, cas ou l’ordre et la profondeur dependent du contenu
Points cles :
La comparaison ne compare plus une sequence a elle-meme. Tant que le paradigme B simulait le PM par une regle deterministe, les deux colonnes produisaient les memes comptes. Ici la divergence est mesurable : cote DAG, n_arguments = 3 par decoupe sur les points ; cote LLM, l’etat informel enregistre arg_1 et arg_2 malgre un tableau qui en annonce 3 — l’avertissement de la section 4.2 en est la trace directe.
Determinisme = propriete du modeleur, pas des briques. Le DAG est deterministe par construction (ordre topologique fixe). L’orchestrateur conversationnel ne l’est plus des que le PM est un LLM : deux runs du meme texte peuvent router differemment. C’est la principale raison de preferer un DAG pour la CI et les tests de non-regression.
Cout predictable vs imprevisible. Le DAG facture exactement N executions de handlers locaux — la mesure ci-dessus est quasi nulle. Le conversationnel facture chaque tour au service LLM : les 498 s cumulees de cette run (group-chat + pipeline) sont le vrai prix de l’analyse rhetorique approfondie, d’ou les plafonds max_group_turns / max_pipeline_turns du carnet et les budgets de tours en production.
Forme de sortie => code aval. Indexer par nom (DAG) permet d’ecrire result['phases']['detect_fallacies']. Le journal du conversationnel se parcourt par position ; le state_snapshot du pipeline se lit par cles d’etat. Le code consommateur est structure differemment — c’est visible jusque dans les cellules de cette section.
Aucun paradigme n’est superieur. Ils repondent a des besoins differents. L’architecture EPITA les implemente tous les deux (argumentation_lib de ce dossier) et le choix depend du cas d’usage : batch reproductible et bon marche, ou analyse riche au prix d’appels reseau et de variance.
Exercice 3 : fonction de comparaison programmatique des deux modes
Contexte : la comparaison manuelle ci-dessus est visuelle. On veut une fonction reutilisable qui lance les deux modes sur un texte et retourne un tableau de comparaison programmable.
Objectif : ecrivez compare_modes(text) qui lance le DAG et le conversationnel sur le même text, puis retourne une liste de dictionnaires (un par mode) avec les colonnes : mode, n_turns_or_phases, n_arguments, n_fallacies, duration_s, deterministic_bool.
Indices : - Étape 1 : cote DAG, appeler execute_dag(PHASES, text). Cote conversationnel reel, reutiliser la fonction de la section 4.2 : journal, group_dur, result_conv, pipe_dur = asyncio.run(_run_real_conversational(text)) (nest_asyncio est deja applique dans ce carnet). Attention au cout : chaque appel conversationnel = plusieurs minutes d’appels LLM. - Étape 2 : pour le DAG, n_turns_or_phases = len(result['phases']) (un dict cale par nom) ; pour le conversationnel, = len(journal) (la liste des tours du group-chat). - Étape 3 : n_arguments / n_fallacies viennent du summary (DAG) ; cote conversationnel, les compter dans result_conv['state_snapshot'] (chercher la cle qui porte les arguments informels enregistres). - Étape 4 : deterministic_bool = True pour le DAG (ordre topologique fixe) et False pour le conversationnel reel : le PM est un LLM, deux runs du même texte peuvent router differemment (cf. l’interpretation de la section 4.2). Variante deterministe : run_conversational_v2 de l’exercice 2 prendrait True. - Étape 5 (optionnel) : envelopper dans pandas.DataFrame(rows) pour un affichage tabulaire
# Exercice 3 : fonction de comparaison programmatique des deux modes.# Optionnel : importer pandas pour retourner un DataFrame (sinon liste de dicts).# import pandas as pd # decommenter si DataFrame souhaitedef compare_modes(text):"""Lance DAG et conversationnel sur le meme texte, retourne un tableau de comparaison. Retourne une liste de dicts (colonnes : mode, n_turns_or_phases, n_arguments, n_fallacies, duration_s, deterministic_bool). """ rows = [] # TODO etudiant# Etape 1 : resultats des deux modes.# TODO etudiant# Etape 2-4 : remplir 'rows' avec un dict par mode.# TODO etudiantreturnNone# TODO etudiant : retourner rows (ou pd.DataFrame(rows) a l'etape 5)# Testresult_ex3 =None# TODO etudiant : compare_modes(TEXTE_SYNTHETIQUE)print("Exercice 3 : tableau de comparaison a construire")print("Exercice a completer")
Exercice 3 : tableau de comparaison a construire
Exercice a completer
6. Pont vers l’état partagé
Les deux paradigmes produisent des arguments extraits et des sophismes détectés. En production, ces résultats alimentent un état partagé unique — l’objet RhetoricalAnalysisState du module argumentation_lib — que tous les rungs enrichissent à tour de rôle. Le rung 1 y écrit ses détections mots-clés ; le rung 2 y ajoute les verdicts formels de Tweety ; ce rung (3) y dépose le résultat de l’orchestration.
Cette section montre le geste d’intégration : quel que soit le paradigme choisi (DAG ou conversationnel), on mappe la sortie de l’orchestrateur vers le conteneur partagé. C’est ce conteneur qu’un capstone (rung 4) ou une UI consommerait pour produire un rapport final.
Le conteneur est déterministe (un dictionnaire typé sans aucune logique LLM). Le remplir ne coûte aucun appel API : on réutilise les sorties déjà calculées ci-dessus. En production, c’est ce même conteneur que le conversational_orchestrator.py (Semantic Kernel) remplit via des agents LLM — la forme de l’objet est identique, seuls les producteurs changent.
# Pont vers l'etat partage : on mappe le resultat de l orchestration# (quel que soit le paradigme) vers RhetoricalAnalysisState, le conteneur# commun a tous les rungs. Rung 1 y ecrit ses detections informelles, rung 2# y ajoute les verdicts formels de Tweety, ce rung (3) y depose le resultat# orchestre. Aucun LLM requis : on reutilise les sorties deja calculees.import logging# Le package argumentation_lib vit a cote du notebook ; il est importable# directement (le dossier du notebook est sur sys.path au runtime). La shim# resout ses ressources (data/, libs/) via _paths.py (__file__-relative, donc# cwd-independant) -- on evite ainsi le chemin relatif qui leverait une erreur# hors de ce dossier. Aucun guard de path ni raise dans la cellule (regle C.1) :# la robustesse cwd est portee par la shim, comme au rung 1 (#3867) et rung 2# (#3857).from argumentation_lib import RhetoricalAnalysisState# On reduit le bruit du logger interne de l'etat (niveau INFO par defaut).logging.getLogger("RhetoricalAnalysisState").setLevel(logging.WARNING)# On reutilise les sorties du DAG (paradigme A) deja calculees en section 5.arguments_extraits = result_dag_cmp["phases"]["extract_arguments"]["output"]fallacies_detectees = result_dag_cmp["phases"]["detect_fallacies"]["output"]# Mapping type de sophisme -> famille (herite de la table mots-cles section 2).FAMILLE_PAR_FALLACY = {"Generalisation abusive": "induction","Ad hominem": "pertinence","Appel a l autorite": "pertinence","Faux dilemme": "presomption",}# Remplissage du conteneur partage : un argument cible agregeant le bloc# argumentatif, puis un sophisme par detection mot-cle rattache a cet argument.etat_partage = RhetoricalAnalysisState(initial_text=TEXTE_SYNTHETIQUE)arg_id = etat_partage.add_argument("Bloc argumentatif synthetique (3 phrases extraites par l orchestrateur).")for d in fallacies_detectees: etat_partage.add_fallacy( fallacy_type=d["fallacy"], justification=(f"declencheur mot-cle : '{d['keyword']}' "f"(phrase : \"{d['sentence']}\")" ), target_arg_id=arg_id, family=FAMILLE_PAR_FALLACY.get(d["fallacy"], ""), )snapshot = etat_partage.get_state_snapshot(summarize=True)print("--- Pont vers l'etat partage (argumentation_lib) ---")print(f"arguments dans l etat : {snapshot['argument_count']}")print(f"sophismes dans l etat : {snapshot['fallacy_count']}")print(f"texte brut (extrait) : {snapshot['raw_text_snippet'][:60]}...")print("Ce meme conteneur serait enrichi par rung 1 (informel) et rung 2 (formel).")
--- Pont vers l'etat partage (argumentation_lib) ---
arguments dans l etat : 1
sophismes dans l etat : 3
texte brut (extrait) : Tous les elements A presentent le meme defaut. Le professeur...
Ce meme conteneur serait enrichi par rung 1 (informel) et rung 2 (formel).
7. Conclusion et recapitulatif
Ce rung a distille les deux paradigmes d’orchestration de la production EPITA : le paradigme A en handlers deterministes auto-contenus, le paradigme B sur les vrais agents de la shim argumentation_lib de ce dossier (ChatCompletionAgent + service LLM via Semantic Kernel). Le tri topologique de Kahn, le routage par etat et les agents reels tournent sur un vrai texte synthetique — les sorties committées sont celles de l’execution (journal de tours et pipeline AnalysisRunner de la section 4.2).
Convergence ou max_group_turns / max_pipeline_turns
Determinisme
Toujours (par construction)
Non : le PM est un LLM, le routage affiche a ete decide a l’execution (section 4.2)
Cout
Quasi nul (handlers locaux, ~2.6e-05 s mesure)
Chaque tour facture au service LLM (~498 s cumulees sur cette run, plafonnees)
Cas d’usage
Batch, CI, reproductible
Analyse riche, interactif, exploration adverse
Points a retenir
Les deux paradigmes orchestrent des briques de natures differentes. Le DAG compose des handlers Python deterministes ; l’orchestrateur conversationnel anime des agents qui appellent un service de chat a chaque tour. La difference n’est plus seulement la composition, c’est aussi la nature du decideur (regle fixe vs LLM).
Le DAG privilegie la reproductibilite. L’ordre topologique est calcule une fois, les phases tournent dans le même ordre a chaque exécution. Ideal pour la CI, les tests de non-regression et les pipelines batch.
Le conversationnel privilegie l’adaptabilite — au prix du determinisme. Le PM peut re-invoquer un agent, en sauter un, changer d’ordre selon le contenu : c’est ce qu’a fait le routage JSON journalise en section 4.2. Ce cout et cette variance (comptes d’arguments qui divergent du tableau annonce) sont le prix ordinaire d’une analyse rhetorique faite par un modele.
Vrai outil, vraie sortie. Le paradigme B n’est pas une simulation : il invoque les agents reels de la shim (le même organe que conversational_orchestrator.py en production et que le carnet 08b-Executor), avec un service LLM local (glm-5.2), et les sorties committées sont sa vraie sortie — le routage affiche a ete decide par le PM a l’execution.
Frontiere avec le rung 2. Ce notebook orchestre mais ne verifie rien formellement : la phase formal_check de l’exercice 1 est un squelette. La vraie verification logique est l’objet du rung 2-formal (Tweety via JPype). Une composition complete brancherait le handler_formal_check de l’exercice 1 avec un vrai appel Tweety.
Pont vers l’etat partage. Quel que soit le paradigme, la sortie de l’orchestrateur se mappe vers le même conteneur RhetoricalAnalysisState que les autres rungs (section 6). C’est le bus d’integration : un capstone ou une UI n’a qu’a lire cet objet pour combiner informel (rung 1), formel (rung 2) et orchestration (rung 3) en un rapport complet.
Prochaines étapes
Brancher un vrai solveur formel : completer l’exercice 1 en appelant SimplePlReasoner (rung 2) dans handler_formal_check.
Coder la version deterministe du decideur : l’exercice 2 (pm_route_v2 / run_conversational_v2) implemente a la main la strategie de selection que le PM-LLM applique en section 4.2 — comparer les deux routages sur le même texte pour mesurer ce que le LLM ajoute.