درس 5 — Construire

Escalade et reprise

9 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Escalade et reprise

Samedi, 11 h 40. Paul attend un colis depuis dix jours et écrit « c'est inadmissible, je veux parler à quelqu'un ». L'agent transfère. Amélie, la conseillère, annule la commande et promet le remboursement sous 10 jours, puis rend la main à l'assistant pour partir en pause. Paul demande : « sur quelle carte, et dans combien de temps ? ». Si l'agent n'a rien vu de l'échange, il répondra « je ne trouve pas de remboursement en cours » et contredira la conseillère. Une escalade réussie, c'est trois transitions sans perte : vers la file, vers l'humain, et retour vers l'agent avec le contexte.

Le mécanisme

Le statut de la conversation, en base, route chaque message (leçon 1). Deux colonnes complètent le dispositif :

ColonneRôle
priorite, escalade_leordre de la file : priorité haute d'abord, puis la plus ancienne
vu_par_agentidentifiant du dernier message que l'agent a vu ; tout ce qui suit lui est résumé au tour suivant
sequenceDiagram
  participant C as Client
  participant S as SupportService
  participant A as Agent ADK
  participant Q as File (SQLite)
  participant H as Console conseillère
  C->>S: « je veux parler à quelqu'un »
  S->>A: tour d'agent
  A->>Q: transferer_a_un_conseiller(motif, "haute")
  S-->>C: réponse + « position 1 dans la file »
  C->>S: « toujours là ? »
  S->>Q: stocké, l'agent n'est pas appelé
  H->>S: prendre, répondre « remboursement sous 10 jours », rendre
  C->>S: « sur quelle carte ? »
  S->>A: [Contexte] messages après vu_par_agent + question
  A-->>C: « moyen de paiement initial, sous 10 jours »

Deux portes mènent à la file : l'outil transferer_a_un_conseiller, que le modèle appelle selon la règle 6 de sa consigne, et toute exception pendant le tour (modèle indisponible, outil en erreur). Dans ce second cas, le client reçoit « Le service est momentanément indisponible. Votre message est bien reçu, un conseiller vous répondra » et la conversation passe en priorité haute.

Mise en œuvre

L'outil de transfert et la position

files/support/tools.py (extrait)
def transferer_a_un_conseiller(motif: str, priorite: str, tool_context: ToolContext) -> dict:
    cid = tool_context.state.get("conversation_id")
    prio = "haute" if priorite.strip().lower() == "haute" else "normale"
    position = store.escalader(cid, motif, prio)
    return {"transfere": True, "position_file": position,
            "horaires": "conseillers du lundi au vendredi 9 h-18 h, samedi 10 h-13 h"}

L'identifiant de conversation vient de l'état de session, pas d'un argument : le modèle ne peut pas transférer la conversation d'un autre. La priorité est normalisée, car un modèle peut écrire « Haute » ou « urgente ». Le motif rédigé par le modèle s'affiche dans la console : c'est le résumé que la conseillère lit avant d'ouvrir l'historique.

Le message « position N dans la file » est ajouté par le service, après le tour, à partir de la base. Le modèle n'a pas à le formuler.

En attente, l'agent se tait

files/support/service.py (extrait)
conv = self.store.conversation(cid)
if conv["statut"] in ("en_attente", "humain"):
    return Reponse(cid, conv["statut"])  # le conseiller verra le message dans sa console
return await self._tour_agent(conv, texte)

Ce test a lieu avant tout appel au modèle : un client en file ne consomme aucun jeton, et l'agent ne répond jamais par-dessus une conseillère.

La reprise : ce que l'agent n'a pas vu

files/support/service.py (extrait)
def _avec_contexte(self, conv: dict, texte: str) -> str:
    non_vus = self.store.messages(conv["id"], apres=conv["vu_par_agent"])[:-1]  # sans le message courant
    if not non_vus:
        return texte
    lignes = [f"- {m['role']} : {m['texte']}" for m in non_vus[-12:]]
    return "[Contexte] Échanges depuis votre dernière réponse :\n" + "\n".join(lignes) + f"\n\nMessage du client : {texte}"

La même fonction couvre un second cas : après un redémarrage, la session ADK en mémoire est perdue. _session le signale (session recréée) et le service injecte alors l'historique depuis le premier message, au lieu de partir de vu_par_agent, qui supposerait que l'agent s'en souvient. test_reprise_apres_redemarrage simule ce redémarrage avec deux services sur la même base. La règle 8 de la consigne dit au modèle que ces lignes font foi, en particulier les engagements de la conseillère.

La console

/conseiller?token=… liste la file (priorité haute en rouille), l'historique avec les outils appelés à chaque réponse de l'agent, et la fiche : motif, priorité, temps d'attente. Quatre actions : Prendre (statut=humain, prise_le enregistré pour le tableau de bord), Envoyer (refusé avec 409 tant que la conversation n'est pas prise), Rendre à l'assistant, Clore.

Console conseillère : conversation WhatsApp transférée en priorité haute, avec le motif rédigé par l'agent (2026-10-15)
terminal
make test     # test_escalade_puis_reprise, test_reprise_apres_redemarrage, test_file_par_priorite, test_panne_modele_escalade
make run      # widget : « je veux parler à un humain », puis /conseiller?token=<ADMIN_TOKEN>

Résultat obtenu

Étape 4 de make e2e, Vertex AI, gemini-3.5-flash-lite, 2026-10-16 :

make e2e (extrait, 2026-10-16)
[whatsapp 33600000106] Ma commande LL-10533 n'arrive toujours pas, c'est inadmissible. Je veux parler à quelqu'un.
  -> webhook HTTP 200, 2.6 s
  -> Votre demande est transmise à un conseiller, qui va vous prendre en charge. Vous êtes actuellement en position 1 dans la file d'attente.

Votre demande est transmise à un conseiller (position 1 dans la file). Il reprendra cette conversation ici même.
  OK  conversation en file, priorité haute
        (la conseillère prend, écrit « remboursement de 158 euros sous 10 jours », rend la main)
[whatsapp 33600000106] Merci. Le remboursement arrive sur quelle carte, et dans combien de temps ?
  -> webhook HTTP 200, 3.2 s
  -> Le remboursement est effectué par le moyen de paiement initial, c'est-à-dire sur la carte bancaire qui a servi
     lors de votre achat. Il interviendra dans un délai de 10 jours comme vous l'a indiqué le conseiller.
  OK  l'agent reprend en tenant compte du conseiller (délai de 10 jours)

La reprise fonctionne. La première réponse montre en revanche un défaut réel : la position est annoncée deux fois, une par le modèle, une par le service. C'est l'objet de l'exercice.

Ce qui casse en production

  • La position annoncée deux fois. Le modèle lit position_file dans la réponse de l'outil et la répète, malgré la règle 6. Une consigne ne retient pas un modèle de répéter une donnée qu'on lui donne ; ne la lui donnez pas.
  • Deux conseillères prennent la même conversation. prendre écrase le champ conseiller sans vérifier qu'il est vide : la seconde gagne, les deux répondent. Parade : UPDATE … SET statut='humain', conseiller=? WHERE id=? AND statut='en_attente', puis vérifier le nombre de lignes modifiées.
  • Le contexte tronqué. Seuls les 12 derniers messages non vus sont injectés. Une conversation humaine de 40 messages perd son début, souvent l'engagement initial. Parade : faire rédiger par la conseillère, au moment de rendre la main, une note d'une ligne injectée en tête.
  • La file pleine le dimanche. Le client attend sans savoir jusqu'à quand. La réponse de l'outil porte les horaires des conseillers pour que le modèle puisse les donner ; à brancher sur un vrai calendrier si les horaires varient.
  • Une panne du modèle remplit la file. Pendant une vague de 429, chaque tour en échec devient une escalade haute (leçon 8). C'est voulu, le client a une réponse, mais l'alerte doit porter sur « escalades pour erreur technique », pas sur le volume total.

La règle de décision

Faites escalader par l'agent ce qui demande un jugement ou un engagement de l'entreprise (geste commercial, annulation, litige), et par le code tout ce qui est une panne. Ne laissez jamais le modèle décider s'il répond à un client déjà chez un humain : c'est le statut qui décide. Rendez la main à l'agent seulement quand la question restante relève de ses outils.

Pratique

Pendant qu'Amélie traite la conversation de Paul (statut humain), Paul écrit « et pour la housse de l'autre commande ? ». Que se passe-t-il ?

Après un redéploiement, un client dont la conversation était chez l'agent écrit « et le retour, c'est gratuit ? » sans redonner son numéro de commande. L'agent répond en tenant compte de la commande citée avant le redéploiement. D'où vient cette connaissance ?

Exercice. Supprimez la double annonce de position constatée dans l'e2e, sans modifier la consigne. Puis ajoutez un test qui l'empêche de revenir.

Voir la solution

La cause : la réponse de l'outil contient position_file, que le modèle répète. Le service ajoute déjà la position calculée en base, au moment de la réponse. Retirez-la de l'outil :

store.escalader(cid, motif, prio)
return {"transfere": True, "annonce": "le système communique lui-même la position au client",
        "horaires": "conseillers du lundi au vendredi 9 h-18 h, samedi 10 h-13 h"}

Test sans modèle : le faux modèle de conftest.py enregistre ses requêtes. Après « je veux parler à un conseiller », vérifiez qu'aucune function_response de la dernière requête ne contient la clé position_file, et que r.texte.count("position") == 1. Le second contrôle passe avec le faux modèle même avant correction, car il ne répète rien : c'est le premier qui protège. Ajoutez ensuite à eval/conversations.json un ne_contient_pas sur « actuellement en position » pour les cas esc-* : seule l'évaluation réelle voit le comportement du vrai modèle.

  • Je sais pourquoi l'agent n'est jamais appelé quand la conversation est en attente ou chez un humain
  • Je sais réinjecter à l'agent ce qu'il n'a pas vu, avec un pointeur en base
  • Je sais distinguer une escalade décidée par le modèle d'une escalade pour panne
  • J'ai pris, traité puis rendu une conversation depuis la console
التالي