Générateur de Recettes PDF

# Parameters
BATCH_MODE = True
# Parameters
BATCH_MODE = True

Introduction - receipe maker

Navigation : Index

Ce notebook couvre les concepts et techniques principaux de receipe maker. Les objectifs pedagogiques incluent la comprehension des fondamentaux, la mise en pratique via des exercices, et analyse de résultats.

Prerequis : notions de base en Python et en algorithmique.

Adapté d’une production étudiante EPF : Dorian & Bastien (cuisine). Universalisation : refactor #890.

Ce notebook orchestre une collaboration multi-agents pour : 1. Collecter des contraintes utilisateur 2. Générer une recette personnalisée 3. Produire un PDF stylisé

Agents :
- InputCollector → Collecte des préférences
- RecipeGenerator → Crée la recette via LLM
- PDFGenerator → Génère le document final


Installation des bibliothèques

Pourquoi ce notebook s’appuie sur Semantic Kernel et non sur un simple appel d’API : générer une recette structurée (titre, ingrédients listés, étapes numérotées, respect des contraintes diététiques) en une seule requête LLM produit un résultat au format libre et souvent incohérent. Semantic Kernel orchestre plusieurs agents spécialisés (collecteur de critères, générateur de recette, validateur de contraintes) qui se passent un état partagé — c’est l’architecture multi-agent qui garantit que la recette finale respecte toutes les contraintes (végétarien, sans champignons, 6 convives). Les import guards de la cellule suivante rendent chaque dépendance optionnelle explicite : le notebook fonctionne en mode dégradé sans crasher si une librairie manque, ce qui est la robustesse attendue d’un cas d’étude exécutable sur des environnements hétérogènes.

# Import guards - availability flags for external dependencies

try:
    from dotenv import load_dotenv
    DOTENV_AVAILABLE = True
except ImportError:
    DOTENV_AVAILABLE = False
    print(f'  dotenv non disponible - certaines fonctionnalites seront limitees')

try:
    import semantic_kernel
    SEMANTIC_KERNEL_AVAILABLE = True
except ImportError:
    SEMANTIC_KERNEL_AVAILABLE = False
    print(f'  semantic_kernel non disponible - certaines fonctionnalites seront limitees')

# Cell 1 - Installation

État Global & Configuration

Pourquoi un état global partagé est indispensable en multi-agent : chaque agent (collecteur, générateur, validateur) traite une partie du problème mais doit voir l’ensemble des contraintes. La classe RecipeState est cet état partagé — elle porte le régime alimentaire, les ingrédients exclus, le nombre de convives, et la recette en cours de construction. Sans état global, chaque agent devrait re-passer toutes les contraintes dans son prompt (redondant, coûteux en tokens, source d’oublis) ; avec état global, un agent lit state.regime et agit en conséquence. C’est le pattern blackboard de l’IA classique modernisé : les agents ne communiquent pas directement entre eux, ils communiquent à travers l’état — ce qui découple les agents et permet d’en ajouter (un agent nutritionniste) sans modifier les autres.

class RecipeState:
    def __init__(self):
        self.diet: str = ""
        self.excluded_ingredients: list[str] = []
        self.guests: int = 4
        self.ingredients: list[str] = []
        self.steps: list[str] = []
        self.cooking_time: float = 0.0
        self.ready_to_generate: bool = False
        self.pdf_path: str = ""

print("Classe RecipeState définie")
print("Classe RecipeState definie")
Classe RecipeState définie
Classe RecipeState definie

Exercice 1 : Enrichir l’etat partage avec des préférences culinaires

La classe RecipeState stocke le regime, les ingredients exclus et le nombre de convives. L’objectif est de l’enrichir avec de nouveaux champs : niveau de difficulte (debutant, intermediaire, expert), temps de preparation maximum, et cuisine preferee (francaise, italienne, asiatique, etc.).

Objectif : etendre RecipeState avec ces nouveaux attributs et créer les méthodes de mise a jour correspondantes.

Indices : - # Étape 1 : Ajouter les attributs difficulty, max_prep_time et cuisine_type dans __init__ - # Étape 2 : Créer les méthodes set_difficulty, set_max_prep_time et set_cuisine - # Indice : suivre le pattern existant des autres attributs (type hints + valeur par defaut)

class ExtendedRecipeState(RecipeState):
    # TODO etudiant : enrichir l'etat avec les nouvelles preferences
    
    def __init__(self):
        super().__init__()
        self.difficulty: str = ""         # Etape 1 : debutant / intermediaire / expert
        self.max_prep_time: float = 0.0   # Etape 1 : temps max en minutes
        self.cuisine_type: str = ""       # Etape 1 : type de cuisine preferee
    
    def set_difficulty(self, level: str) -> str:
        result = None  # TODO etudiant : valider et assigner
        return result  # TODO etudiant : retourner confirmation
    
    def set_max_prep_time(self, minutes: float) -> str:
        result = None  # TODO etudiant : valider et assigner
        return result  # TODO etudiant : retourner confirmation
    
    def set_cuisine(self, cuisine: str) -> str:
        result = None  # TODO etudiant : assigner
        return result  # TODO etudiant : retourner confirmation

print("Exercice a completer : ExtendedRecipeState")
Exercice a completer : ExtendedRecipeState

Plugins d’agents et fonctions de modification d’état

Pourquoi les agents modifient l’état via des fonctions (plugins) plutôt qu’en générant du texte libre : si un agent écrivait « régime = végétarien » en prose, les autres agents devraient parser cette phrase pour extraire la contrainte — fragile et error-prone. Les plugins exposent des fonctions typées (set_regime, add_excluded_ingredient, set_convives) que le kernel appelle de manière structurée : la contrainte atterrit directement dans le bon champ de RecipeState, sans ambiguïté de parsing. C’est l’écart entre un agent qui décrit une action (« je note que l’utilisateur est végétarien ») et un agent qui exécute une action (state.regime = vegetarien) — le second est reproductible et vérifiable, le premier dépend de l’interprétation du lecteur.

import textwrap
from semantic_kernel.functions import kernel_function
from reportlab.pdfgen import canvas

class InputCollectorPlugin:
    def __init__(self, state: RecipeState):
        self.state = state
    
    @kernel_function(name="set_diet", description="Définit le régime alimentaire")
    def set_diet(self, diet: str) -> str:
        self.state.diet = diet
        return f"Régime {diet} enregistré"

    @kernel_function(name="exclude_ingredient", description="Ajoute un ingrédient à exclure")
    def exclude_ingredient(self, ingredient: str) -> str:
        self.state.excluded_ingredients.append(ingredient)
        return f"Ingrédient {ingredient} exclu"

    @kernel_function(name="set_guests", description="Définit le nombre de convives")
    def set_guests(self, guests: int) -> str:
        self.state.guests = guests
        return f"{guests} convives prévus"

class RecipeGeneratorPlugin:
    def __init__(self, state: RecipeState):
        self.state = state

    @kernel_function(name="submit_recipe", description="Valide la recette générée")
    def submit_recipe(self, ingredients: list[str], steps: list[str], cooking_time: float) -> str:
        self.state.ingredients = ingredients
        self.state.steps = steps
        self.state.cooking_time = cooking_time
        self.state.ready_to_generate = True
        return "Recette validée et prête pour la génération PDF"

class PDFGeneratorPlugin:
    def __init__(self, state: RecipeState):
        self.state = state
    
    @kernel_function(name="generate_pdf", description="Génère le PDF final")
    def generate_pdf(self, output_path: str) -> str:
        c = canvas.Canvas(output_path)
        PAGE_W, PAGE_H = 595, 842  # A4 en points
        MARGIN_L, MARGIN_R, MARGIN_TOP, MARGIN_BOTTOM = 100, 80, 50, 50
        LINE_H = 15
        MAX_LINE_W = (PAGE_W - MARGIN_L - MARGIN_R)  # 415 pts de large utile
        y = PAGE_H - MARGIN_TOP
        
        def draw_wrapped(text, x, y, font_size=10, indent=0):
            """Dessine text enveloppe sur MAX_LINE_W avec retour a la ligne."""
            c.setFont("Helvetica", font_size)
            avg_char_w = font_size * 0.5  # approximation ReportLab Helvetica
            chars_per_line = int(MAX_LINE_W / avg_char_w) - indent
            for line in textwrap.wrap(text, width=chars_per_line) or [""]:
                if y < MARGIN_BOTTOM + LINE_H:
                    c.showPage()  # pagination -- nouvelle page si debordement
                    c.setFont("Helvetica", font_size)
                    y = PAGE_H - MARGIN_TOP
                c.drawString(x + indent, y, line)
                y -= LINE_H
            return y
        
        c.setFont("Helvetica-Bold", 14)
        c.drawString(MARGIN_L, y, "Recette personnalisée")
        y -= 25
        c.setFont("Helvetica", 10)
        y = draw_wrapped(f"Pour {self.state.guests} personnes", MARGIN_L, y)
        y = draw_wrapped(f"Régime: {self.state.diet}", MARGIN_L, y)
        y -= 5
        c.setFont("Helvetica-Bold", 11)
        c.drawString(MARGIN_L, y, "Ingrédients:")
        y -= LINE_H
        c.setFont("Helvetica", 10)
        for ing in (self.state.ingredients or []):
            y = draw_wrapped(f"- {ing}", MARGIN_L, y, indent=15)
        y -= 5
        c.setFont("Helvetica-Bold", 11)
        c.drawString(MARGIN_L, y, "Étapes:")
        y -= LINE_H
        c.setFont("Helvetica", 10)
        for i, step in enumerate(self.state.steps or [], start=1):
            y = draw_wrapped(f"{i}. {step}", MARGIN_L, y, indent=15)
        y -= 5
        c.setFont("Helvetica-Oblique", 10)
        y = draw_wrapped(f"Temps de cuisson: {self.state.cooking_time} min", MARGIN_L, y)
        c.save()
        self.state.pdf_path = output_path
        return f"PDF généré : {output_path}"

print("Imports Semantic Kernel OK")
Imports Semantic Kernel OK

Exercice 2 : Plugin d’estimation nutritionnelle

Les plugins actuels gerent la collecte d’informations et la generation de recette, mais aucune information nutritionnelle n’est fournie. L’objectif est d’ajouter un NutritionPlugin qui estime les calories et macronutriments d’une recette.

Objectif : créer une classe NutritionPlugin avec une méthode estimate_nutrition annotee @kernel_function qui retourne une estimation des calories par portion.

Indices : - # Étape 1 : Définir un dictionnaire approximatif de calories par ingredient de base - # Étape 2 : Calculer la somme des calories et diviser par le nombre de convives - # Indice : le dictionnaire peut contenir des valeurs moyennes (ex: poulet = 165 kcal/100g, riz = 130 kcal/100g)

class NutritionPlugin:
    """Plugin d'estimation nutritionnelle pour les recettes."""
    
    def __init__(self, state: RecipeState):
        self.state = state
    
    @kernel_function(name="estimate_nutrition", description="Estime les calories par portion")
    def estimate_nutrition(self, ingredients: str) -> str:
        # TODO etudiant : implementer l'estimation nutritionnelle
        # Etape 1 : dictionnaire de reference (ingredient -> kcal/100g)
        CALORIES_REF = {}  # TODO etudiant : completer le dictionnaire
        
        # Etape 2 : parser les ingredients et calculer les calories totales
        total_calories = 0  # TODO etudiant : calculer
        calories_par_portion = None  # TODO etudiant : diviser par state.guests
        
        result = None  # TODO etudiant : formater le resultat
        return result  # TODO etudiant : retourner la description nutritionnelle

print("Exercice a completer : NutritionPlugin")
Exercice a completer : NutritionPlugin

Création des agents

Pourquoi distinguer plusieurs agents plutôt qu’un seul prompt « génère une recette » : un agent unique devrait gérer simultanément la collecte des critères, la génération de la recette, et la validation des contraintes — trois rôles cognitivement différents, chacun avec son propre prompt et son système de raisonnement. La séparation en agents spécialisés (un collecteur empathique qui pose les bonnes questions, un générateur créatif qui propose, un validateur strict qui vérifie) suit le principe de responsabilité unique appliqué au LLM : chaque agent a un rôle clair, un prompt ciblé, et un critère de réussite mesurable. C’est aussi ce qui rend le système debuggable — si une recette ne respecte pas une contrainte, on sait quel agent a fauté.

import os
from dotenv import load_dotenv
from openai import AsyncOpenAI
from semantic_kernel import Kernel
from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
CHAT_MODEL = os.getenv("OPENAI_CHAT_MODEL", "gpt-5-mini")


def build_kernel(plugin, plugin_name: str) -> Kernel:
    """Cree un kernel avec un service de chat OpenAI et le plugin fourni.

    Sans add_service, l'agent leve "No service found" des le premier appel LLM.

    Note gpt-5 / function-tools : SK 1.42 refuse la valeur `none` pour
    `reasoning_effort` (Literal["low","medium","high"] uniquement), mais
    OpenAI l'accepte pour /v1/chat/completions et le requiert pour les
    function tools (400 : "Function tools with reasoning_effort are not
    supported for gpt-5-mini"). On force donc la valeur via `default_query`
    sur le client AsyncOpenAI, ce qui ajoute le parametre a TOUTES les
    requetes HTTP du service (chat, completions, etc.) sans validation
    pydantic cote SK. `async_client=` est le seul point d'injection reconnu
    par OpenAIChatCompletion.__init__ pour un client custom (le kwarg
    `client=` n'apparait pas dans la signature).
    """
    client = AsyncOpenAI(
        api_key=OPENAI_API_KEY,
        default_query={"reasoning_effort": "none"},
    )
    kernel = Kernel()
    kernel.add_service(
        OpenAIChatCompletion(
            service_id="openai",
            ai_model_id=CHAT_MODEL,
            async_client=client,
        )
    )
    kernel.add_plugin(plugin, plugin_name)
    return kernel


shared_state = RecipeState()

# InputCollector
input_kernel = build_kernel(InputCollectorPlugin(shared_state), "input_plugin")
input_agent = ChatCompletionAgent(
    kernel=input_kernel,
    name="InputCollector",
    instructions="""Collectez les informations utilisateur de maniere structuree.
    - Demandez d'abord le regime alimentaire
    - Puis les ingredients a exclure
    - Enfin le nombre de convives"""
)

# RecipeGenerator avec son plugin
recipe_kernel = build_kernel(RecipeGeneratorPlugin(shared_state), "recipe_plugin")
recipe_agent = ChatCompletionAgent(
    kernel=recipe_kernel,
    name="RecipeGenerator",
    instructions="""Generez des recettes en respectant les contraintes.
    - Convertir les ingredients en liste Python
    - Structurer les etapes de preparation
    - Calculer le temps de cuisson"""
)

# PDFGenerator
pdf_kernel = build_kernel(PDFGeneratorPlugin(shared_state), "pdf_plugin")
pdf_agent = ChatCompletionAgent(
    kernel=pdf_kernel,
    name="PDFGenerator",
    instructions="""Generez un PDF professionnel:
    - Structurez en sections claires
    - Utilisez une mise en page aeree
    - Quand la recette est validee, appelez la fonction generate_pdf avec
      output_path='recette_generee.pdf'"""
)

print("Imports et configuration OK")
Imports et configuration OK

création de la conversation avec critères de terminaison et/ou de sélection

Pourquoi la conversation a besoin de critères de terminaison explicites : sans règle d’arrêt, les agents pourraient boucler indéfiniment (le validateur demande une amélioration, le générateur propose, le validateur demande encore…). Les critères de terminaison définissent quand la conversation s’arrête — typiquement quand le validateur confirme que toutes les contraintes sont satisfaites, ou après un nombre maximal d’échanges. Les critères de sélection, eux, définissent quel agent parle ensuite (après le collecteur → le générateur ; après le générateur → le validateur). Ces deux mécanismes transforment un flot potentiellement chaotique d’échanges LLM en un protocole ordonné avec un début, un milieu et une fin garantis.

from semantic_kernel.agents import AgentGroupChat
from pydantic import PrivateAttr

from semantic_kernel.agents.strategies import TerminationStrategy

class ReadyTerminationStrategy(TerminationStrategy):
    _state: RecipeState = PrivateAttr()

    def __init__(self, state: RecipeState, **kwargs):
        super().__init__(**kwargs)
        self._state = state

    async def should_agent_terminate(self, agent, history):
        # Terminer sur le LIVRABLE produit (pdf_path), pas sur la demande
        # de le produire (ready_to_generate) : sinon la conversation
        # s'arrête exactement au tour où PDFGenerator devrait parler.
        return bool(self._state.pdf_path)


group_chat = AgentGroupChat(
    agents=[input_agent, recipe_agent, pdf_agent],
    termination_strategy=ReadyTerminationStrategy(shared_state, maximum_iterations=12),
)

print("Imports agents OK")
Imports agents OK

Boucle principale

Pourquoi la boucle principale orchestre plutôt que d’exécuter séquentiellement : les agents ne s’enchaînent pas dans un ordre fixe (collecteur puis générateur puis validateur, toujours) — la boucle permet au validateur de redemander au générateur si une contrainte n’est pas respectée, créant un cycle de raffinement. C’est la différence entre un pipeline (une seule passe, pas de retour) et une conversation (itérative, avec feedback). La boucle principale est le chef d’orchestre : elle lit l’état, décide du prochain agent selon les critères de sélection, exécute l’agent, vérifie les critères de terminaison, et recommence jusqu’à convergence. C’est ce qui permet au système de corriger ses propres erreurs au lieu de les propager.

# Fallback: s'assurer que group_chat est défini
try:
    group_chat
except NameError:
    print("⚠️ group_chat non défini - vérifiez que la cellule précédente a été exécutée")
    group_chat = None

from semantic_kernel.contents import ChatMessageContent, AuthorRole

async def recipe_workflow():
    if group_chat is None:
        print("❌ Impossible d'exécuter: group_chat non initialisé")
        return

    # En mode batch (Papermill/CI), stdin n'existe pas : demande de recette
    # fixée -- même patron que Medical-Chatbot et Fort-Boyard.
    if BATCH_MODE:
        recipe_query = (
            "Je souhaite une recette végétarienne pour 6 personnes, "
            "sans champignons ni produits laitiers."
        )
        print(f"[Batch] Demande de démonstration : {recipe_query}")
    else:
        recipe_query = input("Décrivez votre demande de recette : ")

    # Amorcer la conversation (API SK 1.42 : add_chat_message est asynchrone,
    # l'appeler sans await laisse un coroutine jamais attendue).
    await group_chat.add_chat_message(
        ChatMessageContent(role=AuthorRole.USER, content=recipe_query)
    )

    try:
        async for message in group_chat.invoke():
            print(f"[{message.name}] {message.content}")
    except Exception as e:
        print(f"Erreur lors de la génération: {str(e)}")

    print("\nProcessus terminé")

await recipe_workflow()
[Batch] Demande de démonstration : Je souhaite une recette végétarienne pour 6 personnes, sans champignons ni produits laitiers.
[InputCollector] Parfait — j’ai noté vos préférences : régime végétarien, exclusion des champignons et des produits laitiers, pour 6 personnes.

Voici une recette adaptée et complète : Curry de légumes rôtis au lait de coco (végétarien, sans champignons ni produits laitiers)

Ingrédients
- 2 patates douces moyennes (environ 800 g), pelées et coupées en dés
- 2 carottes, coupées en rondelles
- 2 poivrons rouges, en lanières
- 1 brocoli moyen, séparé en fleurettes
- 1 oignon jaune moyen, émincé
- 3 gousses d’ail, hachées
- 1 boîte (400 ml) de lait de coco (vérifier qu’il n’y a pas de lait concentré avec produits laitiers)
- 1 boîte (400 g) de tomates concassées
- 2 c. à soupe de pâte de curry rouge (ou jaune selon préférence) — vérifier les ingrédients pour éviter produits laitiers
- 2 c. à soupe d’huile d’olive ou d’huile de coco
- 1 c. à soupe de gingembre frais râpé
- 1 c. à café de curcuma en poudre
- 1 c. à café de cumin moulu
- 1 c. à café de coriandre moulue
- Sel et poivre au goût
- 1 c. à soupe de jus de citron ou de lime
- 1 bouquet de coriandre fraîche, hachée (pour servir)
- 300 g de pois chiches cuits (ou 1 boîte, égouttée)
- Riz basmati ou quinoa pour servir (environ 300–360 g sec pour 6 personnes)
- Optionnel : piments frais ou flocons de piment selon goût

Préparation
1. Préchauffer le four à 200 °C. Sur une grande plaque, disposer les dés de patate douce, rondelles de carotte et poivrons; arroser d’1 c. à soupe d’huile, saler, poivrer et rôtir 25–30 minutes jusqu’à ce qu’ils soient tendres et dorés.
2. Pendant ce temps, chauffer 1 c. à soupe d’huile dans une grande casserole ou une poêle profonde à feu moyen. Faire revenir l’oignon 4–5 min jusqu’à translucide. Ajouter l’ail et le gingembre; cuire 1 min.
3. Ajouter la pâte de curry, le curcuma, le cumin et la coriandre moulue; cuire 1–2 min en remuant pour libérer les arômes.
4. Ajouter les tomates concassées et le lait de coco; bien mélanger. Porter à légère ébullition puis réduire le feu et laisser mijoter 8–10 min pour épaissir légèrement.
5. Ajouter le brocoli et les pois chiches; laisser cuire 5–7 min jusqu’à ce que le brocoli soit tendre mais encore croquant.
6. Incorporer les légumes rôtis, goûter et rectifier l’assaisonnement (sel, poivre, jus de citron). Laisser mijoter 2–3 min pour que les saveurs se mélangent.
7. Servir chaud sur riz basmati ou quinoa, garnir de coriandre fraîche et de piment si désiré.

Conseils
- Vous pouvez substituer ou ajouter d’autres légumes de saison (courgette, aubergine, chou-fleur).
- Pour un repas plus consistant, ajouter du tofu ferme grillé ou du tempeh (vérifier qu’ils conviennent au régime).
- La pâte de curry peut être remplacée par des épices sèches supplémentaires si vous préférez éviter les préparations industrielles.

Souhaitez-vous que j’adapte la recette (moins épicée, version sans gluten, liste de courses imprimable, ou estimation nutritionnelle) ?
[RecipeGenerator] Recette validée. Ci‑dessous la version finale demandée — ingrédients en liste Python, étapes structurées et temps de cuisson calculé.

Pour 6 personnes — Curry de légumes rôtis au lait de coco (végétarien, sans champignons ni produits laitiers)

Ingredients (liste Python)
ingredients = [
    "2 patates douces moyennes (≈800 g), pelées et coupées en dés",
    "2 carottes, coupées en rondelles",
    "2 poivrons rouges, émincés",
    "1 brocoli moyen, séparé en fleurettes",
    "1 oignon jaune moyen, émincé",
    "3 gousses d'ail, hachées",
    "1 boîte (400 ml) de lait de coco (sans produits laitiers ajoutés)",
    "1 boîte (400 g) de tomates concassées",
    "2 c. à soupe de pâte de curry rouge ou jaune (vérifier l'étiquette)",
    "2 c. à soupe d'huile d'olive ou d'huile de coco",
    "1 c. à soupe de gingembre frais râpé",
    "1 c. à café de curcuma en poudre",
    "1 c. à café de cumin moulu",
    "1 c. à café de coriandre moulue",
    "Sel et poivre au goût",
    "1 c. à soupe de jus de citron ou de lime",
    "1 bouquet de coriandre fraîche, hachée (pour servir)",
    "300 g de pois chiches cuits (ou 1 boîte, égouttée)",
    "Riz basmati ou quinoa pour servir (300–360 g sec pour 6 personnes)",
    "Optionnel : piments frais ou flocons de piment"
]

Etapes de préparation (structurées)
steps = [
    "Préchauffer le four à 200 °C. Disposer les dés de patate douce, les rondelles de carotte et les lanières de poivron sur une grande plaque. Arroser d'1 c. à soupe d'huile, saler et poivrer. Enfourner 25–30 minutes jusqu'à ce que les légumes soient tendres et dorés.",
    "Pendant que les légumes rôtissent, cuire le riz ou le quinoa selon les indications du paquet.",
    "Dans une grande casserole ou poêle profonde, chauffer 1 c. à soupe d'huile à feu moyen. Faire revenir l'oignon 4–5 minutes jusqu'à translucide.",
    "Ajouter l'ail et le gingembre ; cuire 1 minute en remuant.",
    "Incorporer la pâte de curry, le curcuma, le cumin et la coriandre moulue ; cuire 1–2 minutes pour libérer les arômes.",
    "Verser les tomates concassées et le lait de coco ; bien mélanger. Porter à légère ébullition, réduire le feu et laisser mijoter 8–10 minutes pour épaissir légèrement.",
    "Ajouter les pois chiches et le brocoli ; cuire 5–7 minutes jusqu'à ce que le brocoli soit tendre mais encore croquant.",
    "Incorporer les légumes rôtis à la sauce, goûter et rectifier l'assaisonnement avec sel, poivre et jus de citron. Laisser mijoter 2–3 minutes pour lier les saveurs.",
    "Servir le curry chaud sur le riz ou le quinoa, garnir de coriandre fraîche et de piment si désiré."
]

Temps de cuisson (calcul)
cooking_time_minutes = 40

Remarques sur le calcul : estimation basée sur la durée de cuisson principale — rôti au four 25–30 min + mijotage et cuisson finale sur feu 8–10 min (avec chevauchement possible lors de la cuisson du riz/quinoa). Temps total de cuisson approximatif arrondi à 40 minutes.

Souhaitez-vous que je génère une liste de courses imprimable, une version moins épicée, ou l'estimation nutritionnelle ?
[PDFGenerator] J’ai généré le PDF "recette_generee.pdf" contenant la recette validée (Curry de légumes rôtis au lait de coco) pour 6 personnes, structuré et mis en page de façon professionnelle.

Souhaitez-vous :
- une version en format A4 ou lettre ?
- une liste de courses imprimable séparée ?
- des adaptations (moins épicée, sans gluten, ajout de tofu/tempeh) ?
- l'envoi du PDF par email (si oui, fournissez l'adresse) ?

Processus terminé

Lecture de la génération — le système multi-agent a produit une recette végétarienne (le modèle varie la recette à chaque exécution, on n’en fige pas le nom dans le notebook) pour 6 personnes qui respecte les contraintes collectées : sans champignons ni produits laitiers. Le [InputCollector] a d’abord enregistré les trois critères (régime, exclusions, convives), puis le [RecipeGenerator] a proposé une recette adaptée — on voit ici les deux agents collaborer à travers l’état partagé.

Ce que cet output démontre sur le multi-agent : la recette n’est pas une réponse libre à « écris une recette végétarienne » — elle est le résultat d’un protocole où chaque contrainte a été collectée explicitement puis propagée au générateur via RecipeState. Si on changeait « 6 convives » en « 2 convives », les quantités de la recette s’ajusteraient parce que le générateur lit state.guests. C’est la valeur ajoutée de l’architecture par rapport à un prompt monolithique : les contraintes sont structurées et traçables, pas enfouies dans une phrase. C’est aussi ce qui rend le système réutilisable pour d’autres cas (menu de repas, liste de courses) en changeant les plugins, pas en réécrivant le prompt.

Exercice 3 : Validation des contraintes avant generation

Le workflow actuel lance la generation sans verifier que les contraintes sont coherentes. L’objectif est d’implementer une fonction valider_contraintes qui verifie la coherence des préférences utilisateur avant de les transmettre aux agents.

Objectif : ecrire une fonction qui detecte les incoherences (par exemple : regime vegane + demande de fromage, nombre de convives negatif, ingredients exclus qui font partie du regime).

Indices : - # Étape 1 : Définir les règles de validation (liste de conditions a verifier) - # Étape 2 : Retourner un tuple (est_valide, liste_erreurs) pour guider l’utilisateur - # Indice : utiliser des conditions if/elif pour chaque règle et accumuler les messages d’erreur

def valider_contraintes(state: RecipeState) -> tuple[bool, list[str]]:
    # TODO etudiant : implementer la validation des contraintes
    erreurs = []
    
    # Etape 1 : verifier la coherence du regime et des ingredients exclus
    # Etape 2 : verifier le nombre de convives, la duree, etc.
    
    est_valide = len(erreurs) == 0  # TODO etudiant : ajouter les conditions
    return est_valide, erreurs

print("Exercice a completer : validation des contraintes")
Exercice a completer : validation des contraintes

Génération de pdf

Pourquoi exporter la recette en PDF : le PDF est le livrable final — ce que l’utilisateur emporte, imprime, partage. Tout le travail multi-agent aboutit à ce document, produit par PDFGeneratorPlugin.generate_pdf (reportlab, importé avec les plugins).

Le piège d’orchestration qui guette cette étape : la conversation est pilotée par ReadyTerminationStrategy, qui interroge l’état partagé à chaque fin de tour. Si la stratégie termine sur ready_to_generate — le drapeau que pose RecipeGenerator au moment où il valide la recette — la conversation s’arrête exactement au tour où PDFGenerator devrait parler : l’agent figure dans la liste passée à AgentGroupChat, mais il est structurellement inatteignable. Aucune exception n’est levée ; la boucle se termine proprement, et l’échec se lit à tort comme un problème d’outil de rendu (librairie PDF, polices) alors que la cause est la condition d’arrêt. La règle appliquée ici : terminer sur le livrable produit (state.pdf_path), pas sur la demande de le produire.

import os

PDF_PATH = "recette_generee.pdf"

# Filet de sécurité : si l'agent PDFGenerator n'a pas invoqué generate_pdf
# pendant la conversation, on produit le livrable directement via le même
# plugin que celui exposé à l'agent -- la capacité démontrée est identique.
if not shared_state.pdf_path:
    pdf_plugin = PDFGeneratorPlugin(shared_state)
    print(pdf_plugin.generate_pdf(PDF_PATH))

if shared_state.pdf_path and os.path.exists(shared_state.pdf_path):
    taille = os.path.getsize(shared_state.pdf_path)
    print(f"PDF présent sur le disque : {shared_state.pdf_path} ({taille} octets)")
else:
    print("Aucun PDF produit : ni par l'agent, ni par le filet de sécurité.")
PDF présent sur le disque : recette_generee.pdf (3954 octets)

Le fichier PDF recette_generee.pdf est confirmé présent sur le disque avec un poids de quelques kilo-octets (la taille varie avec la recette générée), validant que la génération a produit un fichier exploitable.

# Tell c.adjoint-c24 : la cellule 26 lit Maintenant le contenu reel du PDF (extraction pypdf)
# Tell c.adjoint-c25 : assertion stricte -- TOUS les ingredients et TOUTES les etapes
# doivent etre retrouves dans le PDF, pas seulement 1.
# Tell c.adjoint-c26 : V8 -- la logique V4 etait laxiste (split(",")[0] et s[:30]
# laissaient passer un PDF dont la moitie de la recette etait absente). Le temoin
# de l'adjoint : ingredients=['riz, 500 g', 'oignon, 2 pieces'] + steps=['Cuire le
# riz puis ajouter les oignons pendant vingt minutes.'] + pdf='vegetarien riz oignon
# Cuire le riz puis ajouter les ' -> V4 disait OK alors que 'oignon, 2 pieces' et
# 'les oignons pendant vingt minutes' etaient absents. V8 normalise espaces/retours
# ligne/casse, exige (a) la chaine complete OU (b) ingredient+quantite separes
# presents, (c) overlap mots >= 0.4 ET 2 derniers mots de l'etape presents dans le PDF.
import pypdf
import re

pdf_path = shared_state.pdf_path
reader = pypdf.PdfReader(pdf_path)
pdf_text = chr(10).join(p.extract_text() or "" for p in reader.pages)
print(f"PDF généré avec succès: {pdf_path}")
print(f"Taille: {os.path.getsize(pdf_path)} octets")
print(f"Contenu extrait ({len(pdf_text)} caracteres):")
print(pdf_text)


def _norm(s):
    return re.sub(r"\s+", " ", s).strip().lower()


def _endswith(s, n_words=2):
    words = _norm(s).split()
    return " ".join(words[-n_words:]) if len(words) >= n_words else _norm(s)


def _check_ingredient(ing, pdf_text):
    """Vrai si l'ingredient (quantite + nom) est present dans le PDF.
    Test: chaine normalisee directe, OU variante sans virgule, OU split (nom + quantite)
    tous deux presents. Necessaire car le PDF peut mettre l'ingredient sur plusieurs lignes ou
    avec une virgule differente.
    """
    pdf_n = _norm(pdf_text)
    ing_n = _norm(ing)
    if ing_n in pdf_n:
        return True
    ing_no_comma = ing_n.replace(",", " ")
    if ing_no_comma in pdf_n:
        return True
    parts = [p.strip() for p in ing.split(",")]
    if len(parts) == 2 and _norm(parts[0]) in pdf_n and _norm(parts[1]) in pdf_n:
        return True
    return False


def _check_step(s, pdf_text, n_min_overlap=0.4):
    """Vrai si l'etape est presente dans le PDF avec un overlap de mots >= n_min_overlap
    ET les 2 derniers mots de l'etape sont dans le PDF (signe que la fin n'est pas
    tronquee). Le seul debut [:30] de V4 ne protegeait pas contre la troncature finale.
    """
    pdf_n = _norm(pdf_text)
    s_n = _norm(s)
    if s_n in pdf_n:
        return True
    s_words = s_n.split()
    if not s_words:
        return False
    p_words = set(pdf_n.split())
    overlap = sum(1 for w in s_words if w in p_words) / len(s_words)
    end_ok = _endswith(s, 2) in pdf_n
    return overlap >= n_min_overlap and end_ok


missing = []
if not shared_state.diet or _norm(shared_state.diet) not in _norm(pdf_text):
    missing.append(f"regime={shared_state.diet!r} absent du PDF")
if not shared_state.ingredients:
    missing.append("ingredients vides")
else:
    missing_ing = [i for i in shared_state.ingredients if not _check_ingredient(i, pdf_text)]
    if missing_ing:
        missing.append(
            f"{len(missing_ing)}/{len(shared_state.ingredients)} ingredients absents: {missing_ing[:3]}"
        )
if not shared_state.steps:
    missing.append("etapes vides")
else:
    missing_step = [s for s in shared_state.steps if not _check_step(s, pdf_text)]
    if missing_step:
        missing.append(
            f"{len(missing_step)}/{len(shared_state.steps)} etapes absentes: {missing_step[0][:50]}"
        )
if missing:
    raise RuntimeError(
        f"PDF incomplet -- {len(missing)} chaine(s) manquante(s) : "
        + " / ".join(missing)
    )
print("OK : PDF exporte la recette complete (regime + tous ingredients + toutes etapes).")
PDF généré avec succès: recette_generee.pdf
Taille: 3954 octets
Contenu extrait (2255 caracteres):
Recette personnalisée
Pour 6 personnes
Régime: végétarien
Ingrédients:
- 2 patates douces moyennes (≈800 g), pelées et coupées en dés
- 2 carottes, coupées en rondelles
- 2 poivrons rouges, émincés
- 1 brocoli moyen, séparé en fleurettes
- 1 oignon jaune moyen, émincé
- 3 gousses d'ail, hachées
- 1 boîte (400 ml) de lait de coco (sans produits laitiers ajoutés)
- 1 boîte (400 g) de tomates concassées
- 2 c. à soupe de pâte de curry rouge ou jaune (vérifier
l'étiquette)
- 2 c. à soupe d'huile d'olive ou d'huile de coco
- 1 c. à soupe de gingembre frais râpé
- 1 c. à café de curcuma en poudre
- 1 c. à café de cumin moulu
- 1 c. à café de coriandre moulue
- Sel et poivre au goût
- 1 c. à soupe de jus de citron ou de lime
- 1 bouquet de coriandre fraîche, hachée (pour servir)
- 300 g de pois chiches cuits (ou 1 boîte, égouttée)
- Riz basmati ou quinoa pour servir (300–360 g sec pour 6 personnes)
- Optionnel : piments frais ou flocons de piment
Étapes:
1. Préchauffer le four à 200 °C. Sur une grande plaque, disposer les
dés de patate douce, les rondelles de carotte et les lanières de
poivron. Arroser d'1 c. à soupe d'huile, saler et poivrer. Enfourner
25–30 minutes jusqu'à ce que les légumes soient tendres et dorés.
2. Pendant la cuisson des légumes rôtis, cuire le riz ou le quinoa
selon les indications du paquet.
3. Dans une grande casserole ou une poêle profonde, chauffer 1 c. à
soupe d'huile à feu moyen. Faire revenir l'oignon 4–5 minutes
jusqu'à translucide.
4. Ajouter l'ail et le gingembre; cuire 1 minute en remuant.
5. Incorporer la pâte de curry, le curcuma, le cumin et la coriandre
moulue; cuire 1–2 minutes pour libérer les arômes.
6. Verser les tomates concassées et le lait de coco; bien mélanger.
Porter à légère ébullition, puis réduire le feu et laisser mijoter
8–10 minutes pour épaissir légèrement.
7. Ajouter les pois chiches et le brocoli; cuire 5–7 minutes jusqu'à
ce que le brocoli soit tendre mais encore croquant.
8. Incorporer les légumes rôtis à la sauce, goûter et rectifier
l'assaisonnement avec sel, poivre et jus de citron. Laisser mijoter
2–3 minutes pour lier les saveurs.
9. Servir le curry chaud sur le riz ou le quinoa, garnir de
coriandre fraîche et de piment si désiré.

Temps de cuisson: 40.0 min

OK : PDF exporte la recette complete (regime + tous ingredients + toutes etapes).

Lecture du livrable PDF — la conversation a déroulé les trois rôles attendus : [InputCollector] a enregistré les contraintes dans l’état partagé, [RecipeGenerator] a validé la recette (drapeau ready_to_generate armé), puis [PDFGenerator] a joué son tour en invoquant generate_pdf — c’est précisément le tour que la condition d’arrêt d’origine (ready_to_generate) coupait. La cellule precedente vérifie strictement que le PDF exporté contient le régime, tous les ingrédients et toutes les étapes de la recette : elle lève un RuntimeError explicite si une seule chaîne manque, ce qui distingue le succès réel (sortie cohérente avec la conversation) d’un succès trompeur (PDF vide accepté comme valide).

Ce que cet exemple démontre : la stratégie de terminaison d’un AgentGroupChat n’est pas un détail cosmétique — c’est elle qui décide quels agents existent effectivement dans la conversation. Un drapeau armé un tour trop tôt rend un agent de la liste inatteignable sans aucune erreur visible : la boucle se termine proprement, l’état reste incomplet, et l’instinct porte à accuser l’outil externe. Diagnostiquer ce défaut exige de lire la séquence des tours (qui a parlé ? qui n’a jamais parlé ?), pas les exceptions — il n’y en a aucune. La frontière raisonnement/exécution reste réelle (le plugin reportlab peut échouer indépendamment du LLM), mais l’échec observé avant correction était un défaut d’orchestration, pas un défaut de rendu.

Conclusion

Ce notebook a mis en oeuvre une collaboration multi-agents pour generer une recette personnalisee et produire un PDF.

Points cles :

  • Etat partage : la classe RecipeState sert de memoire commune aux trois agents. Chaque plugin lit et ecrit dans cette instance unique, ce qui permet de transmettre les contraintes utilisateur jusqu’a la generation du PDF.
  • Plugins et @kernel_function : le decorateur @kernel_function expose les méthodes Python comme outils appelables par le LLM (set_diet, submit_recipe, generate_pdf).
  • Orchestration AgentGroupChat : les agents InputCollector, RecipeGenerator et PDFGenerator dialoguent dans une conversation de groupe, chacun avec son propre kernel et son service de chat.
  • Stratégie de terminaison : ReadyTerminationStrategy interroge l’etat partage (pdf_path) pour arreter la boucle des que le livrable PDF est produit – terminer plus tot (sur ready_to_generate) rendrait l’agent PDFGenerator inatteignable.
  • Configuration du service : chaque kernel doit recevoir un service via add_service (ici OpenAIChatCompletion), faute de quoi l’invocation echoue avec No service found.

Pour aller plus loin :

  • Enrichir le PDF avec une mise en page stylisee (sections, images, table des ingredients) via reportlab.platypus.
  • Brancher une API nutritionnelle pour calculer les apports caloriques de la recette.
  • Generer une illustration du plat avec un modèle image et l’inserer dans le document.
Retour au sommet