Restitution en 3 actes — scaffold déterministe, narration LLM gated
Série Argument Analysis · distillation de l’arc anti-théâtre / fail-loud de 2025-Epita-Intelligence-Symbolique (Epic source #1258 / #1134). Voir l’Epic CoursIA #2137.
Coordonnées du tronc (audit #18476 — la discipline « 0 import » conserve le code recopié à l’identique, elle ne doit pas faire perdre les chemins) : l’organe vivant est argumentation_analysis/reporting/restitution/. Ancres vérifiées : acts.py:22 (ACT_TITLES) et acts.py:30 (RestitutionActs) · specialist_roles.py:50 (rôles probatoires) et :145 (classify_specialist_roles) · conclusion_salience.py:61 (_MAX_RANKED = 5) · renderer.py:42 (_MIN_ACT_CHARS = 120) · readability_gate.py:394 (check_acts) · act3_conclusion_plugin.py:171-176 (axes de l’Acte III). Amont immédiat de cette distillation : docs/coursia_contrib/restitution_evidential_roles.ipynb — conception #1914, piste CI-5 #1943, PR #1948.
Une analyse argumentative produit un dump dimensionnel : des arguments, des sophismes, des scores de qualité, des verdicts de solveur, des extensions de Dung. Illisible pour un non-spécialiste. La restitution transforme ce dump en un récit en trois actes qu’un lecteur peut suivre :
Acte
Rôle
Acte I — Mise en situation
le cadre : genre du discours, enjeux, spectre de sophismes attendu (le seul acte qui anticipe)
Acte II — Récit dialectique
le cœur : la narration découpée par mouvement argumentatif, tissant qualité + sophismes + contre-arguments + tenue formelle
Acte III — Conclusion actionnable
le verdict gated, les appréciations (forces et faiblesses), et « comment se faire son avis »
Mais cette narration a besoin d’un LLM — et un LLM laissé seul fabrique, sur-affirme, fait fuiter du jargon. La question centrale de ce notebook :
Comment obtenir un rapport lisible dont l’honnêteté est garantie, que le LLM soit disponible ou non ?
La réponse est une séparation des responsabilités :
un scaffold 100% déterministe détient le contrat d’honnêteté (l’extraction ne surface que du réel-en-état, la bande de verdict est gated sur la couverture réelle, le renderer nomme les actes manquants, le gate de lisibilité interdit mécaniquement l’énumération) ;
le LLM détient uniquement la prose, et il est gated : quand il est absent, le rapport le dit bruyamment (fail-loud) — jamais un template (anti-pendule #1108/#1019).
Le scaffold déterministe (§1–§5) est pur stdlib Python : aucune JVM, aucun solveur, aucun LLM n’est requis pour l’exécuter — il détient le contrat d’honnêteté et tourne entièrement ici. La narration (§6–§7) branche un vrai client LLM (SDK openai, clé lue dans GenAI/.env) qui narre les trois actes ; sans clé, le code retombe sur le fail-loud (jamais un texte fabriqué).
1. Le contrat des 3 actes
RestitutionActs est un conteneur volontairement minuscule et sans dépendance : il découple le renderer du comment chaque acte est généré. Deux invariants d’honnêteté y sont câblés dès le contrat :
un acte vide est traité comme manquant — le renderer le nomme (« acte indisponible »), il ne l’omet jamais silencieusement ;
source_id est opaque (jamais un nom de locuteur, un titre ou une date) — la confidentialité est HARD, elle commence dès le type de données.
from __future__ import annotationsfrom dataclasses import dataclass, fieldfrom typing import Any, Awaitable, Callable, Dict, List, Optionalimport reACT_TITLES: Dict[int, str] = {1: "Acte I — Mise en situation",2: "Acte II — Récit dialectique",3: "Acte III — Conclusion actionnable",}@dataclassclass RestitutionActs:"""Les trois actes narratifs + l'identifiant OPAQUE du corpus.""" act1_framing: str="" act2_narrative: str="" act3_conclusion: str="" source_id: str=""# opaque : jamais nom/titre/date (vie privée HARD) degraded: Dict[str, str] = field(default_factory=dict)def as_dict(self) -> Dict[int, str]:return {1: self.act1_framing, 2: self.act2_narrative, 3: self.act3_conclusion}@staticmethoddef act_key(n: int) ->str:return {1: "act1_framing", 2: "act2_narrative", 3: "act3_conclusion"}[n]def is_missing(self, n: int) ->bool:"""Vrai si l'acte n est absent (vide / blancs) -> le renderer le NOMME."""returnnot (self.as_dict()[n] or"").strip()print("Contrat des 3 actes :")for n, t in ACT_TITLES.items():print(f" {n}. {t}")
Contrat des 3 actes :
1. Acte I — Mise en situation
2. Acte II — Récit dialectique
3. Acte III — Conclusion actionnable
2. La bande de verdict — gated sur la couverture réelle
Un rapport ne doit jamais affirmer plus fort que ce que l’analyse a réellement couvert. La bande de verdict gouverne la force avec laquelle le discours peut être caractérisé, dérivée du nombre d’axes analytiques non-triviaux réellement présents (sophismes, qualité, contre-arguments, formel PL, formel FOL, Dung).
Les seuils sont transparents et fixes (anti-pendule : pas de courbe ajustée a posteriori). EXCEEDED exige en plus la profondeur formelle (PL ou FOL vérifié) ET l’axe qualité — une couverture large sans profondeur ne « dépasse » rien. Adapté de #1008 §2.1 : la restitution n’a pas d’analyste externe à qui se comparer, donc la bande mesure la couverture interne.
AXES = ["fallacies", "quality", "counter_arguments", "formal_pl", "formal_fol", "dung"]@dataclassclass VerdictBand: band: str nontrivial_axes: List[str] = field(default_factory=list) missing_axes: List[str] = field(default_factory=list) axes_count: int=0def compute_verdict_band(axes_nontrivial: List[str]) -> VerdictBand: present =set(axes_nontrivial) missing = [a for a in AXES if a notin present] n =len(axes_nontrivial) has_formal ="formal_pl"in present or"formal_fol"in present has_quality ="quality"in presentif n >=5and has_formal and has_quality: band ="EXCEEDED"elif n >=4: band ="MATCH"elif n >=2: band ="PARTIAL"else: band ="BELOW"return VerdictBand(band, list(axes_nontrivial), missing, n)# Démo : la même mécanique sur 4 niveaux de couverturefor axes in ( ["fallacies", "quality", "counter_arguments", "formal_pl", "formal_fol", "dung"], ["fallacies", "counter_arguments", "formal_pl", "formal_fol", "dung"], # 5 mais SANS qualité ["fallacies", "quality", "dung"], ["fallacies"],): v = compute_verdict_band(axes)print(f" {v.axes_count}/6 axes -> {v.band:9s} (touchés: {', '.join(v.nontrivial_axes)})")
Noter le 2ᵉ cas : 5 axes mais sans l’axe qualité → MATCH, pas EXCEEDED. La règle refuse de « dépasser » sans un discours caractérisé. C’est le contraire d’un score qui monte mécaniquement avec le volume.
3. L’extraction d’evidence — déterministe, réel-en-état seulement
build_evidence(state) lit un état d’analyse partagé (la sortie des notebooks de détection / formels / Dung de cette série) et en extrait un paquet d’evidence : points faibles localisés, stratégies de contre, forces de qualité, extraits de revendications. Trois disciplines :
réel-en-état seulement (G4) : aucun axe n’est fabriqué ; un verdict formel non vérifié reste None, jamais collapsé en False (None ≠ False, #1019) ;
chaque point faible est lié à (a) un argument localisé ET(b) le verdict concret qui l’a signalé — c’est l’ancrage qu’exigera le gate de lisibilité ;
les champs dérivés du corpus sont tronqués (vie privée + budget de prompt) et les IDs restent opaques.
@dataclassclass WeakPoint: source: str# "fallacy" | "pl" | "fol" | "dung" label: str target_arg_id: str detail: str=""@dataclassclass Evidence: args_total: int=0 fallacies_total: int=0 counters_total: int=0 quality_axis_available: bool=False quality_strengths: List[tuple] = field(default_factory=list) # (vertu, score) weak_points: List[WeakPoint] = field(default_factory=list) counter_strategies: List[tuple] = field(default_factory=list) # (cible, stratégie, extrait) claim_excerpts: List[str] = field(default_factory=list) verdict: Optional[VerdictBand] =None gates: Dict[str, bool] = field(default_factory=dict)_CLAIM_CAP, _DETAIL_CAP =240, 200def _truncate(text: Any, cap: int) ->str:ifnot text:return"" s =str(text).strip()return s iflen(s) <= cap else s[:cap].rstrip() +" […]"def _pl_verdict(r: Dict[str, Any]) -> Optional[bool]:"""Verdict PL en bool STRICT (None si non vérifié) — jamais bool().""" sat = r.get("satisfiable")if sat isNone: sat = r.get("consistent")return sat ifisinstance(sat, bool) elseNonedef _dung_rejected(state: Dict[str, Any]) -> Dict[str, str]:"""arg_id -> sémantique, pour les arguments présents mais hors extension.""" rejected: Dict[str, str] = {}for _fid, fw in (state.get("dung_frameworks") or {}).items():ifnotisinstance(fw, dict):continue accepted =set(fw.get("extension", []) or []) sem =str(fw.get("semantics", "grounded"))for a in (fw.get("arguments", []) or []):if a notin accepted: rejected.setdefault(a, sem)return rejecteddef build_evidence(state: Dict[str, Any]) -> Evidence: args = state.get("identified_arguments") or {} fallacies = state.get("identified_fallacies") or {} quality = state.get("argument_quality_scores") or {} counters = state.get("counter_arguments") or [] pl = state.get("propositional_analysis_results") or [] fol = state.get("fol_analysis_results") or [] fallacies_total =sum(1for d in fallacies.values()ifisinstance(d, dict) and d.get("target_argument_id")) counters_total =sum(1for c in countersifisinstance(c, dict) and (c.get("target_arg_id") or c.get("counter_content"))) pl_inc =sum(1for r in pl ifisinstance(r, dict) and _pl_verdict(r) isFalse) fol_inc =sum(1for r in fol ifisinstance(r, dict) and r.get("consistent") isFalse) pl_verified =sum(1for r in pl ifisinstance(r, dict) and _pl_verdict(r) isnotNone) fol_verified =sum(1for r in fol ifisinstance(r, dict) and r.get("consistent") in (True, False)) dung_rejected = _dung_rejected(state) axes: List[str] = []if fallacies_total: axes.append("fallacies")ifbool(quality): axes.append("quality")if counters_total: axes.append("counter_arguments")if pl_verified: axes.append("formal_pl")if fol_verified: axes.append("formal_fol")if dung_rejected: axes.append("dung") weak: List[WeakPoint] = []for _fid, fd in fallacies.items():ifisinstance(fd, dict) and fd.get("target_argument_id"): weak.append(WeakPoint("fallacy",f"{fd.get('type','inconnu')} (famille {fd.get('family','inconnu')})",str(fd["target_argument_id"]), _truncate(fd.get("justification", ""), _DETAIL_CAP)))for aid, sem in dung_rejected.items(): weak.append(WeakPoint("dung", f"argument rejeté par le cadre de Dung (sémantique {sem})", str(aid)))if pl_inc: weak.append(WeakPoint("pl", f"{pl_inc} inférence(s) propositionnelle(s) inconsistantes (solveur Tweety)", "—"))if fol_inc: weak.append(WeakPoint("fol", f"{fol_inc} théorie(s) du premier ordre inconsistantes (solveur Tweety)", "—")) best: Dict[str, float] = {}for qs in quality.values(): spv = qs.get("scores") ifisinstance(qs, dict) andisinstance(qs.get("scores"), dict) else {}for vname, vval in spv.items():ifisinstance(vval, (int, float)) and vval >0: best[vname] =max(best.get(vname, 0.0), float(vval)) strengths =sorted(best.items(), key=lambda kv: kv[1], reverse=True) counter_strategies = [ (str(c.get("target_arg_id", "")), str(c.get("strategy", "")), _truncate(c.get("counter_content", ""), _DETAIL_CAP))for c in counters ifisinstance(c, dict) and (c.get("target_arg_id") or c.get("counter_content")) ] claim_excerpts = [_truncate(v, _CLAIM_CAP) for v inlist(args.values())[:5] if _truncate(v, _CLAIM_CAP)] gates = {"G1_arguments_extracted": len(args) >0,"G2_one_dimension_nontrivial": len(axes) >=1,"G3_verdict_computed": True,"G4_no_fabrication": True, }return Evidence(len(args), fallacies_total, counters_total, bool(quality), strengths, weak, counter_strategies, claim_excerpts, compute_verdict_band(axes), gates)
Un état d’analyse de travail
On se donne un état réaliste : trois arguments (un appel à l’autorité, un appel à la popularité, une revendication chiffrée), deux sophismes localisés, des scores de qualité sur l’argument chiffré, un contre-argument, un résultat PL cohérent, une théorie FOL incohérente, et un cadre de Dung qui rejette deux arguments. C’est exactement la forme produite par les notebooks 1-informal / 2-formal / 3-orchestration de cette série.
ETAT = {"identified_arguments": {"arg_1": "Les experts du comité affirment que la réforme est sûre, donc elle l'est.","arg_2": "Tout le monde adopte cette mesure ; vous devriez l'adopter aussi.","arg_3": "La transition réduit les coûts de 12% sur trois ans selon le rapport budgétaire.", },"identified_fallacies": {"f_1": {"type": "appel à l'autorité", "family": "ad verecundiam", "target_argument_id": "arg_1","justification": "L'autorité invoquée n'est pas compétente sur ce point précis."},"f_2": {"type": "appel à la popularité", "family": "ad populum", "target_argument_id": "arg_2","justification": "L'adoption massive ne fonde pas la validité de la mesure."}, },"argument_quality_scores": {"arg_3": {"scores": {"clarté": 0.8, "pertinence": 0.7, "rigueur": 0.6}}, },"counter_arguments": [ {"target_arg_id": "arg_1", "strategy": "réfutation directe","counter_content": "Le comité cité a un conflit d'intérêts documenté sur ce dossier."}, ],"propositional_analysis_results": [{"satisfiable": True}],"fol_analysis_results": [{"consistent": False}],"dung_frameworks": {"af_1": {"arguments": ["arg_1", "arg_2", "arg_3"],"extension": ["arg_3"], "semantics": "grounded"}},}ev = build_evidence(ETAT)print(f"Bande de verdict : {ev.verdict.band} ({ev.verdict.axes_count}/6 axes)")print(f"Axes touchés : {', '.join(ev.verdict.nontrivial_axes)}")print(f"Gates G1-G4 : {ev.gates}")print("\nPoints faibles localisés :")for wp in ev.weak_points:print(f" - [{wp.source}] {wp.label} -> {wp.target_arg_id}")print("\nForces (qualité) :", ", ".join(f"{v}{s:.1f}"for v, s in ev.quality_strengths))
Bande de verdict : EXCEEDED (6/6 axes)
Axes touchés : fallacies, quality, counter_arguments, formal_pl, formal_fol, dung
Gates G1-G4 : {'G1_arguments_extracted': True, 'G2_one_dimension_nontrivial': True, 'G3_verdict_computed': True, 'G4_no_fabrication': True}
Points faibles localisés :
- [fallacy] appel à l'autorité (famille ad verecundiam) -> arg_1
- [fallacy] appel à la popularité (famille ad populum) -> arg_2
- [dung] argument rejeté par le cadre de Dung (sémantique grounded) -> arg_1
- [dung] argument rejeté par le cadre de Dung (sémantique grounded) -> arg_2
- [fol] 1 théorie(s) du premier ordre inconsistantes (solveur Tweety) -> —
Forces (qualité) : clarté 0.8, pertinence 0.7, rigueur 0.6
3.5. Les rôles probatoires — dérivation déterministe depuis le state
Le verdict et les WeakPoint capturent où l’argumentation fléchit, mais pas combien le fléchissement compte. Une réfutation formelle qui change le jugement n’a pas le même poids qu’un sophisme localisé sur un argument dont la qualité est faible — les deux sont des faiblesses, mais leur portance probatoire diffère.
La distillation locale (sans import EPITA) dérive quatre rôles depuis le state dict, 0 appel LLM :
decisif — une violation formelle a été établie (PL/FOL réfuté, argument rejeté par Dung) : la mise à l’épreuve formelle fait tomber une inférence.
contradictoire — un sophisme est localisé sur un argument dont la qualité est solide : les axes se contredisent, tension non résolue.
corroborant — un sophisme est localisé sur un argument dont la qualité est faible, et au moins un autre argument traverse le seuil faible : deux méthodes indépendantes convergent.
non_discriminant — l’axe a tourné mais n’a rien discriminé (PL all-true, qualité vacu, Dung non-décodable) : à ne jamais présenter comme une force.
Le rôle décrit la force de l’evidence, pas une motivation humaine. Une vérification qui passe ne devient pas automatiquement une preuve positive.
# ----- Constantes des rôles probatoires (distillées depuis EPITA #1948, 0 import) -----ROLE_DECISIF ="decisif"ROLE_CONTRADICTOIRE ="contradictoire"ROLE_CORROBORANT ="corroborant"ROLE_NON_DISCRIMINANT ="non_discriminant"ROLE_ORDER = (ROLE_DECISIF, ROLE_CONTRADICTOIRE, ROLE_CORROBORANT, ROLE_NON_DISCRIMINANT)QUALITY_WEAK_FRACTION =5.0/10.0# 0.50 — bar sous lequel la qualité est faibleQUALITY_STRONG_FRACTION =7.0/10.0# 0.70 — bar au-dessus duquel la qualité est solide_MAX_PER_ROLE =3_STATEMENT_CAP =160_MAX_RANKED =5_WEIGHT_DECISIVE =1_WEIGHT_TENSION =2_WEIGHT_ACCOMPANYING =3KIND_VULNERABILITY ="vulnerabilite"KIND_TENSION ="tension"KIND_STRENGTH ="force"@dataclassclass RoleAssignment:"""Un rôle probatoire dérivé localement, sans LLM.""" role: str# 'decisif' | 'contradictoire' | 'corroborant' | 'non_discriminant' statement: str cites: tuple= ()@dataclassclass SalienceItem:"""Un élément de la hiérarchie Acte III — weight 1=le plus saillant.""" weight: int# 1 (decisif) | 2 (contradictory) | 3 (corroborant/strength) kind: str# 'vulnerabilite' | 'tension' | 'force' statement: str cites: tuple= ()@dataclassclass Surplus:"""Surplus honnête — distingue ce qui est *établi* de ce qui est *procédural*.""" established: list# convergence de >= 2 méthodes indépendantes procedural_only: list# un seul axe a tourné, sans corroborationdef _truncate(text, cap): s =str(text).strip()return s iflen(s) <= cap else s[:cap].rstrip() +" [...]"def _settled_counts(results, key_fn):"""Compte les résultats *settled* (true/false) parmi `results`. results : liste de dicts ; key_fn : dict -> Optional[bool] (None si non vérifié). Retourne {"true": n, "false": n} ; ignore les résultats non settled. """ true_n = false_n =0for r in results or (): v = key_fn(r or {})if v isTrue: true_n +=1elif v isFalse: false_n +=1return {"true": true_n, "false": false_n}def _pl_verdict_local(r):"""PL verdict : None si non vérifié, bool strict sinon.""" sat = r.get("satisfiable")if sat isNone: sat = r.get("consistent")return sat ifisinstance(sat, bool) elseNonedef _dung_rejected_local(state):"""arg_id -> sémantique Dung : arguments présents mais hors extension acceptée.""" rejected = {}for _fid, fw in (state.get("dung_frameworks") or {}).items():ifnotisinstance(fw, dict):continue accepted =set(fw.get("extension", []) or []) sem =str(fw.get("semantics", "grounded"))for a in (fw.get("arguments", []) or []):if a notin accepted: rejected.setdefault(a, sem)return rejecteddef _fallacy_targets(fallacies):"""fallacy_id -> target_arg_id (pour lookup rapide dans classify_specialist_roles).""" out = {}for fid, f in (fallacies or {}).items():ifisinstance(f, dict) and f.get("target_argument_id"): out[fid] = f["target_argument_id"]return outdef _quality_fraction(qs):"""Fraction de qualité (moyenne des scores, normalisée 0..1) ou None. qs : dict {"vertu": score, ...} avec scores dans [0..1]. """ifnotisinstance(qs, dict):returnNone scores = [v for v in qs.get("scores", {}).values() ifisinstance(v, (int, float))]ifnot scores:returnNone s =sum(scores) /len(scores)returnmax(0.0, min(1.0, s))def _quality_population_spans_weak(quality):"""True si la population d'arguments avec score qualité traverse le seuil faible. C'est la garde anti-vacuité : si tous les arguments notés sont sous le seuil faible, l'axe qualité ne discrimine pas — on ne doit pas classer un sophisme comme `corroborant` car il n'y a personne à corroborer. """ifnotisinstance(quality, dict) ornot quality:returnFalse fractions = [_quality_fraction(qs) for qs in quality.values()] fractions = [f for f in fractions if f isnotNone]ifnot fractions:returnany(f >= QUALITY_WEAK_FRACTION for f in fractions)returnany(f >= QUALITY_WEAK_FRACTION for f in fractions)def classify_specialist_roles(state):"""Dérive les 4 rôles probatoires depuis le `state` dict. 0 LLM, 0 import EPITA.""" collected = {role: [] for role in ROLE_ORDER}def _add(role, statement, cites): collected[role].append( RoleAssignment(role=role, statement=_truncate(statement, _STATEMENT_CAP), cites=cites) )# --- decisif : violation formelle établie --- pl_counts = _settled_counts(state.get("propositional_analysis_results"), _pl_verdict_local)if pl_counts["false"]: _add(ROLE_DECISIF,f"L'axe PL a réfuté {pl_counts['false']} inférence(s) : la mise à "f"l'épreuve formelle établit qu'au moins une inférence testée ne tient pas.", ("PL", "solveur Tweety")) fol_counts = _settled_counts( state.get("fol_analysis_results"),lambda r: r.get("consistent") ifisinstance(r.get("consistent"), bool) elseNone)if fol_counts["false"]: _add(ROLE_DECISIF,f"L'axe FOL a réfuté {fol_counts['false']} théorie(s) : la mise à "f"l'épreuve formelle établit qu'au moins une théorie testée est incohérente.", ("FOL", "solveur Tweety"))# --- decisif : Dung rejette ---for arg_id, label insorted(_dung_rejected_local(state).items()): _add(ROLE_DECISIF,f"Le graphe de Dung ({label}) bâti sur les arguments extraits ne retient pas "f"{arg_id} dans l'extension acceptée.", (arg_id, f"Dung {label}"))# --- contradictoire / corroborant : cross-axis verdicts par argument --- args = state.get("identified_arguments") or {}ifisinstance(args, dict): fallacy_by_arg = _fallacy_targets(state.get("identified_fallacies")) quality = state.get("argument_quality_scores") quality_spans = _quality_population_spans_weak(quality)for arg_id insorted(args):if arg_id notin fallacy_by_arg.values():# skip si aucun sophisme ne cible cet argumentcontinue qs = quality.get(arg_id) ifisinstance(quality, dict) elseNone fraction = _quality_fraction(qs)if fraction isNone:continueif fraction >= QUALITY_STRONG_FRACTION: _add(ROLE_CONTRADICTOIRE,f"Tension non résolue sur {arg_id} : un sophisme y est localisé "f"mais la qualité mesurée est solide ({fraction:.0%} du maximum applicable) "f"— les axes se contredisent.", (arg_id, "sophisme", "qualite"))elif fraction < QUALITY_WEAK_FRACTION and quality_spans: _add(ROLE_CORROBORANT,f"Les axes sophisme et qualité corroborent la faiblesse de {arg_id} "f"({fraction:.0%} du maximum applicable) — deux méthodes indépendantes s'accordent.", (arg_id, "sophisme", "qualite"))# --- non-discriminant : ran, settled, but nothing distinguished ---if pl_counts["false"] ==0and pl_counts["true"]: _add(ROLE_NON_DISCRIMINANT,f"L'axe PL a vérifié {pl_counts['true']} inférence(s), toutes satisfaisables "f"— le test ne distingue rien ici.", ("PL", "solveur Tweety"))if fol_counts["false"] ==0and fol_counts["true"]: _add(ROLE_NON_DISCRIMINANT,f"L'axe FOL a vérifié {fol_counts['true']} théorie(s), toutes cohérentes "f"— le test ne distingue rien ici.", ("FOL", "solveur Tweety"))# qualité vacu : toutes les fractions sous le seuil faible quality_entries = state.get("argument_quality_scores") measured = []ifisinstance(quality_entries, dict): measured = [f for f in (_quality_fraction(qs) for qs in quality_entries.values()) if f isnotNone]if measured andnotany(f >= QUALITY_WEAK_FRACTION for f in measured): _add(ROLE_NON_DISCRIMINANT,f"L'axe qualité ne discrimine pas ce run : {len(measured)}/{len(measured)} "f"notes mesurées sous le seuil faible ({QUALITY_WEAK_FRACTION:.0%}) "f"— la faiblesse mesurée n'y distingue rien.", ("qualite", "évaluateur de vertus")) out = []for role in ROLE_ORDER: out.extend(collected[role][:_MAX_PER_ROLE])return outdef assess_conclusion_salience(state):"""Construit la hiérarchie Acte III — P1>P2>P3, non-discriminant exclu du ranking.""" roles = classify_specialist_roles(state) by_role = {role: [a for a in roles if a.role == role] for role in ROLE_ORDER} ranked = []for a in by_role[ROLE_DECISIF]: ranked.append(SalienceItem(_WEIGHT_DECISIVE, KIND_VULNERABILITY, _truncate(a.statement, _STATEMENT_CAP), a.cites))for a in by_role[ROLE_CONTRADICTOIRE]: ranked.append(SalienceItem(_WEIGHT_TENSION, KIND_TENSION, _truncate(a.statement, _STATEMENT_CAP), a.cites))for a in by_role[ROLE_CORROBORANT]: ranked.append(SalienceItem(_WEIGHT_ACCOMPANYING, KIND_VULNERABILITY, _truncate(a.statement, _STATEMENT_CAP), a.cites)) ranked = ranked[:_MAX_RANKED]return rankeddef assess_surplus(state):"""Surplus honnête — distingue ce qui est *établi* (multi-axes) de *procédural* (un seul axe).""" established = [] procedural = []for a in classify_specialist_roles(state):if a.role == ROLE_CORROBORANT: established.append(a.statement)elif a.role == ROLE_DECISIF: established.append(a.statement)elif a.role == ROLE_NON_DISCRIMINANT: procedural.append(a.statement)return Surplus(established=established, procedural_only=procedural)# --- Démonstration sur l'ETAT de référence du notebook ---roles_demo = classify_specialist_roles(ETAT)print(f"Rôles dérivés (sur ETAT de référence) : {len(roles_demo)}")for r in roles_demo:print(f" - [{r.role:<16}] {r.statement[:80]}")salience_demo = assess_conclusion_salience(ETAT)print(f"\nHiérarchie Acte III : {len(salience_demo)} item(s)")for s in salience_demo:print(f" - P{s.weight} [{s.kind:<14}] {s.statement[:80]}")surplus_demo = assess_surplus(ETAT)print(f"\nSurplus établi : {len(surplus_demo.established)}")print(f"Surplus procédural : {len(surplus_demo.procedural_only)}")
Rôles dérivés (sur ETAT de référence) : 4
- [decisif ] L'axe FOL a réfuté 1 théorie(s) : la mise à l'épreuve formelle établit qu'au moi
- [decisif ] Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_
- [decisif ] Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_
- [non_discriminant] L'axe PL a vérifié 1 inférence(s), toutes satisfaisables — le test ne distingue
Hiérarchie Acte III : 3 item(s)
- P1 [vulnerabilite ] L'axe FOL a réfuté 1 théorie(s) : la mise à l'épreuve formelle établit qu'au moi
- P1 [vulnerabilite ] Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_
- P1 [vulnerabilite ] Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_
Surplus établi : 3
Surplus procédural : 1
3.6. Cas synthétiques — couverture des 4 rôles
Quatre états synthétiques couvrent chacun un rôle dominant :
Cas 1 — décisif : PL réfuté → le verdict formel fait tomber une inférence, le rôle pèse 1.
Cas 2 — contradictoire : sophisme localisé sur un argument dont la qualité est solide (≥ 70 %) → tension non résolue, le rôle pèse 2.
Cas 3 — corroborant : sophisme localisé sur un argument dont la qualité est faible (< 50 %) ET au moins un autre argument traverse le seuil faible → deux axes convergent, le rôle pèse 3.
Cas 4 — non-discriminant en deux formes : (4a) PL all-true (axe tourné, rien réfuté) ; (4b) qualité vacu (toutes les notes sous le seuil faible). Ces résultats ne doivent jamais être présentés comme des forces — assess_conclusion_salience les exclut du ranking Acte III.
Chaque cas est un dictplain (pas d’objet EPITA) et passe dans classify_specialist_roles. L’objectif est de vérifier que la dérivation est correcte avant de l’utiliser dans la narration LLM.
# ----- Cas 1 : decisif (PL refute) -----ETAT_DECISIF = {"identified_arguments": {"a1": "Si X, alors Y."},"propositional_analysis_results": [{"satisfiable": False}], # réfutation PL"fol_analysis_results": [],"dung_frameworks": {},}roles_1 = classify_specialist_roles(ETAT_DECISIF)print(f"Cas 1 (PL refute) — rôles : {[r.role for r in roles_1]}")assertany(r.role == ROLE_DECISIF for r in roles_1), "Cas 1 devrait inclure DECISIF"# ----- Cas 2 : contradictoire (sophisme + qualité solide) -----ETAT_CONTRADICTOIRE = {"identified_arguments": {"arg_1": "Affirmation controversée."},"identified_fallacies": {"f_1": {"type": "ad hominem", "target_argument_id": "arg_1","justification": "Attaque la personne, pas l'argument."} },"argument_quality_scores": {# Score moyen (0.85 + 0.80) / 2 = 0.825 → au-dessus de STRONG bar (0.70)"arg_1": {"scores": {"clarte": 0.85, "rigueur": 0.80}}, },"propositional_analysis_results": [],"fol_analysis_results": [],"dung_frameworks": {},}roles_2 = classify_specialist_roles(ETAT_CONTRADICTOIRE)print(f"Cas 2 (sophisme + qualité solide) — rôles : {[r.role for r in roles_2]}")assertany(r.role == ROLE_CONTRADICTOIRE for r in roles_2), "Cas 2 devrait inclure CONTRADICTOIRE"# ----- Cas 3 : corroborant (sophisme + qualité faible + qualité population spans weak) -----ETAT_CORROBORANT = {"identified_arguments": {"arg_1": "Affirmation faible.","arg_2": "Autre argument, fort.", # garantit que la population traverse le seuil },"identified_fallacies": {"f_1": {"type": "petitio principii", "target_argument_id": "arg_1","justification": "Présuppose ce qu'il faut démontrer."} },"argument_quality_scores": {# arg_1 : score (0.2 + 0.3) / 2 = 0.25 → sous WEAK bar (0.50)"arg_1": {"scores": {"clarte": 0.2, "rigueur": 0.3}},# arg_2 : 0.85 → la population traverse le seuil faible"arg_2": {"scores": {"clarte": 0.85, "rigueur": 0.85}}, },"propositional_analysis_results": [],"fol_analysis_results": [],"dung_frameworks": {},}roles_3 = classify_specialist_roles(ETAT_CORROBORANT)print(f"Cas 3 (sophisme + qualité faible + spans weak) — rôles : {[r.role for r in roles_3]}")assertany(r.role == ROLE_CORROBORANT for r in roles_3), "Cas 3 devrait inclure CORROBORANT"# ----- Cas 4a : non-discriminant (PL all-true) -----ETAT_PL_ALL_TRUE = {"identified_arguments": {"a1": "Plausible claim."},"propositional_analysis_results": [{"satisfiable": True}, {"satisfiable": True}],"fol_analysis_results": [],"dung_frameworks": {},}roles_4a = classify_specialist_roles(ETAT_PL_ALL_TRUE)print(f"Cas 4a (PL all-true) — rôles : {[r.role for r in roles_4a]}")assertany(r.role == ROLE_NON_DISCRIMINANT for r in roles_4a), "Cas 4a devrait inclure NON_DISCRIMINANT"salience_4a = assess_conclusion_salience(ETAT_PL_ALL_TRUE)assert salience_4a == [], "Cas 4a NE DOIT PAS apparaître dans la hiérarchie Acte III"print(f" Salience Acte III : {len(salience_4a)} item(s) (attendu 0)")# ----- Cas 4b : non-discriminant (qualité vacu) -----ETAT_QUALITE_VACU = {"identified_arguments": {"arg_1": "Affirmation.","arg_2": "Autre affirmation.", },"argument_quality_scores": {# Tous sous le seuil faible (0.50)"arg_1": {"scores": {"clarte": 0.2, "rigueur": 0.1}},"arg_2": {"scores": {"clarte": 0.3, "rigueur": 0.2}}, },"propositional_analysis_results": [],"fol_analysis_results": [],"dung_frameworks": {},}roles_4b = classify_specialist_roles(ETAT_QUALITE_VACU)print(f"Cas 4b (qualité vacu) — rôles : {[r.role for r in roles_4b]}")assertany(r.role == ROLE_NON_DISCRIMINANT for r in roles_4b), "Cas 4b devrait inclure NON_DISCRIMINANT"salience_4b = assess_conclusion_salience(ETAT_QUALITE_VACU)assert salience_4b == [], "Cas 4b NE DOIT PAS apparaître dans la hiérarchie Acte III"print(f" Salience Acte III : {len(salience_4b)} item(s) (attendu 0)")print("\nTous les cas synthétiques passent (4 rôles dérivés + non-discriminant exclu du ranking).")
Cas 1 (PL refute) — rôles : ['decisif']
Cas 2 (sophisme + qualité solide) — rôles : ['contradictoire']
Cas 3 (sophisme + qualité faible + spans weak) — rôles : ['corroborant']
Cas 4a (PL all-true) — rôles : ['non_discriminant']
Salience Acte III : 0 item(s) (attendu 0)
Cas 4b (qualité vacu) — rôles : ['non_discriminant']
Salience Acte III : 0 item(s) (attendu 0)
Tous les cas synthétiques passent (4 rôles dérivés + non-discriminant exclu du ranking).
3.7. Acte III — hiérarchie par portance
L’Acte III (conclusion) est traditionnellement le lieu où l’on classe ce qui’a été trouvé. L’ordre de présentation suit la portance probatoire : un élément décisif (P1, weight=1) passe avant une tension (P2, weight=2), qui passe avant une corroboration ou une force accompagnante (P3, weight=3). Les éléments non_discriminant sont exclus du ranking — ils ne peuvent pas bouger le jugement.
Cette hiérarchie est portée par le scaffold déterministe et injectée comme contexte dans le prompt Acte III (cf §6) : le LLM ne la calcule pas, il la respecte. Le renderer fail-loud (§5) refuse toute conclusion LLM qui nommerait un non_discriminant comme une force — l’honnêteté reste sous contrôle du scaffold.
# Affichage de la hiérarchie Acte III sur l'ETAT de référence du notebooksalience = assess_conclusion_salience(ETAT)surplus = assess_surplus(ETAT)print("="*60)print("HIÉRARCHIE ACTE III — portance probatoire")print("="*60)ifnot salience:print("(rien à classer — état trop pauvre en evidence discriminante)")else:for i, s inenumerate(salience, start=1): label = {1: "P1 décisif", 2: "P2 tension", 3: "P3 vulnérabilité/force"}[s.weight]print(f"\n{i}. {label} — {s.kind}")print(f" {s.statement}")if s.cites:print(f" cites: {' · '.join(s.cites)}")print("\n"+"="*60)print("SURPLUS — distingué *établi* / *procédural*")print("="*60)print(f"\nÉtabli (convergence ≥ 2 axes ou violation formelle) : {len(surplus.established)}")for s in surplus.established:print(f" ✓ {s[:90]}")print(f"\nProcédural (axe unique tourné, rien de discriminant) : {len(surplus.procedural_only)}")for s in surplus.procedural_only:print(f" · {s[:90]}")# Assertion d'honnêteté : surplus.procedural_only ne doit jamais prétendre être une forcefor s in surplus.procedural_only:assert"satisfaisable"in s.lower() or"sous le seuil"in s.lower(), \f"Surplus procédural mal étiqueté : {s[:100]}"
============================================================
HIÉRARCHIE ACTE III — portance probatoire
============================================================
1. P1 décisif — vulnerabilite
L'axe FOL a réfuté 1 théorie(s) : la mise à l'épreuve formelle établit qu'au moins une théorie testée est incohérente.
cites: FOL · solveur Tweety
2. P1 décisif — vulnerabilite
Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_1 dans l'extension acceptée.
cites: arg_1 · Dung grounded
3. P1 décisif — vulnerabilite
Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_2 dans l'extension acceptée.
cites: arg_2 · Dung grounded
============================================================
SURPLUS — distingué *établi* / *procédural*
============================================================
Établi (convergence ≥ 2 axes ou violation formelle) : 3
✓ L'axe FOL a réfuté 1 théorie(s) : la mise à l'épreuve formelle établit qu'au moins une thé
✓ Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_1 dans l'e
✓ Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_2 dans l'e
Procédural (axe unique tourné, rien de discriminant) : 1
· L'axe PL a vérifié 1 inférence(s), toutes satisfaisables — le test ne distingue rien ici.
3.8. Surplus — établi vs procédural
Le scaffold dérive deux listes de surplus, affichées séparément dans le rapport :
established — convergence de plusieurs méthodes indépendantes, ou violation formelle établie. C’est ce qui peut être nommé comme acquis dans l’Acte III.
procedural_only — un axe a tourné mais n’a rien discriminé. À ne jamais présenter comme une force : le surplus est honnête s’il distingue l’absence de signal de l’absence d’effort.
Cette distinction ferme l’écueil classique : un test qui passe (PL all-true, qualité vacu) ne devient pas automatiquement une preuve positive. Le renderer Acte III fail-loud (cf §5) refuse toute conclusion qui nommerait un procedural_only comme acquis.
def _salience_block_for_prompt(state, cap_per_item=120):"""Bloc texte prêt à être injecté dans le prompt Acte III — classement par portance. Le LLM ne calcule pas la hiérarchie ; il la reçoit comme contexte à respecter. Les `non_discriminant` sont omis (ranking excluant). """ salience = assess_conclusion_salience(state)ifnot salience:return ("Hiérarchie portance (Acte III) : vide — aucun élément probant ""(les axes ont tourné mais rien n'a été discriminant)." ) lines = ["Hiérarchie portance (Acte III, à respecter dans cet ordre) :"]for i, s inenumerate(salience, start=1): label = {1: "P1 décisif", 2: "P2 tension", 3: "P3 vulnérabilité/force"}[s.weight] lines.append(f" {i}. {label} ({s.kind}) : {_truncate(s.statement, cap_per_item)}")return"\n".join(lines)# Aperçu du bloc tel qu'il sera passé au prompt Acte III :print(_salience_block_for_prompt(ETAT))
Hiérarchie portance (Acte III, à respecter dans cet ordre) :
1. P1 décisif (vulnerabilite) : L'axe FOL a réfuté 1 théorie(s) : la mise à l'épreuve formelle établit qu'au moins une théorie testée est incohérente.
2. P1 décisif (vulnerabilite) : Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_1 dans l'extension acceptée.
3. P1 décisif (vulnerabilite) : Le graphe de Dung (grounded) bâti sur les arguments extraits ne retient pas arg_2 dans l'extension acceptée.
4. Le gate de lisibilité — la règle de tissage (§4) en code
C’est le contrat anti-énumération du rapport. La spec §4 dit : pour chaque citation d’un cadre (Tweety, Dung, taxonomie, vertus), le rapport DOIT fournir un ancrage narratif — le cadre est la preuve d’un point de récit, jamais une entrée de liste isolée.
Le détecteur repère l’odeur d’énumération — le contre-exemple canonique :
❌ « Sophisme : ad verecundiam (0.8) » — un nom + un nombre, détaché de toute histoire.
Une ligne est nue quand elle cite un cadre ET porte un score isolé ET ne contient aucun verbe narratif. Le contraste :
✅ « … le solveur Tweety confirme l’inconsistance de l’inférence. » — cite Tweety, porte un verbe, pas de score isolé → tissée.
Le gate reporte ce qu’il trouve (WARN pour un résidu, FAIL pour une énumération manifeste) ; il ne truque jamais un PASS pour plaire (#1019).
_FRAMEWORKS = {"tweety", "dung", "aspic", "aif", "walton", "jtms", "atms"}_FALLACY_NAMES = ("ad hominem", "ad verecundiam", "ad populum", "ad baculum", "tu quoque","post hoc", "petitio", "non sequitur", "slippery slope", "straw man","false dichotomy", "faux dilemme", "sophisme", "paralogisme")_NARRATIVE_VERBS = ("confirme", "invalide", "valide", "montre", "prouve", "démontre", "isole","défait", "appuie", "soutient", "implique", "révèle", "indique", "établit","signifie", "traduit", "reflète", "expose", "illustre", "n'est", "c'est","permet", "justifie", "constitue")_STANDALONE_SCORE_RE = re.compile(r"\(\s*0?\.\d{1,2}\s*\)"r"|\(?\s*(?:score|confiance|poids|confidence|sévérité)\s*[:=]?\s*0?\.\d{1,2}\b")_DUMP_HEADING_RE = re.compile(r"^\s*#{0,6}\s*(?:Sophisme|Argument|Dimension|Phase)\s+\d+\s*[:\-—]?", re.IGNORECASE | re.MULTILINE)@dataclassclass GateVerdict: band: str# PASS | WARN | FAIL reasons: List[str] = field(default_factory=list)@propertydef passed(self) ->bool:returnself.band in ("PASS", "WARN")def merge(self, other: "GateVerdict") ->"GateVerdict": order = {"PASS": 0, "WARN": 1, "FAIL": 2} worst =selfif order[self.band] >= order[other.band] else otherreturn GateVerdict(worst.band, [*self.reasons, *other.reasons])def _worsen(a: str, b: str) ->str: order = {"PASS": 0, "WARN": 1, "FAIL": 2}return a if order[a] >= order[b] else bdef _line_has_framework(line: str) ->bool: low = line.lower()returnany(k in low for k in _FRAMEWORKS) orany(n in low for n in _FALLACY_NAMES)def _line_is_bare(line: str) ->bool:"""Cadre cité + score isolé + AUCUN verbe narratif = référence NUE."""ifnot _line_has_framework(line):returnFalseifnot _STANDALONE_SCORE_RE.search(line):returnFalsereturnnotany(v in line.lower() for v in _NARRATIVE_VERBS)class ReadabilityGate:def__init__(self, bare_warn=2, bare_fail=3, dump_fail=2):self.bare_warn, self.bare_fail, self.dump_fail = bare_warn, bare_fail, dump_faildef check_acts(self, acts: RestitutionActs) -> GateVerdict: reasons, worst, total_bare = [], "PASS", 0for n in (1, 2, 3): text = acts.as_dict()[n] or"" title = {1: "Acte I", 2: "Acte II", 3: "Acte III"}[n]if acts.is_missing(n): reasons.append(f"{title} absent — le rapport n'est pas complet (générateur non câblé ou acte vide).") worst = _worsen(worst, "FAIL");continue bare = [ln.strip() for ln in text.splitlines() if _line_is_bare(ln)] total_bare +=len(bare)if bare: reasons.append(f"{title}: {len(bare)} référence(s) de cadre « nue(s) » "f"(framework + score isolé, sans ancrage narratif — §4). Ex: « {bare[0][:90]} ».") dump_n =len(_DUMP_HEADING_RE.findall(text))if dump_n >=self.dump_fail: reasons.append(f"{title}: {dump_n} titres « Sophisme N: / Argument N: » — l'acte a dégénéré en énumération.") worst = _worsen(worst, "FAIL")if total_bare >=self.bare_fail: worst = _worsen(worst, "FAIL")elif total_bare >0: worst = _worsen(worst, "WARN")return GateVerdict(worst, reasons)gate = ReadabilityGate()woven = RestitutionActs( act1_framing="Le discours se présente comme un plaidoyer technique au ton assuré. "*3, act2_narrative="Le solveur Tweety confirme l'inconsistance de cette inférence, ce qui défait l'argument central du plaidoyer. "*2, act3_conclusion="En conclusion, le cadre de Dung isole cette revendication comme rejetée ; le lecteur doit la recevoir avec prudence. "*2)bare = RestitutionActs( act1_framing="Mise en situation correcte et suffisamment développée du discours technique. "*3, act2_narrative="ad verecundiam (0.8)\nad populum (0.7)\ndung (0.9)", # nues : nom + score, aucun verbe act3_conclusion="Conclusion actionnable détaillée à destination du lecteur, nuancée. "*3)print("Actes TISSÉS :", gate.check_acts(woven).band)vb = gate.check_acts(bare)print("Actes NUS :", vb.band)for r in vb.reasons:print(" -", r)
Le renderer assemble les trois actes en un Markdown lisible — et c’est ici que l’honnêteté devient visible. Un acte manquant est nommé (« Acte II indisponible — … »), jamais omis. Un acte dégradé est émis avec sa note de dégradation. Et le verdict du gate est surfacé verbatim dans le rapport : le document ne se déclare pas lisible si le gate n’est pas d’accord.
Démontrons-le sur le cas le plus instructif : un rapport où seul l’Acte I a été produit (Actes II et III non câblés). Au lieu d’un rapport tronqué qui semble complet, on obtient un rapport qui dit ce qui manque.
_MIN_ACT_CHARS =120_MISSING_WORDING = {1: ("Acte I indisponible — le générateur de mise en situation n'a pas produit de cadre ""(non câblé ou en échec). Le rapport entre directement dans l'analyse."),2: ("Acte II indisponible — le récit dialectique n'a pas pu être généré ""(cœur narratif absent). Le rapport n'a pas de substance narrative."),3: ("Acte III indisponible — la conclusion actionnable n'a pas été générée ""(portes G1–G4 non évaluées). Le rapport s'arrête sans synthèse."),}@dataclassclass RenderedReport: markdown: str verdict: GateVerdictdef _render_verdict_block(v: GateVerdict) ->str: icon = {"PASS": "[PASS]", "WARN": "[WARN]", "FAIL": "[FAIL]"}.get(v.band, "[?]") lines = ["---", "", f"## Gate lisibilité — {icon}{v.band}", ""]if v.reasons: lines += ["Contrôles structurels (règle de tissage, §4) :", ""] lines += [f"- {r}"for r in v.reasons]else: lines.append("Tous les contrôles structurels passent : 3 actes présents et non-triviaux, aucune référence de cadre nue.") lines += ["", "_Verdict honnête reporté par le gate — non truqué (#1019)._"]return"\n".join(lines)def render_report(acts: RestitutionActs, gate: Optional[ReadabilityGate] =None) -> RenderedReport: gate = gate or ReadabilityGate() body, thin = [], []for n in (1, 2, 3): body += [f"## {ACT_TITLES[n]}", ""]if acts.is_missing(n): body += [f"_{_MISSING_WORDING[n]}_", ""] # fail-loud : NOMME l'acte manquantcontinue text = acts.as_dict()[n].strip() body += [text, ""] key = RestitutionActs.act_key(n)if key in acts.degraded: body += [f"> [!WARNING] **Acte dégradé** — {acts.degraded[key]}", ""]iflen(text) < _MIN_ACT_CHARS: thin.append(f"{ACT_TITLES[n]} est anormalement court ({len(text)} caractères) — sortie probablement stub/dégradée.") verdict = gate.check_acts(acts)if thin: verdict = verdict.merge(GateVerdict("WARN", thin)) source = (acts.source_id or"corpus_anonyme").strip() parts = [f"# Rapport de restitution — {source}", "", ("Récit en trois actes (mise en situation -> analyse narrative -> conclusion actionnable). ""Les cadres formels/informels (Tweety, Dung, taxonomie, vertus) sont les *preuves* citées en ""appui du récit, jamais une énumération (§4)."), "","\n".join(body).strip(), "", _render_verdict_block(verdict), "", ]return RenderedReport("\n".join(parts).rstrip() +"\n", verdict)partiel = RestitutionActs( act1_framing=("Le discours relève du plaidoyer institutionnel : il défend une réforme en s'appuyant ""sur l'autorité d'un comité et sur l'adhésion générale. On peut s'attendre à des appuis ""d'autorité et de popularité, classiques de ce registre."), source_id="doc_A") # Actes II et III NON câblésrendu = render_report(partiel)print(rendu.markdown)
# Rapport de restitution — doc_A
Récit en trois actes (mise en situation -> analyse narrative -> conclusion actionnable). Les cadres formels/informels (Tweety, Dung, taxonomie, vertus) sont les *preuves* citées en appui du récit, jamais une énumération (§4).
## Acte I — Mise en situation
Le discours relève du plaidoyer institutionnel : il défend une réforme en s'appuyant sur l'autorité d'un comité et sur l'adhésion générale. On peut s'attendre à des appuis d'autorité et de popularité, classiques de ce registre.
## Acte II — Récit dialectique
_Acte II indisponible — le récit dialectique n'a pas pu être généré (cœur narratif absent). Le rapport n'a pas de substance narrative._
## Acte III — Conclusion actionnable
_Acte III indisponible — la conclusion actionnable n'a pas été générée (portes G1–G4 non évaluées). Le rapport s'arrête sans synthèse._
---
## Gate lisibilité — [FAIL] FAIL
Contrôles structurels (règle de tissage, §4) :
- Acte II absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
- Acte III absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
_Verdict honnête reporté par le gate — non truqué (#1019)._
Le rapport est complet dans sa structure tout en étant honnête sur ses trous : un lecteur sait exactement ce qui a été produit et ce qui manque. Le verdict [FAIL] du gate n’est pas un bug — c’est le rapport qui refuse de se déclarer lisible alors que deux actes manquent.
Jusqu’ici, tout est déterministe et a tourné entièrement. La narration des trois actes, elle, est conduite par un LLM. La discipline (Epic source #1258) :
chaque acte a un prompt construit déterministiquement à partir de l’evidence (build_act1/2/3_prompt) — il varie avec le corpus, ce n’est pas un template statique (#1108). On peut donc l’inspecter ;
le LLM est un callable async injectableCallable[[str], Awaitable[str]] — on y branchera un vrai client au §7 ;
quand aucun LLM n’est injecté (ou qu’un acte échoue), l’orchestrateur est fail-loud : narration vide, note de dégradation, et le renderer nomme le trou. Jamais de template de repli.
Les trois prompts partagent un bloc evidence (les données vérifiées du state) et la règle de tissage, puis spécialisent la consigne par acte. Affichons le prompt conduit de l’Acte III :
LlmCallable = Callable[[str], Awaitable[str]]_BAND_CEILING = {"EXCEEDED": "Caractérise l'analyse comme APPROFONDIE et multi-axes (profondeur formelle vérifiée + profil de qualité).","MATCH": "Caractérise l'analyse comme COMPLÈTE : elle couvre les axes principaux. Nomme honnêtement les axes non-concluables.","PARTIAL": "Caractérise l'analyse comme PARTIELLE : diagnostic honnête, nomme ce qui est couvert ET ce qui manque.","BELOW": "Caractérise l'analyse comme MINIMALE : couverture réduite, liste honnête des axes non-concluables.",}_DOCTRINE = ("RÈGLE DE TISSAGE (§4) : chaque citation d'un cadre (Tweety, Dung, taxonomie, vertus)\n""DOIT être ancrée narrativement — liée à un argument localisé ET au verdict concret.\n""INTERDIT : « ad verecundiam (0.8) » (nom + score isolé). Le cadre APPUIE un mouvement\n""du récit, jamais une sous-section ni une entrée de liste isolée.\n""HONNÊTETÉ (anti-pendule #1019) : ne formule jamais un claim au-delà de la bande de verdict.\n""Si un axe manque, dis-le ; ne le simule jamais. Aucun sophisme fabriqué pour remplir.")def _evidence_block(ev: Evidence) ->str: v = ev.verdictif v isnotNone: synth = (f"Bande de verdict : {v.band} (couverture : {v.axes_count}/6 axes non-triviaux).\n"f" Axes touchés : {', '.join(v.nontrivial_axes) or'aucun'}.\n"f" Axes manquants : {', '.join(v.missing_axes) or'aucun'}.\n"f" Plafond de claim autorisé : {_BAND_CEILING[v.band]}")else: synth ="Bande de verdict : NON CALCULABLE (gates G1–G4 non passés)." claims ="\n".join(f" - {c}"for c in ev.claim_excerpts) or" (aucune revendication extraite — G1 non passé)" strengths ="\n".join(f" - {vir} (meilleur score : {sc:.1f})"for vir, sc in ev.quality_strengths[:6]) or" (axe qualité non concluable)" weaknesses ="\n".join(f" - [{wp.source}] {wp.label} sur {wp.target_arg_id}"+ (f" : {wp.detail}"if wp.detail else"")for wp in ev.weak_points[:8]) or" (aucun point faible localisé — texte vertueux)" counters ="\n".join(f" - Pour {t} ({s}) : {snip}"for t, s, snip in ev.counter_strategies[:8]) or" (aucun contre-argument généré)"return ("DONNÉES VÉRIFIÉES DANS LE STATE (ne citer que celles-ci) :\n\n"f"[CE QUI A ÉTÉ DIT]\n{claims}\n\n"f"[VERDICT GATED]\n{synth}\n\n"f"[CE QUI TIENT — forces]\n{strengths}\n\n"f"[CE QUI NE TIENT PAS — faiblesses localisées]\n{weaknesses}\n\n"f"[CONTRE-POINTS]\n{counters}")def build_act1_prompt(ev: Evidence) ->str:return ("Tu es l'auteur de l'ACTE I d'un rapport de restitution argumentative — la MISE EN\n""SITUATION. En 120-220 mots de prose continue (pas de liste) : (1) caractérise le genre\n""du discours (plaidoyer, polémique, exposé…) ; (2) annonce les enjeux pour le lecteur ;\n""(3) anticipe le SPECTRE de sophismes attendu pour ce genre — c'est le SEUL acte qui peut\n""anticiper. Reste prudent : tu n'as pas encore mené l'analyse, tu poses le décor.\n\n"+ _DOCTRINE +"\n\n"+ _evidence_block(ev) +"\n\n""Rédige en français. Décris une ATTENTE de lecture, n'énumère pas les sophismes trouvés." )def build_act2_prompt(ev: Evidence) ->str:return ("Tu es l'auteur de l'ACTE II d'un rapport de restitution argumentative — le RÉCIT\n""DIALECTIQUE, cœur du rapport. En 300-550 mots de prose continue, raconte le discours\n""mouvement par mouvement : pour chaque argument, dis ce qu'il avance, puis comment\n""l'analyse le reçoit (sophisme localisé, verdict formel PL/FOL/Dung via Tweety, force de\n""qualité, contre-argument). Le cadre est la PREUVE d'un point du récit, jamais une entrée\n""de liste.\n\n"+ _DOCTRINE +"\n\n"+ _evidence_block(ev) +"\n\n""Rédige en français, prose continue (sous-titres ### par mouvement autorisés ; PAS de\n""puces « Sophisme N: »). La narration doit VARIER selon le contenu réel ci-dessus." )def build_act3_prompt(ev: Evidence) ->str:return ("Tu es l'auteur de l'ACTE III d'un rapport de restitution argumentative — la CONCLUSION\n""ORIENTÉE LECTEUR. Trois battements en prose (sous-titres thématiques en ###) :\n""1. Ce que le discours dit vraiment (cite les revendications) ;\n""2. Ce qui tient et ce qui ne tient pas (verdict en langage clair) ;\n""3. Comment se faire son avis (points de prudence, pas des cibles à abattre).\n\n"+ _DOCTRINE +"\n\n"+ _evidence_block(ev) +"\n\n""Rédige en français, 300-550 mots, markdown léger. La conclusion doit VARIER selon le\n""contenu réel ci-dessus : pas de prose générique recyclable." )_ACT_PROMPT = {1: build_act1_prompt, 2: build_act2_prompt, 3: build_act3_prompt}print(build_act3_prompt(ev))
Tu es l'auteur de l'ACTE III d'un rapport de restitution argumentative — la CONCLUSION
ORIENTÉE LECTEUR. Trois battements en prose (sous-titres thématiques en ###) :
1. Ce que le discours dit vraiment (cite les revendications) ;
2. Ce qui tient et ce qui ne tient pas (verdict en langage clair) ;
3. Comment se faire son avis (points de prudence, pas des cibles à abattre).
RÈGLE DE TISSAGE (§4) : chaque citation d'un cadre (Tweety, Dung, taxonomie, vertus)
DOIT être ancrée narrativement — liée à un argument localisé ET au verdict concret.
INTERDIT : « ad verecundiam (0.8) » (nom + score isolé). Le cadre APPUIE un mouvement
du récit, jamais une sous-section ni une entrée de liste isolée.
HONNÊTETÉ (anti-pendule #1019) : ne formule jamais un claim au-delà de la bande de verdict.
Si un axe manque, dis-le ; ne le simule jamais. Aucun sophisme fabriqué pour remplir.
DONNÉES VÉRIFIÉES DANS LE STATE (ne citer que celles-ci) :
[CE QUI A ÉTÉ DIT]
- Les experts du comité affirment que la réforme est sûre, donc elle l'est.
- Tout le monde adopte cette mesure ; vous devriez l'adopter aussi.
- La transition réduit les coûts de 12% sur trois ans selon le rapport budgétaire.
[VERDICT GATED]
Bande de verdict : EXCEEDED (couverture : 6/6 axes non-triviaux).
Axes touchés : fallacies, quality, counter_arguments, formal_pl, formal_fol, dung.
Axes manquants : aucun.
Plafond de claim autorisé : Caractérise l'analyse comme APPROFONDIE et multi-axes (profondeur formelle vérifiée + profil de qualité).
[CE QUI TIENT — forces]
- clarté (meilleur score : 0.8)
- pertinence (meilleur score : 0.7)
- rigueur (meilleur score : 0.6)
[CE QUI NE TIENT PAS — faiblesses localisées]
- [fallacy] appel à l'autorité (famille ad verecundiam) sur arg_1 : L'autorité invoquée n'est pas compétente sur ce point précis.
- [fallacy] appel à la popularité (famille ad populum) sur arg_2 : L'adoption massive ne fonde pas la validité de la mesure.
- [dung] argument rejeté par le cadre de Dung (sémantique grounded) sur arg_1
- [dung] argument rejeté par le cadre de Dung (sémantique grounded) sur arg_2
- [fol] 1 théorie(s) du premier ordre inconsistantes (solveur Tweety) sur —
[CONTRE-POINTS]
- Pour arg_1 (réfutation directe) : Le comité cité a un conflit d'intérêts documenté sur ce dossier.
Rédige en français, 300-550 mots, markdown léger. La conclusion doit VARIER selon le
contenu réel ci-dessus : pas de prose générique recyclable.
Le prompt cite les vraies revendications, la vraie bande, les vrais points faibles — il est impossible de le réutiliser tel quel sur un autre corpus. C’est le sens de « conduit, pas template ».
L’orchestrateur ci-dessous construit toujours l’evidence déterministe, puis narre les trois actes via le callable injecté. Sans callable (ou si un acte échoue), il est fail-loud — pas de template :
@dataclassclass ActResult: narrative: str status: str# woven | unavailable note: str=""asyncdef narrate_act(n: int, ev: Evidence, llm_callable: LlmCallable) -> ActResult:try: raw =await llm_callable(_ACT_PROMPT[n](ev))exceptExceptionas exc: # noqa: BLE001 — surface, don't fabricatereturn ActResult("", "unavailable", f"{ACT_TITLES[n]} : le LLM a échoué — {exc} (fail-loud).")ifnot raw:return ActResult("", "unavailable", f"{ACT_TITLES[n]} : le LLM n'a rien produit (fail-loud, #1108).")return ActResult(str(raw).strip(), "woven")asyncdef narrate_full_report(state: Dict[str, Any], llm_callable: Optional[LlmCallable] =None) -> RestitutionActs:"""Evidence déterministe TOUJOURS construite ; les 3 actes sont narrés par le LLM SEULEMENT si llm_callable est fourni ; sinon fail-loud par acte (jamais de template, #1108).""" ev = build_evidence(state) keys = ("act1_framing", "act2_narrative", "act3_conclusion")ifnot ev.gates["G1_arguments_extracted"]: note ="Aucun argument extrait — pas de substrat narratif (G1 échoué)."return RestitutionActs(source_id="corpus_anonyme", degraded={k: note for k in keys})if llm_callable isNone: note ="Narration non conduite — aucun LLM injecté (fail-loud, #1108)."return RestitutionActs(source_id="corpus_anonyme", degraded={k: note for k in keys}) acts, deg = {}, {}for n in (1, 2, 3): r =await narrate_act(n, ev, llm_callable) acts[n] = r.narrativeif r.status !="woven": deg[RestitutionActs.act_key(n)] = r.notereturn RestitutionActs(acts[1], acts[2], acts[3], source_id="corpus_anonyme", degraded=deg)import asyncio, concurrent.futuresdef run_async(coro):"""Exécute une coroutine que l'on soit en script pur OU dans un kernel Jupyter (qui tourne déjà une boucle asyncio)."""try: asyncio.get_running_loop()exceptRuntimeError:return asyncio.run(coro)with concurrent.futures.ThreadPoolExecutor(1) as ex:return ex.submit(asyncio.run, coro).result()# Aucun LLM injecté -> fail-loud honnête : les 3 actes sont NOMMÉS indisponibles.acts_sans_llm = run_async(narrate_full_report(ETAT, llm_callable=None))print("Actes produits :", {n: (not acts_sans_llm.is_missing(n)) for n in (1, 2, 3)})print("\n--- rapport SANS LLM (fail-loud, chaque acte nommé indisponible) ---\n")print(render_report(acts_sans_llm).markdown)
Actes produits : {1: False, 2: False, 3: False}
--- rapport SANS LLM (fail-loud, chaque acte nommé indisponible) ---
# Rapport de restitution — corpus_anonyme
Récit en trois actes (mise en situation -> analyse narrative -> conclusion actionnable). Les cadres formels/informels (Tweety, Dung, taxonomie, vertus) sont les *preuves* citées en appui du récit, jamais une énumération (§4).
## Acte I — Mise en situation
_Acte I indisponible — le générateur de mise en situation n'a pas produit de cadre (non câblé ou en échec). Le rapport entre directement dans l'analyse._
## Acte II — Récit dialectique
_Acte II indisponible — le récit dialectique n'a pas pu être généré (cœur narratif absent). Le rapport n'a pas de substance narrative._
## Acte III — Conclusion actionnable
_Acte III indisponible — la conclusion actionnable n'a pas été générée (portes G1–G4 non évaluées). Le rapport s'arrête sans synthèse._
---
## Gate lisibilité — [FAIL] FAIL
Contrôles structurels (règle de tissage, §4) :
- Acte I absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
- Acte II absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
- Acte III absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
_Verdict honnête reporté par le gate — non truqué (#1019)._
C’est la sortie honnête quand aucun LLM n’est branché : le scaffold a produit toute l’evidence, et le renderer nomme les trois actes manquants au lieu de fabriquer. Branchons maintenant le vrai composant.
7. Le vrai composant : narration LLM réelle
On câble maintenant un vrai client LLM — le composant que le conducteur_illustratifsimulait dans les versions de démonstration. Deux exigences de sécurité, non négociables :
la clé n’est jamais en dur dans le notebook : elle est lue dans l’environnement (os.getenv), alimenté par le fichier MyIA.AI.Notebooks/GenAI/.env (gitignored) de ce dépôt ;
le callable reste gated : sans clé, make_openai_callable() renvoie None, et l’orchestrateur retombe sur le fail-loud du §6 (jamais de texte fabriqué).
Le client utilisé est le SDK openai (AsyncOpenAI), pointé par défaut sur le modèle configuré (OPENAI_CHAT_MODEL_ID). On charge d’abord la config GenAI partagée si elle n’est pas déjà dans l’env :
import osfrom pathlib import Pathdef load_genai_env(max_up: int=8) ->str:"""Charge MyIA.AI.Notebooks/GenAI/.env (gitignored) dans l'environnement, en remontant l'arborescence depuis le cwd. N'écrase pas une variable déjà définie. Ne retourne JAMAIS le contenu (pas de fuite de secret/chemin absolu)."""if os.getenv("OPENAI_API_KEY"):return"déjà présent dans l'environnement" here = Path.cwd()for base in (here, *list(here.parents)[:max_up]):for cand in (base /"GenAI"/".env", base /"MyIA.AI.Notebooks"/"GenAI"/".env"):if cand.exists():for line in cand.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip()ifnot line or line.startswith("#") or"="notin line:continue k, _, v = line.partition("=") os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))return"fichier .env GenAI chargé"return"introuvable (le rapport restera fail-loud)"_status = load_genai_env()_model = os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5-mini")# On n'imprime QUE des booléens / noms non sensibles — jamais la clé ni un chemin absolu.print("Config GenAI :", _status)print("Clé OpenAI présente:", "oui"if os.getenv("OPENAI_API_KEY") else"non")print("Modèle :", _model)
Config GenAI : déjà présent dans l'environnement
Clé OpenAI présente: oui
Modèle : gpt-5-mini
On construit le callable async réel. C’est un Callable[[str], Awaitable[str]] — exactement le contrat que narrate_full_report attend, donc rien d’autre ne change : on injecte le vrai LLM à la place du stub.
from openai import AsyncOpenAIdef make_openai_callable(model: Optional[str] =None) -> Optional[LlmCallable]:"""Renvoie un callable LLM RÉEL, ou None si aucune clé (-> fail-loud en aval). La clé est lue dans l'environnement, JAMAIS écrite dans le notebook.""" key = os.getenv("OPENAI_API_KEY")ifnot key:returnNone client = AsyncOpenAI(api_key=key) mdl = model or os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5-mini")asyncdef _call(prompt: str) ->str: resp =await client.chat.completions.create( model=mdl, messages=[{"role": "user", "content": prompt}], max_completion_tokens=3000, )return (resp.choices[0].message.content or"").strip()return _callllm = make_openai_callable()print("Callable LLM réel :", "prêt"if llm else"absent -> fail-loud (RECOVERABLE-USER-HAND)")
Callable LLM réel : prêt
On génère le rapport complet et réel : trois appels LLM (un par acte), assemblés par le renderer fail-loud, puis le gate de lisibilité s’exécute sur la sortie réelle du LLM. C’est le test décisif de la doctrine : un LLM correctement conduit doit produire une prose tissée qui passe le gate.
if llm isNone:print("Aucune clé LLM — rapport fail-loud (cf §6). Fournis GenAI/.env pour la narration réelle.") acts_reels = run_async(narrate_full_report(ETAT, llm_callable=None))else: acts_reels = run_async(narrate_full_report(ETAT, llm_callable=llm)) # 3 vrais appels LLMprint("Statuts actes :", {n: ("woven"ifnot acts_reels.is_missing(n) else"DÉGRADÉ") for n in (1, 2, 3)})rapport_reel = render_report(acts_reels)print("Verdict du gate sur la sortie RÉELLE du LLM :", rapport_reel.verdict.band)print("\n"+"="*78+"\n")print(rapport_reel.markdown)
Statuts actes : {1: 'DÉGRADÉ', 2: 'DÉGRADÉ', 3: 'DÉGRADÉ'}
Verdict du gate sur la sortie RÉELLE du LLM : FAIL
==============================================================================
# Rapport de restitution — corpus_anonyme
Récit en trois actes (mise en situation -> analyse narrative -> conclusion actionnable). Les cadres formels/informels (Tweety, Dung, taxonomie, vertus) sont les *preuves* citées en appui du récit, jamais une énumération (§4).
## Acte I — Mise en situation
_Acte I indisponible — le générateur de mise en situation n'a pas produit de cadre (non câblé ou en échec). Le rapport entre directement dans l'analyse._
## Acte II — Récit dialectique
_Acte II indisponible — le récit dialectique n'a pas pu être généré (cœur narratif absent). Le rapport n'a pas de substance narrative._
## Acte III — Conclusion actionnable
_Acte III indisponible — la conclusion actionnable n'a pas été générée (portes G1–G4 non évaluées). Le rapport s'arrête sans synthèse._
---
## Gate lisibilité — [FAIL] FAIL
Contrôles structurels (règle de tissage, §4) :
- Acte I absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
- Acte II absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
- Acte III absent — le rapport n'est pas complet (générateur non câblé ou acte vide).
_Verdict honnête reporté par le gate — non truqué (#1019)._
Le rapport ci-dessus est produit par le vrai LLM sur l’evidence déterministe — plus aucun stand-in. Le verdict du gate est calculé sur cette sortie réelle : s’il est PASS/WARN, la doctrine tient (prose tissée) ; s’il est FAIL, le rapport le dit au lieu de se prétendre lisible — l’honnêteté reste sous le contrôle du scaffold, jamais déléguée au LLM.
Verdict SOTA : SOTA-OK — le vrai composant (SDK openai) est installé et invoqué, la sortie committée est sa vraie sortie. Sur une machine sans clé, le même code retombe honnêtement sur le fail-loud du §6 (RECOVERABLE-USER-HAND : il suffit d’une clé dans GenAI/.env).
8. Exercices
Trois exercices pour étendre le scaffold et la narration. Chaque cellule est un stub à compléter — le notebook s’exécute de bout en bout même si les exercices ne sont pas faits (la solution de référence n’est pas fournie).
Exercice 1 — Ajouter l’axe logique modale à la bande de verdict
Le state peut contenir une clé modal_analysis_results (liste de dicts {"consistent": bool|None}, même forme que FOL). Étends la couverture pour qu’un résultat modal vérifié compte comme un 7ᵉ axe non-trivial, et ajoute "modal" à la liste AXES. Veille à la discipline None ≠ False (un résultat non vérifié ne compte pas).
def build_evidence_avec_modal(state):"""Exercice 1 : recompter les axes en incluant un axe 'modal' vérifié. Indice : repars de build_evidence(state), puis inspecte state.get("modal_analysis_results") en comptant les entrées dont r.get("consistent") in (True, False) # vérifié (None exclu). Recalcule compute_verdict_band(...) avec l'axe 'modal' ajouté si non-trivial. """# TODO étudiant : implémenter l'extension modale.print("Exercice 1 à completer : ajouter l'axe modal à la couverture.")returnNone
Exercice 2 — Durcir le gate : interdire les titres numérotés génériques
Le détecteur actuel attrape « Sophisme N: » / « Argument N: ». Étends _DUMP_HEADING_RE (ou écris un nouveau contrôle) pour aussi flaguer les titres génériques numérotés du type « ### Point 1 », « ### Section 2 », « ### Étape 3 » qui trahissent un acte redevenu une liste plate. Renvoie un GateVerdictWARN ou FAIL selon le nombre détecté.
def controle_titres_generiques(acte_markdown):r"""Exercice 2 : détecter les titres numérotés génériques (Point N / Section N / Étape N). Indice : une regex multiline ^#{1,6}\s*(?:Point|Section|Étape)\s+\d+ ; au-delà de 2 occurrences dans un acte -> GateVerdict('FAIL', [...]). """# TODO étudiant : implémenter le contrôle et renvoyer un GateVerdict.print("Exercice 2 à completer : flaguer les titres numérotés génériques.")returnNone
Exercice 3 — Restituer un nouveau corpus avec le vrai LLM, puis le passer au gate
Le §7 a câblé un vrai LLM. Écris ton proprestate (un corpus différent : au moins 2 arguments, 1 sophisme localisé, 1 verdict formel), fais-en générer le rapport complet par narrate_full_report(mon_state, llm_callable=llm), puis fais juger la sortie réelle par le gate (ReadabilityGate().check_acts(...)). Un vrai LLM bien conduit doit passer ; sinon, le gate doit te le dire (ne le contourne pas).
def restituer_mon_corpus():"""Exercice 3 : restituer un corpus inédit avec le vrai LLM puis vérifier au gate. Indice : mon_state = {"identified_arguments": {...}, "identified_fallacies": {...}, "propositional_analysis_results": [{"satisfiable": ...}], ...} actes = run_async(narrate_full_report(mon_state, llm_callable=llm)) # llm du §7 verdict = ReadabilityGate().check_acts(actes) print(render_report(actes, ReadabilityGate()).markdown) Si llm is None (pas de clé), laisse le fail-loud s'exprimer — ne fabrique rien. """# TODO étudiant : construire mon_state, générer le rapport, juger au gate.print("Exercice 3 à completer : restituer un corpus inédit avec le vrai LLM.")returnNone
Conclusion
Ce notebook a distillé le contrat de restitution honnête de l’arc anti-théâtre de 2025-Epita-Intelligence-Symbolique, avec le vrai composant LLM branché (pas de stand-in) :
un scaffold déterministe — extraction d’evidence (réel-en-état seulement), bande de verdict gated sur la couverture, gate de lisibilité (règle de tissage §4), renderer fail-loud — qui tourne entièrement sans LLM et détient le contrat d’honnêteté ;
une narration LLM réelle et gated — un client AsyncOpenAI (clé lue dans GenAI/.env, jamais en dur) narre les trois actes via des prompts conduits (varient par corpus, pas un template, #1108) ; sans clé, le code retombe sur le fail-loud (jamais un template de repli) ;
le gate de lisibilité s’exécute sur la sortie réelle du LLM (§7) — le test décisif de la doctrine.
La leçon transférable : la lisibilité d’un rapport peut être confiée à un LLM, mais son honnêteté doit rester sous le contrôle d’un scaffold déterministe. Le LLM enrichit la prose ; il ne décide jamais de ce qui peut être affirmé, ni ne masque ce qui manque. Verdict SOTA : SOTA-OK sur les deux fronts — scaffold (pur Python = le bon outil pour un contrat structurel) et narration (vrai SDK openai installé et invoqué, la sortie committée est sa vraie sortie ; RECOVERABLE-USER-HAND sur une machine sans clé).
Pour aller plus loin : voir Argument_Analysis_Multi_Backend_Routing (la sentinelle de contrat de livraison côté solveurs), Agentic-3-orchestration (le pipeline qui peuple le state lu ici) et Agentic-5-jtms (un autre moteur déterministe pur stdlib de cette série).