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 :

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 annotations
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
import re

ACT_TITLES: Dict[int, str] = {
    1: "Acte I — Mise en situation",
    2: "Acte II — Récit dialectique",
    3: "Acte III — Conclusion actionnable",
}

@dataclass
class 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}

    @staticmethod
    def 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."""
        return not (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"]

@dataclass
class VerdictBand:
    band: str
    nontrivial_axes: List[str] = field(default_factory=list)
    missing_axes: List[str] = field(default_factory=list)
    axes_count: int = 0

def compute_verdict_band(axes_nontrivial: List[str]) -> VerdictBand:
    present = set(axes_nontrivial)
    missing = [a for a in AXES if a not in present]
    n = len(axes_nontrivial)
    has_formal = "formal_pl" in present or "formal_fol" in present
    has_quality = "quality" in present
    if n >= 5 and 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 couverture
for 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)})")
  6/6 axes -> EXCEEDED   (touchés: fallacies, quality, counter_arguments, formal_pl, formal_fol, dung)
  5/6 axes -> MATCH      (touchés: fallacies, counter_arguments, formal_pl, formal_fol, dung)
  3/6 axes -> PARTIAL    (touchés: fallacies, quality, dung)
  1/6 axes -> BELOW      (touchés: fallacies)

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.
@dataclass
class WeakPoint:
    source: str            # "fallacy" | "pl" | "fol" | "dung"
    label: str
    target_arg_id: str
    detail: str = ""

@dataclass
class 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, 200

def _truncate(text: Any, cap: int) -> str:
    if not text:
        return ""
    s = str(text).strip()
    return s if len(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 is None:
        sat = r.get("consistent")
    return sat if isinstance(sat, bool) else None

def _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():
        if not isinstance(fw, dict):
            continue
        accepted = set(fw.get("extension", []) or [])
        sem = str(fw.get("semantics", "grounded"))
        for a in (fw.get("arguments", []) or []):
            if a not in accepted:
                rejected.setdefault(a, sem)
    return rejected

def 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(1 for d in fallacies.values()
                          if isinstance(d, dict) and d.get("target_argument_id"))
    counters_total = sum(1 for c in counters
                         if isinstance(c, dict) and (c.get("target_arg_id") or c.get("counter_content")))
    pl_inc = sum(1 for r in pl if isinstance(r, dict) and _pl_verdict(r) is False)
    fol_inc = sum(1 for r in fol if isinstance(r, dict) and r.get("consistent") is False)
    pl_verified = sum(1 for r in pl if isinstance(r, dict) and _pl_verdict(r) is not None)
    fol_verified = sum(1 for r in fol if isinstance(r, dict) and r.get("consistent") in (True, False))
    dung_rejected = _dung_rejected(state)

    axes: List[str] = []
    if fallacies_total:            axes.append("fallacies")
    if bool(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():
        if isinstance(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") if isinstance(qs, dict) and isinstance(qs.get("scores"), dict) else {}
        for vname, vval in spv.items():
            if isinstance(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 if isinstance(c, dict) and (c.get("target_arg_id") or c.get("counter_content"))
    ]
    claim_excerpts = [_truncate(v, _CLAIM_CAP) for v in list(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 faible
QUALITY_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 = 3

KIND_VULNERABILITY = "vulnerabilite"
KIND_TENSION = "tension"
KIND_STRENGTH = "force"


@dataclass
class RoleAssignment:
    """Un rôle probatoire dérivé localement, sans LLM."""
    role: str            # 'decisif' | 'contradictoire' | 'corroborant' | 'non_discriminant'
    statement: str
    cites: tuple = ()


@dataclass
class 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 = ()


@dataclass
class 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 corroboration


def _truncate(text, cap):
    s = str(text).strip()
    return s if len(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 = 0
    for r in results or ():
        v = key_fn(r or {})
        if v is True:
            true_n += 1
        elif v is False:
            false_n += 1
    return {"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 is None:
        sat = r.get("consistent")
    return sat if isinstance(sat, bool) else None


def _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():
        if not isinstance(fw, dict):
            continue
        accepted = set(fw.get("extension", []) or [])
        sem = str(fw.get("semantics", "grounded"))
        for a in (fw.get("arguments", []) or []):
            if a not in accepted:
                rejected.setdefault(a, sem)
    return rejected


def _fallacy_targets(fallacies):
    """fallacy_id -> target_arg_id (pour lookup rapide dans classify_specialist_roles)."""
    out = {}
    for fid, f in (fallacies or {}).items():
        if isinstance(f, dict) and f.get("target_argument_id"):
            out[fid] = f["target_argument_id"]
    return out


def _quality_fraction(qs):
    """Fraction de qualité (moyenne des scores, normalisée 0..1) ou None.

    qs : dict {"vertu": score, ...} avec scores dans [0..1].
    """
    if not isinstance(qs, dict):
        return None
    scores = [v for v in qs.get("scores", {}).values() if isinstance(v, (int, float))]
    if not scores:
        return None
    s = sum(scores) / len(scores)
    return max(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.
    """
    if not isinstance(quality, dict) or not quality:
        return False
    fractions = [_quality_fraction(qs) for qs in quality.values()]
    fractions = [f for f in fractions if f is not None]
    if not fractions:
        return any(f >= QUALITY_WEAK_FRACTION for f in fractions)
    return any(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") if isinstance(r.get("consistent"), bool) else None)
    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 in sorted(_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 {}
    if isinstance(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 in sorted(args):
            if arg_id not in fallacy_by_arg.values():
                # skip si aucun sophisme ne cible cet argument
                continue
            qs = quality.get(arg_id) if isinstance(quality, dict) else None
            fraction = _quality_fraction(qs)
            if fraction is None:
                continue
            if 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"] == 0 and 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"] == 0 and 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 = []
    if isinstance(quality_entries, dict):
        measured = [f for f in (_quality_fraction(qs) for qs in quality_entries.values()) if f is not None]
    if measured and not any(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 out


def 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 ranked


def 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 dict plain (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]}")
assert any(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]}")
assert any(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]}")
assert any(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]}")
assert any(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]}")
assert any(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 notebook
salience = assess_conclusion_salience(ETAT)
surplus = assess_surplus(ETAT)

print("=" * 60)
print("HIÉRARCHIE ACTE III — portance probatoire")
print("=" * 60)
if not salience:
    print("(rien à classer — état trop pauvre en evidence discriminante)")
else:
    for i, s in enumerate(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 force
for 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)
    if not 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 in enumerate(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)

@dataclass
class GateVerdict:
    band: str  # PASS | WARN | FAIL
    reasons: List[str] = field(default_factory=list)
    @property
    def passed(self) -> bool:
        return self.band in ("PASS", "WARN")
    def merge(self, other: "GateVerdict") -> "GateVerdict":
        order = {"PASS": 0, "WARN": 1, "FAIL": 2}
        worst = self if order[self.band] >= order[other.band] else other
        return 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 b

def _line_has_framework(line: str) -> bool:
    low = line.lower()
    return any(k in low for k in _FRAMEWORKS) or any(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."""
    if not _line_has_framework(line):
        return False
    if not _STANDALONE_SCORE_RE.search(line):
        return False
    return not any(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_fail
    def check_acts(self, acts: RestitutionActs) -> GateVerdict:
        reasons, worst, total_bare = [], "PASS", 0
        for 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)
Actes TISSÉS : PASS
Actes NUS    : FAIL
   - Acte II: 3 référence(s) de cadre « nue(s) » (framework + score isolé, sans ancrage narratif — §4). Ex: « ad verecundiam (0.8) ».

5. Le renderer fail-loud

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."),
}

@dataclass
class RenderedReport:
    markdown: str
    verdict: GateVerdict

def _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 manquant
            continue
        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]}", ""]
        if len(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és
rendu = 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.

6. La frontière LLM — prompts conduits (3 actes), callable injectable, fail-loud

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) :

  1. 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 ;
  2. le LLM est un callable async injectable Callable[[str], Awaitable[str]] — on y branchera un vrai client au §7 ;
  3. 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.verdict
    if v is not None:
        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 :

@dataclass
class ActResult:
    narrative: str
    status: str            # woven | unavailable
    note: str = ""

async def narrate_act(n: int, ev: Evidence, llm_callable: LlmCallable) -> ActResult:
    try:
        raw = await llm_callable(_ACT_PROMPT[n](ev))
    except Exception as exc:   # noqa: BLE001 — surface, don't fabricate
        return ActResult("", "unavailable", f"{ACT_TITLES[n]} : le LLM a échoué — {exc} (fail-loud).")
    if not raw:
        return ActResult("", "unavailable", f"{ACT_TITLES[n]} : le LLM n'a rien produit (fail-loud, #1108).")
    return ActResult(str(raw).strip(), "woven")

async def 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")
    if not 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 is None:
        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.narrative
        if r.status != "woven":
            deg[RestitutionActs.act_key(n)] = r.note
    return RestitutionActs(acts[1], acts[2], acts[3], source_id="corpus_anonyme", degraded=deg)

import asyncio, concurrent.futures

def 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()
    except RuntimeError:
        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_illustratif simulait 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 os
from pathlib import Path

def 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()
                    if not line or line.startswith("#") or "=" not in 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 AsyncOpenAI

def 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")
    if not key:
        return None
    client = AsyncOpenAI(api_key=key)
    mdl = model or os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5-mini")
    async def _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 _call

llm = 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 is None:
    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 LLM
    print("Statuts actes :", {n: ("woven" if not 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.")
    return None

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 GateVerdict WARN 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.")
    return None

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 propre state (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.")
    return None

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).

Retour au sommet