SK-2-Functions : Function Calling, Memory et Fonctionnalites Avancees

Navigation : Index | << 01-Intro | 03-Agents >>


Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Utiliser Function Calling avec FunctionChoiceBehavior pour orchestrer automatiquement des fonctions 2. Configurer la memoire vectorielle avec l’API moderne (InMemoryStore, embeddings) 3. Integrer des modèles Hugging Face comme alternative a OpenAI 4. Implementer un Groundedness Checking pour reduire les hallucinations 5. Generer plusieurs reponses en un seul appel API

Prerequis

Duree estimee : 50 minutes


Sommaire

Section Contenu Concepts cles
1 Installation et configuration Kernel, services LLM
2 Chat avec KernelArguments Templates, historique
3 Function Calling moderne FunctionChoiceBehavior.Auto()
4 Memoire et Embeddings InMemoryStore, Vector Search
5 Hugging Face Integration Modèles open-source
6 Groundedness Checking Anti-hallucination
7 Multi-Result Generations multiples

Note importante : Les anciens SequentialPlanner et FunctionCallingStepwisePlanner sont deprecies depuis SK 1.30. Ce notebook utilise l’approche moderne avec FunctionChoiceBehavior.

# ============================
# Cellule : Installation & Imports
# ============================

# N'installez qu'une seule fois si nécessaire

# Imports de base
import os
import sys
from dotenv import load_dotenv

from semantic_kernel import Kernel
from semantic_kernel.functions import KernelArguments
from semantic_kernel.contents import ChatHistory

print("Imports et installation OK.")
Imports et installation OK.

1. Installation et configuration initiale

Cette première cellule effectue deux opérations essentielles : 1. Installation de semantic-kernel : Le SDK Python pour orchestrer des modèles de langage 2. Imports de base : Les modules fondamentaux pour interagir avec le kernel et gerer l’environnement

Le fichier .env doit contenir vos cles API (voir la section suivante pour le format attendu).

Bloc Markdown – Configuration du Kernel & .env

### Configuration du Kernel

Pour exécuter les exemples, on suppose que vous avez un fichier `.env` comportant vos clés d'API OpenAI / Azure OpenAI / Hugging Face.  
Exemple `.env` :

GLOBAL_LLM_SERVICE=“OpenAI” # ou AzureOpenAI, HuggingFace OPENAI_API_KEY=“sk-…” OPENAI_CHAT_MODEL_ID=“gpt-5-mini” …


Le `Kernel` lira ces informations pour décider quel connecteur LLM utiliser.  
Ensuite, nous ajouterons nos services (`OpenAIChatCompletion`, `AzureChatCompletion`, etc.) selon la variable `GLOBAL_LLM_SERVICE`.

## 2. Initialisation du Kernel avec service LLM dynamique

Cette cellule configure le Kernel en fonction du service LLM specifie dans le fichier `.env`. Le code supporte trois backends :
- **OpenAI** : API officielle d'OpenAI (GPT-4, GPT-3.5, etc.)
- **Azure OpenAI** : Deploiement Azure avec endpoint personnalise
- **Hugging Face** : Modèles open-source en local ou via le Hub

Le pattern utilise ici est courant : on lit la variable `GLOBAL_LLM_SERVICE` pour determiner dynamiquement quel connecteur instancier.

::: {#63d7d73b .cell quarto-private-1='{"key":"papermill","value":{"duration":1.317252,"end_time":"2026-08-17T04:09:42.105759","exception":false,"start_time":"2026-08-17T04:09:40.788507","status":"completed"}}' tags='[]' execution_count=2}
``` {.python .cell-code}
# ============================
# Cellule : Initialisation du Kernel
# ============================

from semantic_kernel.connectors.ai.open_ai import (
    OpenAIChatCompletion,
    AzureChatCompletion
)

# On charge le .env
load_dotenv()
global_llm_service = os.getenv("GLOBAL_LLM_SERVICE", "AzureOpenAI")

# Initialisation du Kernel
kernel = Kernel()

service_id = "default"

if global_llm_service.lower() == "openai":
    # Ajout du service OpenAI
    kernel.add_service(
        OpenAIChatCompletion(service_id=service_id),
    )
    print("Service OpenAI configuré.")
elif global_llm_service.lower() == "huggingface":
    # Ajout du service HuggingFace
    from semantic_kernel.connectors.ai.hugging_face import HuggingFaceTextCompletion
    kernel.add_service(
        HuggingFaceTextCompletion(
            service_id=service_id,
            ai_model_id="distilgpt2", # ex. pour text-generation
            task="text-generation"
        ),
    )
    print("Service Hugging Face configuré.")
else:
    # Par défaut : Azure OpenAI
    kernel.add_service(
        AzureChatCompletion(service_id=service_id),
    )
    print("Service Azure OpenAI configuré.")
Service OpenAI configuré.

:::

Choix du service LLM

Le code ci-dessus illustre un pattern important : la configuration dynamique du service LLM.

Selon la valeur de GLOBAL_LLM_SERVICE dans votre fichier .env, le Kernel se connectera a : - OpenAI : API officielle avec OpenAIChatCompletion - Azure OpenAI : Deploiement Azure avec AzureChatCompletion - Hugging Face : Modèles open-source avec HuggingFaceTextCompletion

Cette flexibilite permet de switcher facilement entre fournisseurs sans modifier le code applicatif.

Chat Basique avec KernelArguments

L’idée : on crée une fonction de chat qui prend : - l’historique de conversation (objet ChatHistory) - un user_input et on stocke le tout dans un KernelArguments.

Cela permet d’alimenter un prompt (via un template) qui contient la variable history et user_input.

# ============================
# Cellule : Extrait d'un Chat Minimal
# ============================

# Exemple de prompt
chat_prompt = """
{{$history}}
User: {{$user_input}}
ChatBot:
"""

# Création d'une fonction sémantique "chat"
from semantic_kernel.prompt_template import PromptTemplateConfig
from semantic_kernel.prompt_template.input_variable import InputVariable

pt_config = PromptTemplateConfig(
    template=chat_prompt,
    name="chatFunction",
    template_format="semantic-kernel",
    input_variables=[
        InputVariable(name="user_input", description="User's message"),
        InputVariable(name="history", description="Conversation history"),
    ]
)

chat_function = kernel.add_function(
    function_name="chat",
    plugin_name="myChatPlugin",
    prompt_template_config=pt_config
)

chat_history = ChatHistory()
chat_history.add_system_message("Vous êtes un chatbot utile spécialisé en recommandations de livres.")

async def chat_kernel(input_text: str):
    print(f"[Utilisateur] : {input_text}")
    response = await kernel.invoke(
        chat_function,
        KernelArguments(user_input=input_text, history=str(chat_history))
    )
    print(f"[ChatBot] : {response}")
    # Mise à jour de l'historique
    chat_history.add_user_message(input_text)
    chat_history.add_assistant_message(str(response))


# Test
await chat_kernel("Salut, peux-tu me conseiller un livre sur la philosophie antique ?")
await chat_kernel("Merci, tu peux détailler un peu plus la période concernée ?")
[Utilisateur] : Salut, peux-tu me conseiller un livre sur la philosophie antique ?
[ChatBot] : Voici quelques excellentes recommandations sur la **philosophie antique**, selon ce que tu cherches :

### 1) Pour une introduction claire et accessible  
**Pierre Hadot — *Qu’est-ce que la philosophie antique ?***  
Un classique : Hadot explique très bien la philosophie antique comme une **manière de vivre** (exercices spirituels, pratiques, écoles).

### 2) Pour un panorama complet (plus “manuel”)  
**Giovanni Reale — *Histoire de la philosophie antique***  
Très riche et structuré, idéal si tu veux une vue d’ensemble détaillée des penseurs et des courants.

### 3) Pour entrer directement par les textes (Stoïcisme, Épicurisme)  
- **Épictète — *Manuel* (ou *Entretiens*, sélection)** : court, percutant, très pratique.  
- **Épicure — *Lettre à Ménécée*** : une porte d’entrée simple et fondamentale.

### 4) Pour Platon (lecture guidée)  
**Platon — *Apologie de Socrate***  
Un texte bref et passionnant pour comprendre Socrate, la méthode philosophique et la question du juste.

Si tu me dis ce que tu préfères (plutôt **stoïcisme**, **Platon/Aristote**, ou une **introduction générale**), et ton niveau (débutant/à l’aise), je peux te proposer **1 ou 2 titres parfaitement adaptés**.
[Utilisateur] : Merci, tu peux détailler un peu plus la période concernée ?
[ChatBot] : La **philosophie antique** recouvre en général une période très large, depuis les premiers penseurs grecs jusqu’aux derniers philosophes de l’Antiquité tardive. Selon les auteurs, les bornes changent un peu, mais voici le découpage le plus courant (avec les idées dominantes et quelques noms repères).

## 1) Les débuts : les « présocratiques » (VIe–Ve siècle av. J.-C.)
**Période :** environ de **Thalès** (vers -600) à la génération de **Socrate** (mort en -399).  
**Ce qui domine :** naissance d’une explication rationnelle du monde (*phusis*), recherche de principes (l’« archè »), cosmologie, mathématisation.  
**Auteurs/courants :** Thalès, Anaximandre, Héraclite, Parménide, Empédocle, Anaxagore, Démocrite.  
**Pourquoi c’est important :** ils posent les questions de base (être/devenir, matière, causes) qui nourriront Platon et Aristote.

## 2) La période « classique » (Ve–IVe siècle av. J.-C.)
**Période :** grosso modo **Socrate, Platon, Aristote**.  
**Ce qui domine :** bascule vers l’éthique, la politique, la connaissance, la définition des concepts (justice, vertu, vérité), et construction de systèmes.  
**Repères :**
- **Socrate** : examen de soi, dialogue, vertu et connaissance.  
- **Platon** : théorie des Formes/Idées, politique (la cité juste), âme, dialectique.  
- **Aristote** : logique, métaphysique, éthique des vertus, science des causes.

## 3) L’époque hellénistique (fin IVe–Ier siècle av. J.-C.)
**Période :** après Alexandre le Grand (à partir de ~-323).  
**Ce qui domine :** la philosophie comme **art de vivre** : comment atteindre l’ataraxie (tranquillité), la liberté intérieure, la sagesse dans un monde instable.  
**Écoles majeures :**
- **Stoïcisme** (Zénon, Chrysippe) : vivre selon la raison/nature, vertu, maîtrise des jugements.  
- **Épicurisme** (Épicure, Lucrèce) : plaisir compris comme absence de trouble, amitié, critique des peurs (dieux/mort).  
- **Scepticisme** (Pyrrhon, Sextus Empiricus) : suspension du jugement pour la tranquillité.  
- **Cynisme** (Diogène) : vie simple, critique des conventions.

## 4) La période romaine / impériale (Ier siècle av. J.-C. – IIe/IIIe siècle ap. J.-C.)
**Ce qui domine :** transmission et adaptation (souvent en latin) ; développement d’une éthique pratique et morale.  
**Auteurs repères :**
- **Cicéron** (éclectisme, diffusion de la philosophie grecque à Rome)  
- **Sénèque**, **Épictète**, **Marc Aurèle** (stoïcisme)  
- Continuité épicurienne (par ex. **Lucrèce** un peu plus tôt)

## 5) L’Antiquité tardive et le néoplatonisme (IIIe–VIe siècle ap. J.-C.)
**Période :** environ de **Plotin** (IIIe s.) jusqu’à la fermeture de l’Académie d’Athènes (529).  
**Ce qui domine :** métaphysique et théologie philosophique (l’Un, l’intellect, l’âme), commentaires de Platon/Aristote, dialogue avec le christianisme naissant.  
**Auteurs :** Plotin, Porphyre, Jamblique, Proclus (et, côté chrétien influencé par le platonisme : Augustin, etc.).

---

### Où se situe Pierre Hadot dans ce découpage ?
Hadot parle de **toute l’Antiquité**, mais il met particulièrement l’accent sur la période **hellénistique et romaine**, où la philosophie se comprend très bien comme **pratique de vie** (stoïciens, épicuriens, etc.).

Si tu me dis si tu veux plutôt la période **Socrate/Platon/Aristote** (classique) ou plutôt **stoïciens/épicuriens** (hellénistique–romaine), je peux te recommander un livre qui couvre précisément la tranche qui t’intéresse.

Exercice 1 : Chatbot avec gestion de contexte avancee

Objectif : Implementer un chatbot qui limite automatiquement la taille de l’historique de conversation pour eviter de depasser la fenêtre de contexte du LLM.

Le chat basique avec ChatHistory accumule tous les messages indefiniment. En production, il faut tronquer l’historique quand il devient trop long, tout en preservant le message système.

Indices : - # Étape 1 : Créer une fonction trim_history(history, max_messages) qui garde le message système + N derniers echanges - # Étape 2 : Compter les messages et verifier si len(history.messages) > max_messages - # Étape 3 : Reconstruire un nouvel historique avec le system message + les messages recents - # Indice : Le message système est toujours history.messages[0] (ou filtrer par rôle AuthorRole.SYSTEM)

from semantic_kernel.contents import ChatHistory

def trim_history(history: ChatHistory, max_messages: int = 10) -> ChatHistory:
    """
    Tronque l'historique pour garder le message systeme + les N derniers messages.
    
    # Etape 1 : Verifier si l'historique depasse max_messages
    # Etape 2 : Separer le message systeme (premier message) des messages de conversation
    # Etape 3 : Reconstruire un nouvel historique avec system + derniers messages
    
    # Indice : ChatHistory() cree un nouvel historique vide
    # Indice : history.add_message(msg) ajoute un message existant au nouvel historique
    
    Args:
        history: L'historique de conversation original
        max_messages: Nombre maximum de messages a conserver (hors system)
    
    Returns:
        ChatHistory tronque si necessaire, sinon l'original
    """
    # TODO etudiant : implementer la troncature d'historique
    return history  # TODO etudiant : retourner l'historique tronque

# Test avec un historique long
test_history = ChatHistory()
test_history.add_system_message("Tu es un assistant utile.")
for i in range(15):
    test_history.add_user_message(f"Question {i+1}")
    test_history.add_assistant_message(f"Reponse {i+1}")

print(f"Avant trim : {len(test_history.messages)} messages")
trimmed = trim_history(test_history, max_messages=6)
print(f"Apres trim : {len(trimmed.messages)} messages")
print(f"Premier message conserve (system) : {trimmed.messages[0].content[:30]}...")
print(f"Dernier message : {trimmed.messages[-1].content}")
Avant trim : 31 messages
Apres trim : 31 messages
Premier message conserve (system) : Tu es un assistant utile....
Dernier message : Reponse 15

Interprétation : Chat Conversationnel avec Historique

Sortie obtenue : Un chatbot conversationnel qui maintient le contexte entre les échanges.

Échange Utilisateur Assistant Contexte maintenu
1 “Peux-tu me conseiller un livre sur la philosophie antique ?” Demande une précision (intro moderne / texte ancien, langue ?) puis propose une sélection (Hadot, Platon, Aristote, Stoïciens, Épicuriens) Message système (spécialisation livres)
2 “Merci, tu peux détailler un peu plus la période concernée ?” Détaille les 5 phases de la philosophie antique (Présocratiques → Néoplatonisme / Antiquité tardive) Se rappelle du sujet (philosophie antique)

Points clés :

  1. Architecture du chat avec historique :

    ChatHistory → KernelArguments → PromptTemplate → LLM → Update ChatHistory
         ↓              ↓                 ↓            ↓              ↓
    Contexte      Variables        Prompt final   Réponse    Nouveau contexte
  2. Composants essentiels :

    Composant Rôle Exemple
    ChatHistory Stocke tous les messages (système, utilisateur, assistant) Conserve le contexte
    PromptTemplateConfig Définit le template avec variables {$history}, {$user_input} Structure du prompt
    KernelArguments Passe les valeurs dynamiques au template user_input, history
    chat_history.add_*_message() Met à jour l’historique après chaque échange Persistence mémoire
  3. Gestion du message système :

    chat_history.add_system_message("Vous êtes un chatbot utile spécialisé en recommandations de livres.")
    • Définit le comportement et la personnalité du bot
    • Reste présent dans tout l’historique
    • Peut spécifier le format de réponse, le ton, les contraintes
  4. Exemple de prompt généré (2ème échange) :

    [SYSTEM] Vous êtes un chatbot utile spécialisé en recommandations de livres.
    [USER] Salut, peux-tu me conseiller un livre sur la philosophie antique ?
    [ASSISTANT] Propose une sélection (Hadot, Platon, Aristote, Stoïciens...) en demandant une précision
    [USER] Merci, tu peux détailler un peu plus la période concernée ?
    [CHATBOT] <à générer>

Limitations et améliorations :

  1. Limite de contexte :

    • Le ChatHistory peut devenir très long et dépasser le context window du LLM
    • Solutions :
      • Tronquer l’historique (garder N derniers messages)
      • Résumer l’historique (summarization)
      • Mémoire vectorielle (stocker l’historique dans un vector store)
  2. Code amélioré avec gestion de contexte :

    MAX_HISTORY_LENGTH = 10  # Garder les 10 derniers échanges
    
    if len(chat_history.messages) > MAX_HISTORY_LENGTH:
        # Garder le message système + N derniers messages
        system_msg = chat_history.messages[0]
        recent_msgs = chat_history.messages[-(MAX_HISTORY_LENGTH-1):]
        chat_history = ChatHistory()
        chat_history.add_message(system_msg)
        for msg in recent_msgs:
            chat_history.add_message(msg)

Comparaison avec d’autres patterns :

Pattern Mémoire Use case
Stateless (prompt fixe) Aucune FAQ simple
ChatHistory (ce notebook) Court terme (session) Chatbot conversationnel
Vector Memory (notebook 05) Long terme (permanent) RAG, knowledge base
Agents (notebook 03) Multi-agents avec état partagé Workflows complexes

Cas d’usage typiques : - Support client : Résoudre un problème en plusieurs étapes - Assistant personnel : Planification, organisation avec continuité - Tuteur éducatif : Adaptation progressive au niveau de l’apprenant

3. Function Calling Moderne

Evolution des Planners vers Function Calling

Les Planners (SequentialPlanner, FunctionCallingStepwisePlanner) ont ete deprecies dans Semantic Kernel 1.30+. Microsoft recommande maintenant d’utiliser :

Ancienne approche Nouvelle approche Avantages
SequentialPlanner FunctionChoiceBehavior.Auto() Moins de tokens, plus fiable
FunctionCallingStepwisePlanner Agent avec plugins Pattern ReAct natif
Planification XML Function calling OpenAI Standard API

Qu’est-ce que Function Calling ?

Function Calling permet au LLM de : 1. Analyser la requête utilisateur 2. Decider automatiquement quelle(s) fonction(s) appeler 3. Extraire les paramètres depuis le contexte 4. Retourner le résultat au LLM pour formulation finale

C’est le pattern ReAct (Reasoning + Acting) integre directement dans l’API OpenAI.

Configuration de FunctionChoiceBehavior

Semantic Kernel propose plusieurs modes de Function Calling :

from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior

# Mode Auto : le LLM decide quand appeler les fonctions
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()

# Mode Required : force l'appel d'au moins une fonction
settings.function_choice_behavior = FunctionChoiceBehavior.Required()

# Mode None : desactive le function calling
settings.function_choice_behavior = FunctionChoiceBehavior.NoneInvoke()

Paramètres avances

Paramètre Description Valeur par defaut
auto_invoke Executer automatiquement les fonctions True
filters Filtrer les fonctions disponibles None
maximum_auto_invoke_attempts Limite d’appels 5

L’exemple suivant montre comment configurer un kernel avec plusieurs plugins et laisser le LLM orchestrer automatiquement.

API de memoire vectorielle mise a jour

Note de version : L’API de memoire a evolue dans Semantic Kernel. Les anciennes classes comme VolatileMemoryStore et SemanticTextMemory sont remplacees par : - InMemoryStore : Stockage en memoire volatile - Services d’embedding dedies avec OpenAITextEmbedding

Cette cellule montre le pattern moderne pour configurer la memoire sémantique.

# ============================
# Function Calling Moderne avec FunctionChoiceBehavior
# ============================

from semantic_kernel.core_plugins.text_plugin import TextPlugin
from semantic_kernel.core_plugins.math_plugin import MathPlugin
from semantic_kernel.functions import KernelFunctionFromPrompt, kernel_function
from semantic_kernel.connectors.ai.open_ai import OpenAIChatPromptExecutionSettings
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior

# Charger des plugins avec des fonctions utiles
plugins_directory = "./prompt_template_samples/"
writer_plugin = kernel.add_plugin(plugin_name="WriterPlugin", parent_directory=plugins_directory)
text_plugin = kernel.add_plugin(plugin=TextPlugin(), plugin_name="TextPlugin")
math_plugin = kernel.add_plugin(plugin=MathPlugin(), plugin_name="MathPlugin")

# Creer une fonction semantique pour la poesie
shakespeare_func = KernelFunctionFromPrompt(
    function_name="Shakespeare",
    plugin_name="WriterPlugin",
    prompt="""
{{$input}}

Rewrite the above in the style of Shakespeare.
""",
    prompt_execution_settings=OpenAIChatPromptExecutionSettings(
        service_id=service_id,
        # temperature not set: gpt-5-mini only supports default value (1)
    ),
    description="Rewrite the input in the style of Shakespeare.",
)
kernel.add_function(plugin_name="WriterPlugin", function=shakespeare_func)

# Lister les fonctions disponibles
print("Fonctions disponibles pour le LLM :")
print("-" * 50)
for plugin_name, plugin in kernel.plugins.items():
    for function_name, function in plugin.functions.items():
        desc = function.description[:50] + "..." if function.description and len(function.description) > 50 else function.description
        print(f"  {plugin_name}.{function_name}: {desc}")

# ============================
# Exemple : Function Calling Auto
# ============================
print("\n" + "=" * 50)
print("DEMO : Function Calling avec FunctionChoiceBehavior.Auto()")
print("=" * 50)

# Configurer les settings avec function calling auto
execution_settings = OpenAIChatPromptExecutionSettings(
    service_id=service_id,
    # temperature not set: gpt-5-mini only supports default value (1)
    function_choice_behavior=FunctionChoiceBehavior.Auto(
        auto_invoke=True,  # Execute automatiquement les fonctions
        filters={"included_plugins": ["TextPlugin", "MathPlugin"]}  # Limiter aux plugins
    )
)

# Prompt qui necessite l'appel de fonctions
user_request = """
J'ai besoin d'aide pour deux choses :
1. Convertir le texte "hello world" en majuscules
2. Calculer 25 + 17
"""

print(f"\nRequete utilisateur : {user_request}")

# Creer une fonction de chat avec function calling
from semantic_kernel.contents import ChatHistory

chat_history = ChatHistory()
chat_history.add_system_message("Tu es un assistant qui utilise les outils disponibles pour aider l'utilisateur.")
chat_history.add_user_message(user_request)

# Invoquer avec function calling
chat_service = kernel.get_service(service_id)
result = await chat_service.get_chat_message_content(
    chat_history=chat_history,
    settings=execution_settings,
    kernel=kernel
)

print(f"\nReponse du LLM (avec function calling) :")
print(result.content)
Fonctions disponibles pour le LLM :
--------------------------------------------------
  myChatPlugin.chat: 
  WriterPlugin.Acronym: Generate an acronym for the given concept or phras...
  WriterPlugin.AcronymGenerator: Given a request to generate an acronym from a stri...
  WriterPlugin.AcronymReverse: Given a single word or acronym, generate the expan...
  WriterPlugin.Brainstorm: Given a goal or topic description generate a list ...
  WriterPlugin.EmailGen: Write an email from the given bullet points
  WriterPlugin.EmailTo: Turn bullet points into an email to someone, using...
  WriterPlugin.EnglishImprover: Translate text to English and improve it
  WriterPlugin.NovelChapter: Write a chapter of a novel.
  WriterPlugin.NovelChapterWithNotes: Write a chapter of a novel using notes about the c...
  WriterPlugin.NovelOutline: Generate a list of chapter synopsis for a novel or...
  WriterPlugin.Rewrite: Automatically generate compact notes for any text ...
  WriterPlugin.ShortPoem: Turn a scenario into a short and entertaining poem...
  WriterPlugin.StoryGen: Generate a list of synopsis for a novel or novella...
  WriterPlugin.TellMeMore: Summarize given text or any text document
  WriterPlugin.Translate: Translate the input into a language of your choice
  WriterPlugin.TwoSentenceSummary: Summarize given text in two sentences or less
  WriterPlugin.Shakespeare: Rewrite the input in the style of Shakespeare.
  TextPlugin.lowercase: Convert a string to lowercase.
  TextPlugin.trim: Trim whitespace from the start and end of a string...
  TextPlugin.trim_end: Trim whitespace from the end of a string.
  TextPlugin.trim_start: Trim whitespace from the start of a string.
  TextPlugin.uppercase: Convert a string to uppercase.
  MathPlugin.Add: Returns the addition result of the values provided...
  MathPlugin.Subtract: Returns the difference of numbers provided (suppor...

==================================================
DEMO : Function Calling avec FunctionChoiceBehavior.Auto()
==================================================

Requete utilisateur : 
J'ai besoin d'aide pour deux choses :
1. Convertir le texte "hello world" en majuscules
2. Calculer 25 + 17


Reponse du LLM (avec function calling) :
1) **"hello world"** en majuscules : **HELLO WORLD**  
2) **25 + 17** = **42**

Exercice 2 : Plugin de conversion d’unites avec Function Calling

Objectif : Créer un plugin UnitConverterPlugin avec des fonctions de conversion (temperature, distance) et le rendre disponible via Function Calling automatique.

Le Function Calling automatique est puissant quand les plugins offrent des fonctions bien decrites avec des types précis. Vous allez créer un plugin que le LLM pourra appeler automatiquement pour convertir des unites dans une conversation.

Indices : - # Étape 1 : Créer deux fonctions celsius_to_fahrenheit et km_to_miles avec @kernel_function - # Étape 2 : Ajouter des descriptions claires et des annotations de type pour les paramètres - # Étape 3 : Tester avec une requête naturelle comme “Combien font 37 degrés Celsius en Fahrenheit ?” - # Indice : F = C * 9/5 + 32 et miles = km * 0.621371

# Annotated est requis par les signatures des kernel_functions ci-dessous.
from typing import Annotated

class UnitConverterPlugin:
    """
    Plugin de conversion d'unites pour Function Calling.
    
    # Etape 1 : Implementer celsius_to_fahrenheit avec @kernel_function
    # Etape 2 : Implementer km_to_miles avec @kernel_function
    # Etape 3 : Ajouter des descriptions precises pour que le LLM sache quand les utiliser
    """
    
    @kernel_function(description="Convertit une temperature de Celsius en Fahrenheit")
    def celsius_to_fahrenheit(self, celsius: Annotated[float, "Temperature en degres Celsius"]) -> str:
        """
        # Indice : Appliquer la formule F = C * 9/5 + 32
        # Indice : Retourner une string descriptive comme "37.0 C = 98.6 F"
        """
        # TODO etudiant : implementer la conversion
        return "Exercice a completer"  # TODO etudiant : retourner le resultat formatte
    
    @kernel_function(description="Convertit une distance de kilometres en miles")
    def km_to_miles(self, km: Annotated[float, "Distance en kilometres"]) -> str:
        """
        # Indice : Appliquer la formule miles = km * 0.621371
        """
        # TODO etudiant : implementer la conversion
        return "Exercice a completer"  # TODO etudiant : retourner le resultat formatte

# Test du plugin seul
converter = UnitConverterPlugin()
print(converter.celsius_to_fahrenheit(37.0))  # Attendu : "37.0 C = 98.6 F"
print(converter.km_to_miles(100.0))  # Attendu : "100.0 km = 62.1 miles"

# TODO etudiant : ajouter le plugin au kernel et tester avec Function Calling Auto
# kernel.add_plugin(UnitConverterPlugin(), plugin_name="converter")
# Puis invoquer avec une requete naturelle via le chat service
print("Exercice a completer : integrez le plugin dans le kernel avec Function Calling")
Exercice a completer
Exercice a completer
Exercice a completer : integrez le plugin dans le kernel avec Function Calling

Interprétation : Architecture Function Calling

Sortie obtenue : Le LLM a automatiquement identifié et exécuté deux fonctions sans code manuel : - TextPlugin.uppercase("hello world") → “HELLO WORLD” - MathPlugin.Add(25, 17) → 42

Aspect Détail Signification
Plugins chargés WriterPlugin (18 fonctions), TextPlugin (5), MathPlugin (2) Bibliothèque d’outils disponibles pour le LLM
Mode Auto FunctionChoiceBehavior.Auto() Le LLM décide automatiquement quand et comment appeler les fonctions
Filtres included_plugins: ["TextPlugin", "MathPlugin"] Limitation des fonctions accessibles pour sécurité/performance
Auto-invoke auto_invoke=True Exécution automatique sans intervention manuelle

Points clés :

  1. Pattern ReAct intégré : Le LLM raisonne (analyse la requête) puis agit (appelle les fonctions)
  2. Chaînage transparent : Le LLM peut enchaîner plusieurs appels de fonctions si nécessaire
  3. Extraction de paramètres : Les valeurs sont extraites du contexte naturel (“25 + 17” → Add(25, 17))
  4. Formulation finale : Le résultat brut des fonctions est reformulé dans une réponse naturelle

Note technique : Cette approche remplace les anciens Planners (SequentialPlanner, FunctionCallingStepwisePlanner) qui généraient du XML intermédiaire. Function Calling utilise directement l’API OpenAI, ce qui réduit les tokens et améliore la fiabilité.

Comparaison avec Agents : - Function Calling : Orchestration simple, un LLM qui appelle des fonctions - Agents (voir notebook 03) : Plusieurs agents qui collaborent, chacun avec ses propres plugins

Cas d’usage typiques : - Assistants conversationnels avec accès à des APIs - Chatbots d’entreprise avec fonctions métier (recherche produits, calculs, requêtes DB) - Outils d’aide à la décision avec fonctions analytiques

Mémoire & Embeddings

SemanticTextMemory permet de stocker des textes (avec un embedding) dans un store : - VolatileMemoryStore (en mémoire) - ou connecteurs vers Pinecone, Azure Cognitive Search, Qdrant, etc.

On peut ensuite effectuer des requêtes sémantiques :
await memory.search("MaCollection", "Quelle est mon budget pour 2024 ?")

Verification du GroundingPlugin

Le plugin de Grounding (ancrage) permet de : 1. Extraire les entites d’un texte resume 2. Verifier chaque entite par rapport a un texte source de reference 3. Corriger le resume en supprimant les informations non fondees

Ce mécanisme est essentiel pour reduire les hallucinations des LLMs et garantir que les reponses sont ancrees dans des faits verifiables.

# ============================
# Cellule : Extrait Mémoire
# ============================

# CORRECTION: Utilisation des nouvelles approches pour la mémoire vectorielle
from semantic_kernel.connectors.in_memory import InMemoryStore
from semantic_kernel.connectors.ai.open_ai import OpenAITextEmbedding

# Embedding service (ex : openai text-embedding-ada)
embedding_service = OpenAITextEmbedding(
    service_id="embeddingService",
    ai_model_id="text-embedding-3-small"
)

# Nouvelle approche avec InMemoryStore
store = InMemoryStore()

# Ajout du service d'embedding au kernel
kernel.add_service(embedding_service)

# Pour la démonstration, on simule la mémoire et la recherche
budget_2024 = "Budget 2024 = 100k€"
budget_2023 = "Budget 2023 = 70k€"

print("Note: API de mémoire simplifiée pour cette version.")
print(f"Informations stockées :")
print(f"  - {budget_2024}")
print(f"  - {budget_2023}")

# Simulation de recherche
query = "Quel est mon budget pour 2024 ?"
print(f"\nRequête : {query}")

# Simulation simple : retour de la réponse connue
print("Réponse potentielle : ", budget_2024)
Note: API de mémoire simplifiée pour cette version.
Informations stockées :
  - Budget 2024 = 100k€
  - Budget 2023 = 70k€

Requête : Quel est mon budget pour 2024 ?
Réponse potentielle :  Budget 2024 = 100k€

Interprétation : Mémoire Vectorielle et Embeddings

Sortie obtenue : Configuration d’un système de mémoire sémantique avec embeddings pour recherche contextuelle.

Composant Technologie Rôle
Embedding Service OpenAI text-embedding-3-small Convertit le texte en vecteurs denses (1536 dimensions)
Vector Store InMemoryStore Stockage volatile des embeddings pour recherche rapide
Query “Quel est mon budget pour 2024 ?” Recherche sémantique (pas keyword matching)
Résultat “Budget 2024 = 100k€” Document le plus proche sémantiquement

Points clés :

  1. Recherche sémantique vs. keyword :

    • Keyword : Cherche les mots exacts “budget” et “2024”
    • Sémantique : Comprend l’intention et trouve les concepts liés même avec une formulation différente
  2. Évolution de l’API SK :

    # ❌ Ancienne approche (dépréciée)
    memory_store = VolatileMemoryStore()
    memory = SemanticTextMemory(store, embedding)
    await memory.save_information("collection", "text", "id")
    
    # ✅ Nouvelle approche (2024+)
    store = InMemoryStore()
    embedding = OpenAITextEmbedding(ai_model_id="text-embedding-3-small")
    kernel.add_service(embedding)
  3. Backends vectoriels supportés :

    Backend Use case Persistence
    InMemoryStore Dev, tests, démos Volatile
    Qdrant Production, scalabilité Persistant
    Pinecone Cloud, managed service Persistant
    Azure AI Search Intégration Azure Persistant
    Chroma Local, open-source Persistant
  4. Coût et performance :

    • Embedding : ~$0.00002 per 1K tokens (text-embedding-3-small)
    • Stockage : Dépend du backend (InMemory = gratuit, Qdrant = self-hosted possible)
    • Latence : <100ms pour recherche dans 10K documents

Note technique : Le notebook 05-VectorStores couvre l’intégration complète avec Qdrant pour des systèmes RAG (Retrieval-Augmented Generation) en production.

Cas d’usage typiques : - FAQ intelligente : Recherche dans une base de connaissances - RAG : Enrichir les réponses LLM avec des documents métier - Chatbot avec mémoire : Retrouver des conversations passées par similarité

Evolution de l’API de memoire

L’API de memoire vectorielle de Semantic Kernel a evolue significativement :

Ancienne approche (deprecie) : - VolatileMemoryStore + SemanticTextMemory - Méthodes .save_information() et .search()

Nouvelle approche (2024+) : - InMemoryStore pour le stockage volatile - Services d’embedding dedies (OpenAITextEmbedding) - Integration directe avec les connecteurs vectoriels (Pinecone, Qdrant, Azure AI Search)

Cette evolution permet une meilleure separation des responsabilites et une integration plus flexible avec divers backends.

Configuration des reponses multiples

La fonctionnalite Multi-Result permet d’obtenir plusieurs completions pour un même prompt en un seul appel API. Cela est utile pour : - Generer des alternatives : Obtenir plusieurs variations d’une reponse - Evaluer la diversite : Comparer différentes formulations - Sélection humaine : Presenter plusieurs options a l’utilisateur

Le paramètre number_of_responses dans les settings contrôle le nombre de reponses retournees.

Hugging Face Intégration

Semantic Kernel peut se connecter à Hugging Face localement ou via API.
Exemple :

from semantic_kernel.connectors.ai.hugging_face import HuggingFaceTextCompletion

hf_service = HuggingFaceTextCompletion(
    service_id="textHF", ai_model_id="distilgpt2", task="text-generation"
)
kernel.add_service(hf_service)

Ensuite, on peut enregistrer une fonction sémantique ou invoquer directement hf_service.get_text_contents(...).

Groundedness Checking

Pour éviter les “hallucinations” d’un résumé :
1. Extrait la liste d’entités du résumé.
2. Vérifie la correspondance de chaque entité avec le texte source (référence).
3. Retire ou corrige les entités non-fondées.

Cela se fait via un plugin “GroundingPlugin” (ex. ExtraitEntities, ReferenceCheckEntities, ExciseEntities).

# ============================
# Cellule : Groundedness Checking
# ============================

# Suppose qu'on a un "grounding_text" = un texte source
grounding_text = """
Votre budget 2024 est de 100k euros.
Vous vivez à Genève.
Vous avez investi 50k en actions.
"""

# Suppose qu'on a un résumé "faux"
summary_text = """
Mon budget 2024 est de 200k euros.
J'habite à Milan.
"""

plugins_directory = "./prompt_template_samples/"

# On appelle un plugin (hypothétique) "GroundingPlugin" comportant 3 fonctions
try:
    grounding_plugin = kernel.add_plugin(parent_directory=plugins_directory, plugin_name="GroundingPlugin")
    extract_entities = grounding_plugin["ExtractEntities"]
    check_entities = grounding_plugin["ReferenceCheckEntities"]
    excise_entities = grounding_plugin["ExciseEntities"]
    
    # 1) Extraire entités avec les bonnes variables
    ext_result = await kernel.invoke(
        extract_entities, 
        KernelArguments(
            input=summary_text,
            topic="entities",  # Variable attendue par le template
            example_entities="Person, Location, Organization",  # Variable attendue
            allow_dangerously_set_content=True
        )
    )
    print("Entités détectées:", ext_result)
    
    # 2) Vérifier correspondance - Important: convertir ext_result en string
    ext_result_str = str(ext_result.value) if hasattr(ext_result, 'value') else str(ext_result)
    check_result = await kernel.invoke(
        check_entities, 
        KernelArguments(
            input=ext_result_str,  # Utiliser la version string
            reference_context=grounding_text,
            topic="entities",  # Ajouter les variables attendues
            allow_dangerously_set_content=True  # IMPORTANT: autoriser le contenu complexe
        )
    )
    print("Entités non-fondées:", check_result)
    
    # 3) Retirer entités non-fondées du summary
    check_result_str = str(check_result.value) if hasattr(check_result, 'value') else str(check_result)
    excision = await kernel.invoke(
        excise_entities, 
        KernelArguments(
            input=summary_text,
            ungrounded_entities=check_result_str,  # Utiliser la version string
            allow_dangerously_set_content=True
        )
    )
    print("Summary corrigé:", excision)
    
except Exception as e:
    print(f"Note: Le plugin GroundingPlugin n'est pas disponible ou fonctionnel: {e}")
    print("Démonstration alternative:")
    print(f"Texte source: {grounding_text[:50]}...")
    print(f"Résumé analysé: {summary_text[:50]}...")
    print("Entités potentiellement problématiques: Milan (au lieu de Genève), 200k€ (au lieu de 100k€)")
Entités détectées: <entities>
- 2024
- 200k euros
- Milan
</entities>
Entités non-fondées: <ungrounded_entities>
- 200k euros
- Milan
</ungrounded_entities>
Summary corrigé: Mon budget 2024 est de 200k.
J'habite dans une ville.

Interprétation : Groundedness Checking (Vérification d’ancrage)

Sortie obtenue : Le système a détecté et corrigé les hallucinations dans un résumé en comparant avec un texte source.

Étape Résultat Explication
1. Extraction <entities>Milan</entities> Entités factuelles identifiées dans le résumé
2. Vérification <ungrounded_entities>Milan</ungrounded_entities> Comparaison avec le texte source (Genève ≠ Milan)
3. Correction “Mon budget 2024 est fixé. J’habite en ville.” Suppression des deux entités non-fondées (Milan et 200k euros retirés)

Points clés :

  1. Problème des hallucinations :

    • Les LLMs peuvent générer des informations plausibles mais fausses
    • Particulièrement problématique en production (santé, finance, légal)
    • Le Groundedness Checking ancre les réponses dans des faits vérifiables
  2. Architecture du GroundingPlugin :

    ExtractEntities → ReferenceCheckEntities → ExciseEntities
         ↓                      ↓                       ↓
    Liste entités      Entités invalides      Texte corrigé
  3. Entités détectées dans l’exemple :

    Entité résumé Texte source Statut
    200k euros 100k euros ❌ Incohérence (supprimée)
    Milan Genève ❌ Hallucination (supprimée)
  4. Limites de l’approche :

    • Les deux entités incohérentes (200k euros vs 100k euros, Milan vs Genève) ont été retirées du résumé corrigé
    • Nécessite un prompt bien calibré pour détecter toutes les incohérences
    • Fonctionne mieux avec des entités discrètes (lieux, noms) qu’avec des valeurs numériques

Note technique : Le paramètre allow_dangerously_set_content=True est nécessaire pour passer du contenu structuré (XML) entre les fonctions. En production, il faut valider ce contenu pour éviter les injections.

Améliorations possibles :

# Vérification multi-passes
for i in range(3):  # Itérer jusqu'à ce qu'il n'y ait plus d'entités non-fondées
    entities = extract_entities(summary)
    ungrounded = check_entities(entities, source)
    if not ungrounded:
        break
    summary = excise_entities(summary, ungrounded)

Cas d’usage typiques : - Résumés automatiques : Garantir que le résumé ne contient que des infos du document source - Chatbots réglementés : Assurer la conformité des réponses aux documents officiels - Fact-checking : Valider automatiquement les affirmations d’un texte

Résultats du Groundedness Checking

Le processus de verification d’ancrage comprend trois étapes :

  1. Extraction : Identification des entites factuelles dans le resume (personnes, lieux, montants)
  2. Verification : Comparaison de chaque entite avec le texte source de reference
  3. Correction : Suppression ou modification des entites non-fondees

Dans l’exemple ci-dessus, les entites problematiques detectees seraient : - “Milan” (non mentionne dans le texte source, qui indique “Geneve”) - “200k euros” (le texte source indique “100k euros”)

Cette technique est essentielle pour reduire les hallucinations des LLMs dans les systèmes de production.

Multi-Result

OpenAI (ou Azure) peut renvoyer plusieurs complétions pour un même prompt.
On paramètre number_of_responses=3 dans les settings.
Ensuite, get_text_contents(...) ou get_chat_message_contents(...) renvoie un tableau de résultats.

# ============================
# Cellule : Multi-Result
# ============================

from semantic_kernel.connectors.ai.open_ai import OpenAIChatPromptExecutionSettings

settings = OpenAIChatPromptExecutionSettings(
    service_id=service_id,
    # temperature not set: gpt-5-mini only supports default value (1)
    number_of_responses=3
)

# Utiliser le service chat existant (pas de modele legacy)
chat_service = kernel.get_service(service_id)
chat_history_multi = ChatHistory()
chat_history_multi.add_user_message("Donne-moi une brève blague sur les chats :")

# get_chat_message_contents() => liste de reponses
responses = await chat_service.get_chat_message_contents(chat_history_multi, settings=settings)

for i, r in enumerate(responses):
    print(f"Réponse n°{i+1}:\n{r}\n")
Réponse n°1:
Pourquoi les chats n’aiment pas l’ordinateur ?  
Parce qu’ils ont peur de la souris.

Réponse n°2:
Pourquoi les chats détestent-ils l’ordinateur ?  
Parce qu’ils ont peur de la souris !

Réponse n°3:
Pourquoi le chat s’est assis sur l’ordinateur ?  
Parce qu’il voulait garder un œil sur la souris !

Interprétation : Multi-Result Generation

Sortie obtenue : Trois blagues différentes générées en un seul appel API avec number_of_responses=3.

Réponse Contenu Style
#1 “Pourquoi les chats n’aiment pas Internet ? Parce qu’ils ont peur des souris sans fil.” Jeu de mots (souris = périphérique / animal)
#2 “Pourquoi le chat n’utilise jamais l’ordinateur ? Parce qu’il préfère attraper la souris avec ses pattes.” Double sens (souris ordinateur / animal)
#3 “Pourquoi les chats aiment-ils l’ordinateur ? Parce qu’il y a une souris !” Double sens (souris ordinateur / animal)

Points clés :

  1. Avantages du Multi-Result :

    • Efficacité : Un seul appel API au lieu de 3 appels séquentiels
    • Coût : Réduit les coûts de latence réseau et overhead API
    • Diversité : Variations générées en parallèle avec la même température
    • UX : Permet à l’utilisateur de choisir la meilleure réponse
  2. Paramètres de configuration :

    settings = OpenAIChatPromptExecutionSettings(
        service_id=service_id,
        # temperature non définie : gpt-5-mini ne supporte que la valeur par défaut (1)
        number_of_responses=3    # Nombre de variantes
    )
  3. Cas d’usage typiques :

    Scénario Nombre de réponses Post-traitement
    Brainstorming 5-10 Sélection humaine
    A/B testing 2-3 Métriques engagement
    Génération créative 3-5 Vote utilisateur
    Robustesse 3 Consensus majoritaire
  4. Considérations de coût :

    Coût = nombre_réponses × tokens_par_réponse × prix_par_token
    
    Exemple (GPT-3.5-turbo) :
    - 1 réponse : 60 tokens × $0.002/1K = $0.00012
    - 3 réponses : 180 tokens × $0.002/1K = $0.00036

Note technique : get_text_contents() retourne une liste, contrairement à get_text_content() (singulier) qui retourne un seul résultat. L’API ChatCompletion a un équivalent avec get_chat_message_contents().

Comparaison avec approches alternatives :

# ❌ Approche naïve (3 appels séquentiels)
for i in range(3):
    response = await service.get_text_content(prompt, settings)
# Problème : 3× latence réseau, 3× overhead API

# ✅ Multi-Result (1 appel parallèle)
responses = await service.get_text_contents(prompt, settings)
# Avantage : Latence minimale, coût optimisé

Cas d’usage avancés : - Génération de variantes marketing : Plusieurs versions d’un slogan - Traduction avec nuances : Différentes formulations d’une traduction - Résumé multi-angle : Résumés avec différents niveaux de détail - Code generation : Plusieurs implémentations d’un algorithme

Exercice 3 : Analyseur de diversite des reponses multiples

Objectif : Implementer une fonction qui analyse la diversite lexicale entre les reponses generees par number_of_responses=N et selectionne la meilleure selon un critere.

Quand on genere plusieurs reponses, il est utile de mesurer leur diversite (pour eviter des reponses trop similaires) et de sélectionner automatiquement la meilleure (la plus longue, la plus diverse, etc.).

Indices : - # Étape 1 : Extraire les mots uniques de chaque reponse avec set(response.split()) - # Étape 2 : Calculer le coefficient de Jaccard entre chaque paire de reponses (intersection / union) - # Étape 3 : Sélectionner la reponse avec le plus grand vocabulaire unique - # Indice : len(set1 & set2) / len(set1 | set2) donne le score de Jaccard

def analyze_diversity(responses: list[str]) -> dict:
    """
    Analyse la diversite lexicale entre plusieurs reponses.
    
    # Etape 1 : Convertir chaque reponse en ensemble de mots uniques
    # Etape 2 : Calculer les scores de Jaccard entre chaque paire de reponses
    # Etape 3 : Identifier la reponse la plus riche (plus grand vocabulaire unique)
    
    Returns:
        dict avec avg_jaccard, best_response_idx, diversite_scores
    """
    # TODO etudiant : implementer l'analyse de diversite
    return {"avg_jaccard": 0.0, "best_response_idx": 0, "diversite_scores": []}

# Test avec les reponses generees precedemment
test_responses = [
    "Pourquoi les chats n'aiment pas Internet ? Parce qu'ils ont peur des souris sans fil.",
    "Pourquoi le chat n'utilise jamais l'ordinateur ? Parce qu'il prefere attraper la souris.",
    "Pourquoi les chats aiment l'ordinateur ? Parce qu'il y a une souris !",
]

result = analyze_diversity(test_responses)
print(f"Score Jaccard moyen : {result['avg_jaccard']:.3f}")
print(f"Reponse la plus riche : #{result['best_response_idx'] + 1}")
for i, score in enumerate(result.get('diversite_scores', [])):
    print(f"  Reponse {i+1} : richesse = {score:.2f}")
Score Jaccard moyen : 0.000
Reponse la plus riche : #1

Conclusion et Resume

Dans ce notebook, nous avons explore les fonctionnalites avancees de Semantic Kernel :

Concept Description Statut
Chat avec KernelArguments Gestion de l’historique et du contexte Fondamental
Function Calling Orchestration automatique via FunctionChoiceBehavior.Auto() Moderne (remplace Planners)
Memoire & Embeddings Stockage vectoriel avec InMemoryStore API moderne
Hugging Face Integration de modèles open-source Optionnel
Groundedness Checking Reduction des hallucinations Production
Multi-Result Generations multiples en un appel Creatif

Points cles a retenir

  1. Function Calling remplace les Planners : Plus simple, plus fiable, moins de tokens
  2. API Memory modernisee : InMemoryStore + OpenAITextEmbedding (voir notebook 05 pour Qdrant)
  3. Groundedness : Essentiel pour les systèmes de production

Prochaines étapes


Navigation : << 01-Intro | Index | 03-Agents >>

Conclusion

Nous avons vu : 1. Un Chat basique avec KernelArguments. 2. Les Planners (Sequential, Stepwise) pour orchestrer dynamiquement des steps. 3. La Mémoire & embeddings pour stocker/rechercher des informations sémantiques. 4. L’intégration Hugging Face pour exécuter localement des modèles open-source. 5. Un Groundedness Checking minimal pour éviter les hallucinations. 6. La gestion de plusieurs réponses (Multi-Result) avec un seul appel.

Ces fonctionnalités permettent de créer des scénarios complexes : chat évolué, question-answering avec mémoire persistante, planification automatique, usage local ou cloud, etc. Bonne exploration !

Retour au sommet