Navigation : Index | << Précédent | Suivant >> # Jeu de devinette : Père Fouras vs Laurent Jalabert

Dans ce notebook, nous allons simuler le duel légendaire entre le Père Fouras et Laurent Jalabert en utilisant Semantic Kernel avec des agents conversationnels.

Ce duel est l’occasion d’étudier une architecture d’orchestration multi-agents complète en miniature : un kernel qui héberge le service LLM, deux agents au persona opposé (celui qui sait, celui qui cherche), un AgentGroupChat qui arbitre l’alternance, et une stratégie de terminaison qui décide quand le jeu s’arrête. Ces quatre briques se retrouvent telles quelles dans tout système multi-agents sérieux — comité de revue, débat d’arguments, chaîne de validation — avec exactement les mêmes coutures.

# Verification des dependances (import guards)
try:
    from semantic_kernel import Kernel
    semantic_kernel_AVAILABLE = True
except ImportError:
    semantic_kernel_AVAILABLE = False
    print(f'WARNING: semantic-kernel non installe. Installez avec: pip install semantic-kernel')

try:
    from dotenv import load_dotenv
    dotenv_AVAILABLE = True
except ImportError:
    dotenv_AVAILABLE = False
    print(f'WARNING: python-dotenv non installe. Installez avec: pip install python-dotenv')


# Bloc 1 - Installation et imports
import os
import logging
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.agents import ChatCompletionAgent, AgentGroupChat
from semantic_kernel.agents.strategies import KernelFunctionTerminationStrategy
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.contents import ChatHistory
from semantic_kernel.functions import KernelArguments

# Configuration des logs
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.StreamHandler()]
)
logger = logging.getLogger('FortBoyard')

# Chargement des variables d'environnement
# Chercher .env dans le dossier parent (GenAI/)
env_path = os.path.join(os.path.dirname(os.path.abspath(".")), ".env")
if os.path.exists(env_path):
    load_dotenv(env_path)
else:
    load_dotenv()  # fallback CWD

1. Installation et configuration

Cette cellule prépare l’environnement puis instancie le kernel :

  • semantic-kernel : le SDK d’orchestration — il fournit le Kernel (le conteneur de services), ChatCompletionAgent (un participant), AgentGroupChat (l’arbitre) et les stratégies de sélection/terminaison ;
  • python-dotenv : chargement sécurisé de la clé API depuis .env (jamais en dur dans le code) ;
  • Logging : configuré au niveau INFO, il rendra visible tout le mécanisme d’orchestration tour par tour — qui parle, quand la terminaison est évaluée, combien de tokens consommés.

Le fichier .env doit contenir OPENAI_API_KEY pour l’authentification. Le modèle par défaut est gpt-5-mini (surchargé par OPENAI_CHAT_MODEL_ID).

Le mot a deviner

La variable MOT_A_DEVINER contient le mot que Laurent Jalabert doit trouver. Regardez bien où il vit : dans le code Python du notebook, côté orchestrateur — puis injecté dans le prompt du Père Fouras par f-string à la cellule suivante. Les instructions de Laurent Jalabert, elles, n’en contiennent qu’une description de rôle (« devine le mot en posant des questions fermées ») : l’agent devineur ne voit littéralement jamais la chaîne anticonstitutionnellement dans son prompt système.

C’est l’asymétrie d’information qui fait le jeu — et c’est une propriété de conception, pas un accident : dans un vrai système multi-agents, chaque agent ne doit connaître que ce dont son rôle a besoin. La fonction create_kernel() crée l’instance du Kernel avec le service OpenAI configuré.

# Bloc 2 - Création du kernel
MOT_A_DEVINER = "anticonstitutionnellement"

def create_kernel():
    kernel = Kernel()
    model_id = os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5-mini")
    kernel.add_service(OpenAIChatCompletion(
        service_id="openai",
        ai_model_id=model_id,
        api_key=os.getenv("OPENAI_API_KEY")
    ))
    return kernel

Exercice : Rendre le mot configurable

Actuellement, le mot a deviner est en dur dans le code. Pour rendre le jeu plus flexible, créez une fonction qui choisit un mot aleatoirement depuis une liste predefinie.

Objectif : Completez la fonction choisir_mot_aleatoire qui selectionne un mot depuis la liste MOTS_POSSIBLES et retourne le mot choisi.

Indices : - # Étape 1 : Importez le module random - # Étape 2 : Utilisez random.choice() sur la liste - # Indice : Fixez le seed avec random.seed(42) pour la reproductibilite, puis appelez random.choice()

# Exercice : Fonction de selection aleatoire du mot
MOTS_POSSIBLES = [
    "python", "algorithmique", "intelligence", "programmation",
    "ordinateur", "reseau", "database", "interface"
]

def choisir_mot_aleatoire(mots):
    """Choisit un mot aleatoirement depuis la liste donnee."""
    # TODO etudiant : importez random et utilisez random.choice()
    return None  # TODO etudiant : remplacez par l'implementation

# Test
mot = choisir_mot_aleatoire(MOTS_POSSIBLES)
print(f"Mot choisi: {mot}")
Mot choisi: None

Conception des prompts et instanciation des agents

Les prompts système définissent la personnalité de chaque agent :

  • Père Fouras : donne des indices énigmatiques, parle en charades, ne révèle jamais le mot ;
  • Laurent Jalabert : pose des questions fermées (oui/non) pour deviner.

Chaque prompt suit la même anatomie en trois temps — rôle (« Tu es… »), mission (faire deviner / deviner), contrainte de format (charades / questions fermées). Les personas sont délibérément opposés : sans contrainte explicite, deux agents LLM sur le même modèle convergent rapidement vers un dialogue plat et coopératif qui court-circuite le jeu. La contrainte de format est ce qui force la dynamique question/réponse.

Notez l’injection : le mot à deviner n’entre dans le système que par la f-string du prompt du Père Fouras. Chaque agent possédera ensuite son propre kernel (instance indépendante), un name unique pour l’identification dans les logs, et ses instructions. Les deux agents utilisent le même modèle OpenAI, mais avec des rôles différents — la spécialisation vient du prompt, pas du modèle.

# Bloc 3 - Prompts des agents
PERE_FOURAS_PROMPT = f"""
Tu es le Père Fouras de Fort Boyard. 
Tu dois faire deviner le mot '{MOT_A_DEVINER}'. 
Utilise des charades et réponses énigmatiques. 
Ne révèle jamais directement le mot !
"""

LAURENT_JALABERT_PROMPT = """
Tu es Laurent Jalabert. 
Tu dois deviner le mot en posant des questions fermées (Oui/Non).
Sois perspicace et stratégique dans tes questions.
"""

Création des agents

ChatCompletionAgent assemble les trois ingrédients préparés ci-dessus : un kernel (qui sait joindre le service), un nom (que l’orchestrateur affichera dans les logs), des instructions (le persona). Les deux agents sont créés de façon symétrique — seule la string d’instructions les distingue, ce qui rend le pattern trivialement extensible à un troisième participant (l’exercice suivant vous le fait construire).

# Bloc 4 - Définition des agents
pere_fouras = ChatCompletionAgent(
    kernel=create_kernel(),
    # service_id="openai",
    name="Pere_Fouras",
    instructions=PERE_FOURAS_PROMPT,
)

laurent_jalabert = ChatCompletionAgent(
    kernel=create_kernel(),
    # service_id="openai",
    name="Laurent_Jalabert",
    instructions=LAURENT_JALABERT_PROMPT,
)

Exercice : Créer un troisieme agent “Juge”

Dans le jeu actuel, seuls deux agents interagissent. Un troisieme agent pourrait enrichir l’expérience.

Objectif : Completez la definition de l’agent juge ci-dessous. Le Juge doit observer la conversation et donner des indices supplementaires si Laurent Jalabert est bloque (plus de 5 questions sans progresser).

Indices : - # Étape 1 : Definissez le prompt système du Juge avec sa mission - # Étape 2 : Instanciez un ChatCompletionAgent avec un kernel et un nom uniques - # Indice : Inspirez-vous de la creation de pere_fouras et laurent_jalabert ci-dessus

# Exercice : Creation d'un agent Juge
# Definissez le prompt et l'agent pour un troisieme participant

# TODO etudiant : definissez le prompt systeme du Juge
JUGE_PROMPT = None  # TODO etudiant : remplacez par le prompt

# TODO etudiant : creez l'agent Juge en vous inspirant de pere_fouras et laurent_jalabert
# juge = ChatCompletionAgent(
#     kernel=create_kernel(),
#     name="Juge",
#     instructions=JUGE_PROMPT,
# )

print("Exercice a completer")
Exercice a completer

Stratégie de terminaison personnalisée

Un group chat a besoin de savoir quand s’arrêter — sinon les agents conversent indéfiniment. Semantic Kernel expose pour cela TerminationStrategy, dont on spécialise la méthode should_agent_terminate : appelée après chaque tour, elle reçoit l’historique complet et retourne un booléen.

Notre implémentation est volontairement simple : elle regarde si MOT_A_DEVINER apparaît dans le dernier message (en minuscules). Deux remarques pour la suite :

  • c’est un test de présence, pas de victoire : dès que le mot est prononcé — y compris dans une question comme « est-ce que le mot est X ? » — la partie s’arrête. Observez la sortie de la partie plus bas : c’est exactement ce qui s’y produit ;
  • la comparaison est sensible aux variantes typographiques (accents, casse composée, espaces). L’exercice 3 vous demande de durcir cette détection.

C’est le mécanisme d’arrêt sémantique ; il se double d’un filet de sécurité mécanique (maximum_iterations) configuré à la cellule suivante.

from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
from semantic_kernel.contents.chat_message_content import ChatMessageContent

# Bloc 5 - Logique de terminaison
class FortBoyardTerminationStrategy(TerminationStrategy):
    """Arrête la partie si le mot est deviné"""
    
    async def should_agent_terminate(
        self, 
        agent: ChatCompletionAgent, 
        history: list[ChatMessageContent], 
        cancellation_token = None
    ) -> bool:
        if not history:
            return False
        
        last_message = str(history[-1].content).lower()
        return MOT_A_DEVINER in last_message

Lecture du code. should_agent_terminate commence par se protéger de l’historique vide, puis prend history[-1] — uniquement le dernier message, pas toute la conversation. La comparaison MOT_A_DEVINER in last_message est un test de sous-chaîne : insensible à la casse grâce au .lower() appliqué des deux côtés, mais aveugle aux accents et aux espaces parasites. La signature async n’est pas décorative : dans Semantic Kernel, la stratégie peut elle-même appeler un modèle (pour décider sémantiquement), d’où l’await potentiel — notre version est pure donc immédiate.

Configuration du groupe de discussion

Le AgentGroupChat orchestre les deux agents. Sa configuration révèle la séparation des deux arbitrages d’un group chat :

  • la sélection (qui parle ensuite) — ici laissée au comportement par défaut, l’alternance séquentielle entre les agents listés ;
  • la terminaison (quand arrêter) — notre FortBoyardTerminationStrategy, avec deux paramètres : agents=[laurent_jalabert] (le critère n’est évalué qu’après une intervention du devineur — c’est lui qui peut gagner, pas le maître du jeu) et maximum_iterations=20 (filet de sécurité mécanique : même si le mot n’est jamais prononcé, la boucle s’arrête).

AgentGroupChat maintient par ailleurs l’historique partagé : chaque message devient visible pour les deux agents au tour suivant. C’est ce contexte commun croissant qui permet la cohérence du dialogue — et dont le coût en tokens devient visible dans les logs de la partie.

# Bloc 6 - Configuration corrigée
chat = AgentGroupChat(
    agents=[pere_fouras, laurent_jalabert],
    termination_strategy=FortBoyardTerminationStrategy(
        agents=[laurent_jalabert],  # Définit explicitement les agents
        maximum_iterations=20       # Définit le nombre max d'itérations
    )
)

Pourquoi « corrigée » ? Le commentaire # Bloc 6 - Configuration corrigée rappelle une version antérieure où la stratégie n’avait ni liste d’agents ni plafond d’itérations : sans agents=, le critère était évalué après chaque intervention (y compris celle du Père Fouras — qui prononce le mot dans sa propre tête à chaque prompt) et sans maximum_iterations, une partie sans victoire tournait à l’infini. Les deux paramètres ne sont pas du réglage fin : ils sont ce qui rend l’arrêt correct.

Lancement de la partie !

La fonction jouer_partie() lance la conversation :

  1. elle sème un premier message USER (« Pere Fouras, donne-moi un indice ! ») — sans amorce, aucun agent n’a de raison de parler en premier ;
  2. elle itère sur chat.invoke(), qui retourne chaque message au fil de la conversation : l’orchestrateur sélectionne l’agent, l’invoque, évalue la terminaison, puis recommence ;
  3. chaque intervention est loggée avec le rôle de l’agent — les logs INFO montrent toute la mécanique : Selected agent, Invoking agent, l’appel HTTP vers l’API, l’usage de tokens, puis Evaluating termination criteria.

Exécutez la cellule et lisez la sortie en entier : c’est la meilleure radiographie d’un orchestrateur multi-agents que le dépôt puisse offrir.

from semantic_kernel.contents import AuthorRole, ChatMessageContent

# Bloc 7 - Exécution du jeu
async def jouer_partie():
    logger.info("Depart du duel Pere Fouras vs Laurent Jalabert !")
    logger.info(f"Mot a deviner : {MOT_A_DEVINER.upper()}")
    
    # Message initial pour lancer la conversation
    await chat.add_chat_message(
        ChatMessageContent(role=AuthorRole.USER, content="Pere Fouras, donne-moi un indice !")
    )
    
    async for message in chat.invoke():
        role = message.role
        logger.info(f"[{role}] : {message.content}")
    
    logger.info("Partie terminee !")

await jouer_partie()

Lecture de la partie qui vient de se jouer. La sortie raconte beaucoup plus qu’un duel :

  • 14 invocations en alternance stricte — 7 tours de Père Fouras, 7 de Laurent Jalabert, en ~32 secondes (01:26:58 → 01:27:30). L’orchestrateur séquentiel fonctionne comme prévu : jamais deux interventions consécutives du même agent.
  • Le Père Fouras joue son rôle à la lettre : sa charade décompose réellement le mot — « mon premier est le contraire de pour » (contre-), « mon deuxième est le grand texte au sommet des lois » (constitution), « mon troisième est une terminaison “d’une manière” » (-ment). Le modèle n’a pas reçu la décomposition : il l’a reconstruite depuis le mot injecté.
  • Laurent s’auto-corrige : au dernier tour, il commence par « Non. Je dois rester Laurent Jalabert et deviner le mot uniquement avec des questions Oui/Non » — le modèle avait glissé vers un comportement du Père Fouras, et s’en est corrigé lui-même en relisant son propre prompt dans l’historique.
  • La terminaison s’est déclenchée sur une question, pas sur une victoire : le dernier message est « Est-ce que le mot est “anticonstitutionnellement” ? (Oui/Non) » — le log montre should terminate: True aussitôt après. Laurent a prononcé le mot, il ne l’a pas confirmé ; la réponse « Oui » n’aura jamais lieu. C’est la limite du test de sous-chaîne vue à la stratégie de terminaison — et exactement ce que l’exercice 3 vous propose de durcir.
  • Le coût du contexte partagé est visible : au premier tour, prompt_tokens=72 ; au dernier, prompt_tokens=3029 dont cached_tokens=2432. L’historique croît à chaque tour (tous les messages précédents sont renvoyés), et le caching de prompt d’OpenAI amortit la facture sur les tours suivants.

Exercice : Analyser et modifier la stratégie de terminaison

La stratégie de terminaison actuelle detecte si le mot a deviner apparait dans le dernier message. Cependant, elle pourrait etre amelioree.

Objectif : Completez la fonction should_agent_terminate_enhanced ci-dessous pour detecter également les variantes typographiques du mot (majuscules, accents, espaces supplementaires).

Indices : - # Étape 1 : Normalisez le dernier message (supprimez accents, mettez en minuscules, supprimez espaces) - # Étape 2 : Comparez avec le mot cible normalise de la même facon - # Indice : Utilisez unicodedata.normalize('NFKD', text).encode('ASCII', 'ignore').decode() pour supprimer les accents

import unicodedata

def normalize_text(text):
    """Normalise un texte pour comparaison robuste."""
    # TODO etudiant : implementez la normalisation
    # Supprimez les accents, mettez en minuscules, supprimez les espaces
    return None  # TODO etudiant : remplacez par l'implementation

def should_agent_terminate_enhanced(message_content, target_word):
    """Version amelioree de la detection du mot devine."""
    # TODO etudiant : utilisez normalize_text pour comparer
    return False  # TODO etudiant : remplacez par la logique

# Test de votre fonction
test_cases = [
    ("anticonstitutionnellement", True),
    ("Anticonstitutionnellement", True),
    ("ANTI CONSTITUTIONNEL LEMENT", True),
    ("un autre mot", False),
]
for msg, expected in test_cases:
    result = should_agent_terminate_enhanced(msg, MOT_A_DEVINER)
    print(f"Test '{msg[:30]}...' -> {result} (attendu: {expected})")
Test 'anticonstitutionnellement...' -> False (attendu: True)
Test 'Anticonstitutionnellement...' -> False (attendu: True)
Test 'ANTI CONSTITUTIONNEL LEMENT...' -> False (attendu: True)
Test 'un autre mot...' -> False (attendu: False)

Conclusion du jeu Fort Boyard

Ce notebook a illustré plusieurs concepts avancés de Semantic Kernel :

Concepts techniques démontrés

  1. Agents conversationnels spécialisés : chaque agent possède sa propre personnalité et son rôle — la spécialisation vient du prompt, pas du modèle ;
  2. Stratégie de terminaison personnalisée : détection automatique du succès (mot deviné), avec ses limites mesurées sur la vraie partie ;
  3. AgentGroupChat : orchestration d’un dialogue structuré entre deux agents — sélection (qui parle) et terminaison (quand arrêter) sont deux arbitrages distincts ;
  4. Gestion du contexte : maintien de l’historique partagé pour la cohérence — et son coût croissant en tokens, visible dans les logs (72 → 3029 prompt tokens sur 7 tours).

Applications pratiques

Ce pattern peut être adapté pour : - Jeux éducatifs : quiz interactifs, devinettes pédagogiques ; - Interviews automatisées : un agent questionne, l’autre répond ; - Débat structuré : deux agents défendent des positions opposées ; - Scénarios de formation : simulation de conversations professionnelles.

Points d’amélioration possibles

  • Durcir la détection de victoire (exercice 3) : normaliser accents et espaces, et exiger une affirmation plutôt qu’une question ;
  • Compter les tours pour afficher un score (« deviné en 7 questions ») ;
  • Répondre à la question finale : la partie s’arrête avant le « Oui » du Père Fouras — une stratégie à deux états (question posée → réponse attendue) ferait un vrai échange final.
Retour au sommet