# Exercices — Niveau Avancé, Session 2
# « Tool use avancé »

**Programme :** Applied AI — Yann Isola
**Niveau :** Avancé (préparation Claude Certified Architect)
**Durée totale estimée :** 2 h 30 (en TD ou à la maison)
**Prérequis techniques :** Python ≥ 3.10, `pip install anthropic pydantic`, clé API dans `ANTHROPIC_API_KEY`

⚠ Les noms de modèles utilisés dans les corrigés (`claude-sonnet-4-5`) sont volatils — vérifier la documentation officielle au moment de l'exercice.

---

## Exercice 1 — Défi de conception de schéma d'outil (45 min)

### Contexte

Vous êtes architecte pour une plateforme de gestion de flotte automobile. L'assistant IA (Intelligence Artificielle) doit permettre aux gestionnaires de :

- rechercher des véhicules (par immatriculation, statut, agence),
- planifier une intervention de maintenance,
- consulter l'historique d'entretien d'un véhicule.

Un stagiaire a produit cette première version — elle est **volontairement mauvaise** :

```json
{
  "name": "FleetManager",
  "description": "Gère la flotte",
  "input_schema": {
    "type": "object",
    "properties": {
      "action": {"type": "string"},
      "data": {"type": "object"}
    },
    "required": ["action", "data"]
  }
}
```

### Travail demandé

**1.1 — Critique (10 min).** Listez au moins **6 défauts** de cette définition, en les rattachant chacun à un principe de conception vu en cours (responsabilité unique, nommage, description-prompt, documentation des champs, cas limites, champs requis minimaux).

**1.2 — Refonte (25 min).** Remplacez cet outil unique par **3 outils** bien conçus. Pour chacun, produisez la définition JSON complète avec :

- nom en `snake_case`, verbe + objet ;
- description de 3-4 phrases : quoi / quand l'utiliser / quand ne pas l'utiliser / ce qui est retourné (y compris le cas « aucun résultat ») ;
- `input_schema` avec une `description` pour **chaque** champ, des `enum` là où c'est pertinent, et un usage justifié de `required`.

Contraintes métier à intégrer :
- statuts véhicule possibles : `disponible`, `en_mission`, `en_maintenance`, `hors_service` ;
- types d'intervention : `revision`, `pneus`, `freins`, `carrosserie`, `controle_technique` ;
- une intervention se planifie sur un créneau (date ISO 8601 — norme internationale de format de date — + agence) ;
- l'immatriculation suit le format français `AA-123-BB`.

**1.3 — Test croisé (10 min).** Échangez vos schémas avec un binôme. Chacun rédige 3 requêtes utilisateur en langage naturel (dont une ambiguë) et prédit quel outil sera déclenché et avec quels paramètres. Testez ensuite contre l'API réelle avec `tool_choice: {"type": "auto"}` et comparez. Passez enfin vos schémas dans le validateur de `webpage/index.html`.

### Critères de réussite

- [ ] 3 outils à responsabilité unique, aucun paramètre « fourre-tout ».
- [ ] Chaque champ de chaque schéma a une `description`.
- [ ] Les statuts et types d'intervention sont des `enum`, pas des chaînes libres.
- [ ] Le format d'immatriculation est documenté (description + éventuellement `pattern`).
- [ ] Sur la requête ambiguë, le comportement observé est explicable par vos descriptions.

### Éléments de corrigé (1.1)

1. **Responsabilité multiple** : un outil qui fait tout via `action` → le modèle doit deviner les valeurs valides d'`action`, jamais listées.
2. **Nommage** : `FleetManager` n'est pas en `snake_case` et ne décrit pas une action.
3. **Description inutile** : « Gère la flotte » n'indique ni quand déclencher, ni quoi attendre en retour.
4. **`data` opaque** : `"type": "object"` sans `properties` = zéro contrainte, le modèle invente la structure.
5. **Aucun cas limite documenté** (véhicule introuvable, créneau indisponible…).
6. **`required` mal utilisé** : tout est requis mais rien n'est défini — le pire des deux mondes.

---

## Exercice 2 — Déboguer un flux d'outil cassé (45 min)

### Contexte

Le script ci-dessous est censé répondre à des questions sur des commandes e-commerce. Il contient **5 bugs** (certains provoquent des erreurs API, d'autres des comportements silencieusement faux). Il vous est fourni dans `exo2_broken.py` :

```python
import anthropic, json

client = anthropic.Anthropic()

tools = [{
    "name": "get_order",
    "description": "Récupère une commande par son identifiant. Retourne statut, montant et date. Utiliser dès qu'une question porte sur une commande précise.",
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string",
                         "description": "Identifiant de commande, format ORD-XXXXX."}
        },
        "required": ["order_id"],
    },
}]

DB = {"ORD-10042": {"statut": "expédiée", "montant": 129.90,
                    "date": "2026-06-28"}}

def get_order(order_id):
    return json.dumps(DB[order_id])          # BUG ?

messages = [{"role": "user",
             "content": "Où en est ma commande ORD-10042 ? Et la ORD-99999 ?"}]

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=50,                            # BUG ?
    tools=tools,
    messages=messages,
)

tool_block = response.content[0]              # BUG ?

result = get_order(tool_block.input["order_id"])

messages.append({"role": "user", "content": [{   # BUG ?
    "type": "tool_result",
    "tool_use_id": tool_block.id,
    "content": result,
}]})

final = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    tools=tools,
    messages=messages,
)
print(final.content[0].text)
```

### Travail demandé

**2.1 — Audit statique (15 min).** Sans exécuter, identifiez les 5 bugs. Pour chacun : la ligne, le symptôme attendu (erreur 400 ? exception Python ? JSON tronqué ? réponse incomplète ?), et la classification **syntaxique / sémantique / protocolaire**.

**2.2 — Correction (20 min).** Réécrivez le script avec :
- une boucle `while` gérant un nombre arbitraire de tours d'outils ;
- l'itération sur tous les blocs `tool_use` ;
- la gestion de la commande inexistante via `is_error: true` (message actionnable pour le modèle) ;
- une vérification de `stop_reason` avant tout parsing.

**2.3 — Validation (10 min).** Exécutez et vérifiez que le modèle : (a) donne le statut de ORD-10042, (b) explique proprement que ORD-99999 est introuvable — sans crash. Rejouez le scénario dans le débogueur de `webpage/index.html` en injectant l'erreur « commande introuvable ».

### Corrigé — les 5 bugs

| # | Ligne | Bug | Classification | Symptôme |
|---|---|---|---|---|
| 1 | `max_tokens=50` | Trop bas : le JSON du bloc `tool_use` risque d'être tronqué (`stop_reason: "max_tokens"`) | Syntaxique | `input` incomplet ou absent, parsing qui échoue |
| 2 | `response.content[0]` | Suppose que le premier bloc est le `tool_use` ; il peut y avoir du texte avant, et il y a ICI **deux** appels attendus (deux commandes demandées) | Protocolaire | `AttributeError` (bloc texte sans `.input`) ou second appel ignoré |
| 3 | `messages.append(...)` sans avoir ajouté la réponse assistant | L'historique envoyé contient un `tool_result` sans le `tool_use` correspondant | Protocolaire | Erreur 400 de l'API (`tool_use_id` inconnu) |
| 4 | `DB[order_id]` | `KeyError` sur ORD-99999 : l'erreur d'exécution n'est pas convertie en `tool_result` avec `is_error: true` | Exécution / protocolaire | Crash Python, boucle rompue |
| 5 | Absence de boucle + pas de test de `stop_reason` | Si le modèle répond en texte (pas d'outil) ou enchaîne un 2ᵉ tour d'outil, le script est faux | Protocolaire / sémantique | Comportement erratique selon la réponse |

Squelette de correction attendu :

```python
messages = [...]
while True:
    response = client.messages.create(model="claude-sonnet-4-5",
                                      max_tokens=1024, tools=tools,
                                      messages=messages)
    if response.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        try:
            out = get_order(**block.input)
            results.append({"type": "tool_result",
                            "tool_use_id": block.id, "content": out})
        except KeyError:
            results.append({"type": "tool_result",
                            "tool_use_id": block.id,
                            "content": f"Commande {block.input.get('order_id')} "
                                       "introuvable. Vérifier l'identifiant "
                                       "(format ORD-XXXXX).",
                            "is_error": True})
    messages.append({"role": "user", "content": results})

print(next(b.text for b in response.content if b.type == "text"))
```

---

## Exercice 3 — Pipeline multi-outils (60 min)

### Contexte

Construire un mini-agent « analyste de dépenses » qui enchaîne trois outils pour répondre à :

> « Quel est le total des dépenses de l'équipe Data en juin 2026, converti en USD, et est-il au-dessus du budget ? »

USD = dollar des États-Unis (United States Dollar).

### Les trois outils à implémenter

1. `get_team_expenses(team, month)` → liste de dépenses `[{label, amount_eur}]` (données simulées en dur).
2. `convert_currency(amount, from_currency, to_currency)` → montant converti (taux simulé, ex. 1 EUR = 1,08 USD ⚠ taux fictif).
3. `get_team_budget(team, currency)` → budget mensuel de l'équipe dans la devise demandée.

### Travail demandé

**3.1 — Conception (15 min).** Rédigez les 3 définitions d'outils. Point clé de chaînage : les sorties de l'outil 1 doivent fournir **exactement** ce que l'outil 2 attend (montants + devise explicite). Documentez les devises supportées en `enum` (`EUR`, `USD`).

**3.2 — Implémentation (30 min).** Écrivez la boucle agentique complète :

- boucle `while` avec garde-fou `max_iterations = 10` (question de certification classique : que faire si le modèle boucle ? → limite dure + sortie propre) ;
- gestion multi-blocs (le modèle peut appeler `convert_currency` et `get_team_budget` dans le même tour) ;
- **cache de résultats** : un dictionnaire `{(tool_name, params_frozen): result}` qui court-circuite les appels identiques répétés — loguez les hits de cache ;
- journalisation de chaque tour : `stop_reason`, outils appelés, paramètres.

**3.3 — Test de robustesse (15 min).** Trois scénarios :

1. **Nominal** : la question ci-dessus. Vérifiez le chaînage complet (dépenses → conversion → budget → verdict).
2. **Erreur sémantique provoquée** : demandez « en francs suisses » (CHF, non supporté par l'enum). Observez : le modèle appelle-t-il quand même l'outil ? Renvoyez `is_error: true` (« devise non supportée, devises valides : EUR, USD ») et vérifiez le rattrapage.
3. **Cache** : posez deux questions successives nécessitant les mêmes dépenses. Vérifiez le hit de cache au second tour.

### Critères de réussite

- [ ] Le verdict final (au-dessus / en dessous du budget) est correct par rapport aux données simulées.
- [ ] Aucun crash sur le scénario CHF ; le modèle explique la limitation ou convertit en USD en le signalant.
- [ ] Au moins un hit de cache logué sur le scénario 3.
- [ ] `max_iterations` teste et interrompt proprement une boucle trop longue.
- [ ] Chaque `tool_use` reçu a exactement un `tool_result` renvoyé.

### Indications de corrigé (structure)

```python
import anthropic, json

client = anthropic.Anthropic()
cache: dict[tuple, str] = {}

def call_tool(name: str, args: dict) -> tuple[str, bool]:
    """Retourne (contenu, is_error). Passe par le cache."""
    key = (name, json.dumps(args, sort_keys=True))
    if key in cache:
        print(f"[cache HIT] {name}{args}")
        return cache[key], False
    try:
        out = TOOL_IMPLS[name](**args)      # dict name -> fonction
        cache[key] = out
        return out, False
    except ToolError as e:
        return str(e), True                  # jamais mis en cache

messages = [{"role": "user", "content": QUESTION}]
for turn in range(10):                       # garde-fou
    resp = client.messages.create(model="claude-sonnet-4-5",
                                  max_tokens=2048, tools=TOOLS,
                                  messages=messages)
    print(f"[tour {turn}] stop_reason={resp.stop_reason}")
    if resp.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": resp.content})
    results = []
    for b in resp.content:
        if b.type == "tool_use":
            content, is_err = call_tool(b.name, b.input)
            r = {"type": "tool_result", "tool_use_id": b.id,
                 "content": content}
            if is_err:
                r["is_error"] = True
            results.append(r)
    messages.append({"role": "user", "content": results})
else:
    raise RuntimeError("max_iterations atteint — boucle interrompue")
```

Points de discussion en correction collective :
- Pourquoi ne **jamais** mettre en cache un résultat `is_error: true` ? (L'erreur peut être transitoire ; et on veut que le modèle retente avec de meilleurs paramètres.)
- Où placer la conversion : outil dédié (composable, traçable) vs laisser le modèle calculer (rapide mais non fiable pour la finance) ? Réponse d'architecte : **toujours un outil pour l'arithmétique monétaire**.
- Variante d'extension : ajouter `tool_choice: {"type": "any"}` au premier tour pour garantir que l'agent commence par collecter des données plutôt que de répondre de mémoire.
