# Applied AI — Niveau Avancé — Session 4
# Exercices : Architecture multi-agents

**Instructeur :** Yann Isola
**Durée totale :** 90 minutes
**Modalité :** individuel ou binôme — les corrigés sont destinés à l'instructeur

---

## Exercice 1 — Choix de stratégie de décomposition (25 min)

### Contexte

Vous êtes architecte solutions chez un courtier en instruments financiers tokenisés. Trois projets arrivent simultanément. Pour **chacun**, vous devez choisir une stratégie de décomposition (verticale, horizontale, récursive, hybride — ou *aucune décomposition*) et la justifier avec les trois critères de décision (seuil de complexité, budget de tokens, bénéfice de spécialisation).

### Cas A — Générateur de fiches produit

Une tâche répétitive : à partir d'une fiche technique de 2 pages, générer une description commerciale de 300 mots dans un ton défini. Volume : 500 fiches/jour. La fiche technique + le prompt tiennent largement dans le contexte.

### Cas B — Due diligence d'une contrepartie

Pour chaque nouvelle contrepartie : analyse juridique (structure sociale, licences), analyse financière (bilans, ratios), analyse réputationnelle (presse, sanctions), analyse technique (infrastructure de conservation des actifs). Puis production d'un mémo de synthèse avec recommandation GO/NO-GO. Chaque volet mobilise des documents volumineux et un vocabulaire spécialisé distinct.

### Cas C — Migration documentaire d'un fonds

Un fonds transmet une arborescence de ~4 000 documents (profondeur et structure imprévisibles : dossiers imbriqués, formats hétérogènes, langues mixtes). Chaque document doit être classifié, résumé, indexé et rattaché à un référentiel réglementaire. La structure de l'arborescence n'est connue qu'à l'exécution.

### Livrables attendus (par cas)

1. Stratégie choisie (une phrase).
2. Justification par les 3 critères (tableau).
3. Schéma d'architecture (ASCII ou dessin) : agents, flux, points de convergence.
4. Pour le cas C uniquement : les **trois garde-fous** obligatoires de votre architecture et leurs valeurs proposées.

---

### Corrigé instructeur — Exercice 1

**Cas A — Aucune décomposition.**
- Complexité : une seule étape homogène, aucun embranchement — très en dessous du seuil.
- Budget de tokens : la fiche + le prompt tiennent confortablement dans le contexte.
- Spécialisation : un seul domaine ; un prompt unique bien conçu suffit.
- **Piège du cas :** le volume (500/jour) pousse certains participants vers le multi-agents. Le volume est un problème de *parallélisation d'instances* (500 exécutions du même agent), pas de *décomposition de tâche*. Point à corriger explicitement si l'erreur apparaît.
- Bonus acceptable : un vérificateur léger en aval (pipeline vertical à 2 étages) si le participant le justifie par un enjeu qualité — mais il doit reconnaître le surcoût de 2× sur 500 exécutions/jour.

**Cas B — Hybride : horizontale pour l'analyse, verticale pour l'ensemble.**
- Architecture attendue : orchestrateur → 4 agents de domaine **en parallèle** (juridique, financier, réputationnel, technique) → fan-in vers un agent rédacteur → agent vérificateur (ou revue humaine, GO/NO-GO étant une décision à risque élevé → escalade par nature, lien avec le chapitre 9).
- Complexité : > 5 étapes de natures et domaines différents → au-dessus du seuil.
- Tokens : chaque volet mobilise des documents volumineux → un agent unique saturerait sa fenêtre.
- Spécialisation : maximale — vocabulaires et outils distincts par domaine.
- **Excellent réflexe à valoriser :** le participant qui prévoit la réconciliation des contradictions inter-domaines (le juridique dit GO, le réputationnel dit NO-GO) et l'isolation des pannes (livrer 3 volets sur 4 avec mention explicite).

**Cas C — Récursive.**
- Structure imprévisible et fractale (dossiers imbriqués de profondeur inconnue) : c'est le cas d'école de la récursion — chaque agent de dossier se décompose selon ce qu'il découvre.
- Les trois garde-fous obligatoires :
  1. `max_depth` : 2–3 niveaux (valeur défendable si justifiée) ;
  2. budget de tokens hérité : fraction du budget parent par enfant (ex. : 30 %) ;
  3. contrat de résultat structuré (JSON : statut, artefacts, coût) pour chaque sous-agent.
- Garde-fou bonus recevable : plafond global de sous-agents simultanés (protection du rate limit), checkpointing par dossier traité (lien chapitre 10).

**Barème indicatif (sur 20) :** Cas A : 5 pts (dont 2 pour avoir résisté à la sur-ingénierie) · Cas B : 8 pts (dont 3 pour l'hybride correctement articulé) · Cas C : 7 pts (dont 3 pour les garde-fous chiffrés).

---

## Exercice 2 — Conception d'une politique d'escalade (30 min)

### Contexte

Un assistant multi-agents de service client opère pour une plateforme de conservation d'actifs numériques. Il peut : répondre aux questions (produits, frais, procédures), modifier des informations de profil non sensibles, initier des procédures de récupération de compte, et préparer (sans exécuter) des instructions de retrait.

Trois incidents récents motivent une refonte de la politique d'escalade :

- **Incident 1 :** l'agent a guidé un client à travers une procédure de récupération de compte alors que des signaux d'ingénierie sociale étaient présents (urgence artificielle, incohérences dans les informations fournies). Personne n'a été alerté.
- **Incident 2 :** une question fiscale pointue (résidence fiscale multiple) a reçu une réponse inventée avec assurance. Le client a pris une décision sur cette base.
- **Incident 3 :** les demandes escaladées à l'équipe conformité s'accumulent sans traitement — certaines ont attendu 6 jours ; deux clients sont partis.

### Travail demandé

1. **Matrice de déclencheurs (8 pts)** — Pour chacune des 4 capacités de l'agent, définissez les déclencheurs d'escalade selon les trois familles (confiance sous seuil / hors périmètre / niveau de risque). Présentez sous forme de tableau. Chaque incident doit être couvert par au moins un déclencheur.
2. **Choix des patrons (6 pts)** — Pour chaque déclencheur, associez le patron approprié (pause-and-ask / queue-for-review / fallback-to-human) et justifiez en une phrase.
3. **Politique de SLA (6 pts)** — Concevez la réponse à l'incident 3 : niveaux de priorité, délais cibles, action de timeout par niveau (fail-safe / fail-operational / ré-escalade), et **deux métriques** de pilotage.

### Contrainte

Votre politique doit rester exploitable : si tout escalade en P1 vers un humain, vous avez échoué. Indiquez explicitement ce qui reste **autonome**.

---

### Corrigé instructeur — Exercice 2

**1. Matrice de déclencheurs — éléments attendus :**

| Capacité | Confiance | Hors périmètre | Risque |
|---|---|---|---|
| Questions produits/frais | Juge distinct, seuil calibré (ex. 0,75) → escalade si en dessous | Conseil fiscal/juridique/investissement détecté → escalade (couvre **incident 2**) | Faible → autonome par défaut |
| Modification profil non sensible | — | Demande glissant vers données sensibles (IBAN, 2FA) → escalade | Faible/réversible → autonome + journalisation |
| Récupération de compte | Incohérences dans les vérifications → escalade | — | **Élevé par nature** : détecteur de signaux de fraude (urgence, pression, incohérences) → escalade obligatoire (couvre **incident 1**) |
| Préparation de retrait | — | — | Élevé/irréversible en aval → approbation humaine préalable systématique au-delà d'un seuil de montant |

Points clés à vérifier : (a) l'incident 2 est traité par *hors périmètre* (conseil fiscal réglementé), pas seulement par la confiance — une réponse inventée avec assurance a précisément une confiance auto-déclarée élevée, d'où la nécessité du classifieur de périmètre et/ou d'un juge distinct ; (b) l'incident 1 exige un déclencheur de *risque* indépendant de la confiance.

**2. Patrons attendus :**
- Question fiscale hors périmètre → **fallback-to-human** avec paquet de contexte complet (le client attend une vraie réponse, pas une validation différée).
- Signaux de fraude sur récupération de compte → **pause-and-ask** vers l'équipe fraude (l'agent suspend la procédure — ne surtout pas la terminer puis la faire relire) ; recevable aussi : fallback immédiat. À sanctionner : queue-for-review (la procédure serait déjà accomplie au moment de la revue).
- Préparation de retrait au-delà du seuil → **queue-for-review** (l'agent prépare, l'humain approuve avant exécution) — c'est le cas d'usage canonique de ce patron.
- Confiance basse sur question produit → queue-for-review (réponse à faible enjeu) ou pause-and-ask selon justification.

**3. SLA — réponse type :**
- P1 (< 15–30 min) : suspicion de fraude, compte compromis. Timeout → **ré-escalade** vers astreinte + **fail-safe** : gel de la procédure en attendant.
- P2 (< 4 h ouvrées) : questions réglementées, retraits en attente d'approbation. Timeout → ré-escalade n+1 ; le retrait reste bloqué (fail-safe).
- P3 (< 48 h) : revues d'échantillonnage, corrections de contenu. Timeout → fail-operational (l'option conservatrice pré-approuvée s'applique).
- Métriques : temps de première réponse humaine par priorité, taux de dépassement de SLA, taux d'escalade par motif, taux d'approbation sans modification (signal de sur-escalade). Deux suffisent, quatre = excellent.
- **Autonomie préservée :** questions produits à confiance haute et modifications non sensibles restent autonomes — indispensable pour valider la contrainte d'exploitabilité.

---

## Exercice 3 — Implémentation de la gestion d'erreurs (35 min)

### Contexte

Vous recevez le squelette d'un orchestrateur qui appelle trois sous-agents en parallèle puis fusionne leurs résultats. En production, l'API de l'agent `enrichisseur` connaît des pannes intermittentes qui font actuellement échouer des missions entières.

```python
# --- CODE EXISTANT (défaillant) ---
def run_mission(task):
    r1 = agent_analyste.run(task)        # fiable
    r2 = agent_enrichisseur.run(task)    # pannes intermittentes !
    r3 = agent_redacteur_notes.run(task) # fiable
    return merge(r1, r2, r3)             # KeyError si r2 manque
```

### Travail demandé

Réécrivez `run_mission` et son infrastructure pour intégrer, dans cet ordre :

1. **(6 pts) Typologie + retry avec backoff** — Classez les erreurs (réessayables / fatales). Implémentez un retry (max 3 tentatives) avec backoff exponentiel et jitter, appliqué **uniquement** aux erreurs réessayables. L'appel à `agent_enrichisseur` est-il rejouable sans précaution ? Justifiez en une phrase dans un commentaire (indice : idempotence).
2. **(6 pts) Circuit breaker** — Implémentez un disjoncteur (seuil : 5 échecs, refroidissement : 30 s) autour de `agent_enrichisseur` uniquement. Les trois états doivent être explicites dans le code.
3. **(4 pts) Dégradation gracieuse** — Si l'enrichisseur est indisponible (circuit ouvert ou retries épuisés), la mission doit **aboutir quand même** avec un résultat partiel, porteur d'une mention explicite du volet manquant. `merge` ne doit plus jamais lever `KeyError`.
4. **(4 pts) DLQ + corrélation** — Toute sous-tâche définitivement échouée part en file de lettres mortes avec : correlation ID, nombre de tentatives, dernière erreur, référence de checkpoint. Le correlation ID traverse tous les logs de la mission.

### Contraintes

- Python standard uniquement (pas de bibliothèque externe) ; `time`, `random`, `uuid`, `json` autorisés.
- Le code doit être exécutable tel quel avec des agents simulés (fournissez un stub `FlakyAgent` qui échoue aléatoirement à ~40 %).
- Testez : lancez 20 missions et affichez le tableau final (succès complets / succès dégradés / entrées DLQ / ouvertures de circuit).

---

### Corrigé instructeur — Exercice 3 (solution de référence)

```python
import time, random, uuid, json

# ---------- 1. Typologie d'erreurs ----------
class AgentError(Exception): retryable = False
class TransientError(AgentError): retryable = True    # timeout, rate limit
class FatalError(AgentError): retryable = False       # entrée invalide, policy
class CircuitOpenError(AgentError): retryable = False

def backoff(attempt):
    return min(8, 2 ** attempt) * random.uniform(0.5, 1.5)

def with_retry(fn, cid, name, max_attempts=3):
    # L'agent enrichisseur est un appel de LECTURE (pas d'effet de bord) :
    # il est donc idempotent par nature → retry sûr. S'il créait des
    # ressources, il faudrait une clé d'idempotence avant tout retry.
    last = None
    for attempt in range(max_attempts):
        try:
            return fn()
        except TransientError as e:
            last = e
            log(cid, f"{name} tentative {attempt+1} échouée : {e}")
            time.sleep(backoff(attempt) * 0.01)  # ×0.01 pour le test en classe
    raise last

# ---------- 2. Circuit breaker ----------
class CircuitBreaker:
    def __init__(self, threshold=5, cooldown=30 * 0.01):  # cooldown réduit pour la démo
        self.threshold, self.cooldown = threshold, cooldown
        self.failures, self.state, self.opened_at = 0, "closed", None
        self.opens = 0

    def call(self, fn):
        if self.state == "open":
            if time.time() - self.opened_at >= self.cooldown:
                self.state = "half_open"
            else:
                raise CircuitOpenError("circuit ouvert")
        try:
            result = fn()
        except TransientError:
            self.failures += 1
            if self.state == "half_open" or self.failures >= self.threshold:
                self.state, self.opened_at = "open", time.time()
                self.opens += 1
            raise
        self.failures, self.state = 0, "closed"
        return result

# ---------- Stubs ----------
class FlakyAgent:
    def __init__(self, name, fail_rate=0.0):
        self.name, self.fail_rate = name, fail_rate
    def run(self, task):
        if random.random() < self.fail_rate:
            raise TransientError(f"{self.name} : timeout simulé")
        return {"agent": self.name, "content": f"résultat({task})"}

def log(cid, msg): print(f"[{cid}] {msg}")

# ---------- 3 & 4. Orchestrateur avec dégradation + DLQ ----------
DLQ = []
breaker = CircuitBreaker()

def run_mission(task):
    cid = f"corr-{uuid.uuid4().hex[:8]}"
    results, missing = {}, []

    results["analyste"] = agent_analyste.run(task)          # fiable (simplifié)
    results["notes"] = agent_redacteur_notes.run(task)

    try:
        results["enrichisseur"] = with_retry(
            lambda: breaker.call(lambda: agent_enrichisseur.run(task)),
            cid, "enrichisseur")
    except AgentError as e:
        missing.append("enrichisseur")
        DLQ.append({
            "task_id": task, "correlation_id": cid,
            "attempts": 3 if not isinstance(e, CircuitOpenError) else 0,
            "last_error": type(e).__name__,
            "checkpoint_ref": f"ckpt://{task}/partial",
        })
        log(cid, f"enrichisseur abandonné → DLQ ({type(e).__name__})")

    return merge(results, missing, cid)

def merge(results, missing, cid):
    out = {"correlation_id": cid, "sections": results, "complete": not missing}
    if missing:
        out["avertissement"] = (f"Volets indisponibles : {', '.join(missing)} "
                                "(service en panne) — résultat partiel.")
    return out

# ---------- Test : 20 missions ----------
agent_analyste = FlakyAgent("analyste")
agent_enrichisseur = FlakyAgent("enrichisseur", fail_rate=0.4)
agent_redacteur_notes = FlakyAgent("notes")

full = degraded = 0
for i in range(20):
    m = run_mission(f"task-{i}")
    full += m["complete"]; degraded += not m["complete"]

print(f"\nComplets: {full} | Dégradés: {degraded} | DLQ: {len(DLQ)} | "
      f"Ouvertures circuit: {breaker.opens}")
```

**Points de notation à vérifier :**
- Le retry n'attrape **que** `TransientError` (attraper `Exception` = -2 pts : on rejouerait des erreurs fatales).
- Le commentaire d'idempotence est présent et correct (lecture sans effet de bord → rejouable ; sinon clé d'idempotence).
- Le disjoncteur entoure *uniquement* l'enrichisseur ; les trois états apparaissent ; l'état half-open retombe en open sur échec.
- L'ordre d'emboîtement retry(breaker(appel)) est le bon débat : breaker *à l'intérieur* du retry signifie que les tentatives rejetées par le circuit ouvert comptent comme des échecs immédiats — acceptable si l'étudiant le justifie ; l'inverse (retry à l'intérieur du breaker) compte 3 tentatives comme 1 échec de circuit — acceptable aussi. **L'important est que le choix soit conscient et commenté.**
- `merge` produit un résultat honnête : drapeau `complete`, avertissement explicite — jamais de section silencieusement absente.
- L'entrée DLQ contient les 4 champs exigés ; le `cid` apparaît dans chaque ligne de log.

**Prolongement pour les rapides :** ajouter un checkpoint réel (dict sérialisé en JSON) et une fonction `replay_dlq()` qui rejoue les entrées après « réparation » (fail_rate mis à 0) — démontre le cycle complet DLQ → correction → rejeu.

---

*Fin des exercices — Session 4, niveau avancé. Les corrigés ne doivent pas être distribués avant la séance de restitution.*
