Cas Concret : CRM Automatisé
Mis à jour le 29 juillet 2026
Un CRM piloté par la conversation
Imaginez un CRM où les commerciaux n’ont plus besoin de naviguer dans des interfaces complexes. Ils parlent à un assistant qui crée, modifie et consulte les contacts automatiquement. C’est exactement ce que le function calling permet de construire, et la différence avec le tracker de paiements de la leçon précédente est de taille : jusqu’ici, le modèle ne faisait que lire. Ici, il va écrire, ce qui déplace le centre de gravité de l’exercice vers la validation, la confirmation et la gestion des doublons.
Vous allez donc implémenter un CRM conversationnel avec des opérations CRUD (Create, Read, Update, Delete) sur une base de contacts.
La base de données simulée
Un dictionnaire Python tient lieu de base pour la démonstration. Deux contacts suffisent : un client et un prospect, ce qui permettra plus loin de tester une transition de statut.
import json
import os
from mistralai import Mistral
# Base de contacts en mémoire (en production : PostgreSQL, MongoDB, etc.)
contacts_db = {
"CT001": {
"name": "Marie Dupont",
"email": "[email protected]",
"company": "TechCorp",
"role": "Directrice Technique",
"phone": "+33 6 12 34 56 78",
"status": "client",
"notes": "Intéressée par la solution enterprise"
},
"CT002": {
"name": "Pierre Martin",
"email": "[email protected]",
"company": "StartupIO",
"role": "CEO",
"phone": "+33 6 98 76 54 32",
"status": "prospect",
"notes": "Rendez-vous prévu en avril"
}
}
next_id = 3 # Compteur pour les nouveaux contacts
Les fonctions CRUD
get_contact et search_contacts couvrent la lecture, la seconde acceptant un champ de recherche pour éviter de multiplier les fonctions quasi identiques. create_contact est la première fonction qui modifie l’état du système, et elle illustre une règle qu’on oublie facilement en mode conversationnel : le contrôle d’unicité doit vivre dans votre code, jamais dans la consigne donnée au modèle. Rien n’empêche un commercial de dicter deux fois le même contact ; c’est la vérification d’email en tête de fonction qui retourne alors duplicate_email avec l’identifiant existant, ce qui permet au modèle d’expliquer précisément le refus.
update_contact adopte une approche par champs optionnels : seuls les champs fournis sont modifiés, et la fonction renvoie la liste de ce qui a réellement changé. Cette liste n’est pas décorative — c’est elle qui permet à l’assistant de confirmer la modification par une phrase exacte plutôt que par un vague « c’est fait ».
def get_contact(contact_id: str) -> str:
"""Récupère les informations d'un contact."""
if contact_id in contacts_db:
contact = contacts_db[contact_id].copy()
contact["contact_id"] = contact_id
return json.dumps(contact, ensure_ascii=False)
return json.dumps({"error": f"Contact {contact_id} introuvable"})
def search_contacts(query: str, field: str = "name") -> str:
"""Recherche des contacts par nom, entreprise ou statut."""
results = []
for cid, contact in contacts_db.items():
if field in contact and query.lower() in contact[field].lower():
entry = contact.copy()
entry["contact_id"] = cid
results.append(entry)
if results:
return json.dumps({"results": results, "count": len(results)},
ensure_ascii=False)
return json.dumps({"results": [], "count": 0,
"message": f"Aucun contact trouvé pour '{query}' dans '{field}'"})
def create_contact(name: str, email: str, company: str,
role: str = "", phone: str = "",
status: str = "prospect", notes: str = "") -> str:
"""Crée un nouveau contact dans le CRM."""
global next_id
# Vérifier si l'email existe déjà
for cid, contact in contacts_db.items():
if contact["email"].lower() == email.lower():
return json.dumps({
"error": "duplicate_email",
"message": f"Un contact avec cet email existe déjà : {cid}"
})
contact_id = f"CT{next_id:03d}"
next_id += 1
contacts_db[contact_id] = {
"name": name,
"email": email,
"company": company,
"role": role,
"phone": phone,
"status": status,
"notes": notes
}
return json.dumps({
"success": True,
"contact_id": contact_id,
"message": f"Contact {name} créé avec l'identifiant {contact_id}"
}, ensure_ascii=False)
def update_contact(contact_id: str, **fields) -> str:
"""Met à jour un ou plusieurs champs d'un contact existant."""
if contact_id not in contacts_db:
return json.dumps({"error": f"Contact {contact_id} introuvable"})
updated_fields = []
for key, value in fields.items():
if key in contacts_db[contact_id]:
contacts_db[contact_id][key] = value
updated_fields.append(key)
if updated_fields:
return json.dumps({
"success": True,
"contact_id": contact_id,
"updated_fields": updated_fields,
"message": f"Contact {contact_id} mis à jour : {', '.join(updated_fields)}"
}, ensure_ascii=False)
return json.dumps({"error": "Aucun champ valide à mettre à jour"})
Le ensure_ascii=False présent dans plusieurs retours n’est pas un détail cosmétique : sans lui, « Intéressée » devient une suite d’échappements Unicode dans le contexte du modèle, moins lisible et plus coûteuse en tokens.
Les spécifications JSON des tools
Deux mécanismes portent ici l’essentiel de la fiabilité. Les enum sur status interdisent au modèle d’inventer une valeur : il devra choisir parmi prospect, client, partenaire et inactif, ce qui protège l’intégrité de vos données sans aucune validation supplémentaire. Le tableau required fait le reste du travail en distinguant le nécessaire de l’optionnel — pour create_contact, un contact sans nom, email ou entreprise n’a pas de sens, tandis que le téléphone peut arriver plus tard.
tools = [
{
"type": "function",
"function": {
"name": "get_contact",
"description": "Récupère toutes les informations d'un contact par son identifiant",
"parameters": {
"type": "object",
"properties": {
"contact_id": {
"type": "string",
"description": "Identifiant du contact (ex: CT001)"
}
},
"required": ["contact_id"]
}
}
},
{
"type": "function",
"function": {
"name": "search_contacts",
"description": "Recherche des contacts par nom, entreprise ou statut",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Terme de recherche"
},
"field": {
"type": "string",
"enum": ["name", "company", "status", "role"],
"description": "Champ dans lequel chercher (défaut: name)"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "create_contact",
"description": "Crée un nouveau contact dans le CRM",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Nom complet du contact"
},
"email": {
"type": "string",
"description": "Adresse email du contact"
},
"company": {
"type": "string",
"description": "Nom de l'entreprise"
},
"role": {
"type": "string",
"description": "Poste ou fonction dans l'entreprise"
},
"phone": {
"type": "string",
"description": "Numéro de téléphone"
},
"status": {
"type": "string",
"enum": ["prospect", "client", "partenaire", "inactif"],
"description": "Statut du contact"
},
"notes": {
"type": "string",
"description": "Notes libres sur le contact"
}
},
"required": ["name", "email", "company"]
}
}
},
{
"type": "function",
"function": {
"name": "update_contact",
"description": "Met à jour les informations d'un contact existant",
"parameters": {
"type": "object",
"properties": {
"contact_id": {
"type": "string",
"description": "Identifiant du contact à modifier (ex: CT001)"
},
"name": {"type": "string", "description": "Nouveau nom"},
"email": {"type": "string", "description": "Nouvel email"},
"company": {"type": "string", "description": "Nouvelle entreprise"},
"role": {"type": "string", "description": "Nouveau poste"},
"phone": {"type": "string", "description": "Nouveau téléphone"},
"status": {
"type": "string",
"enum": ["prospect", "client", "partenaire", "inactif"],
"description": "Nouveau statut"
},
"notes": {"type": "string", "description": "Nouvelles notes"}
},
"required": ["contact_id"]
}
}
}
]
Le dispatch et la boucle
Les fonctions ne dépendant d’aucun objet externe, le dictionnaire de dispatch se réduit à une correspondance directe. Le message système, lui, porte deux consignes de comportement absentes du code : confirmer chaque action de création ou de modification avec ses détails, et réclamer les informations manquantes avant de créer un contact. Sans cette seconde phrase, le modèle a tendance à combler les champs requis par des valeurs plausibles.
available_functions = {
"get_contact": get_contact,
"search_contacts": search_contacts,
"create_contact": create_contact,
"update_contact": update_contact,
}
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
model = "mistral-large-latest"
messages = [
{
"role": "system",
"content": "Vous êtes un assistant CRM. Vous aidez les commerciaux "
"à gérer leurs contacts. Répondez en français. "
"Quand vous créez ou modifiez un contact, confirmez "
"l'action avec les détails. Demandez les informations "
"manquantes avant de créer un contact."
}
]
Quatre scénarios et ce qu’ils enseignent
Le premier scénario montre le modèle choisissant lui-même field="company" alors que l’utilisateur n’a rien précisé : l’enum du schéma lui a fourni les options, la formulation de la question a fait le reste. Le deuxième donne les informations en vrac, sans étiquettes, et le modèle les répartit correctement entre nom, email, entreprise et poste. Le troisième est le plus riche : « Passe Pierre Martin en client » ne contient aucun identifiant, le modèle doit donc chaîner une recherche puis une mise à jour, en déduisant au passage la note contextuelle. Le quatrième rappelle que le modèle peut détourner une fonction de son usage littéral pour répondre à une question de comptage.
# Scénario 1 : Rechercher un contact
chat("Trouve-moi les contacts chez TechCorp")
# -> Le modèle appelle search_contacts(query="TechCorp", field="company")
# -> "J'ai trouvé 1 contact chez TechCorp : Marie Dupont (CT001),
# Directrice Technique, statut client."
# Scénario 2 : Créer un contact
chat("Ajoute un nouveau contact : Sophie Laurent, [email protected], "
"DataViz, Data Scientist")
# -> Le modèle appelle create_contact(name="Sophie Laurent",
# email="[email protected]", company="DataViz", role="Data Scientist")
# -> "Contact créé avec succès ! Sophie Laurent (CT003) a été ajoutée
# comme prospect chez DataViz."
# Scénario 3 : Modifier un statut
chat("Passe Pierre Martin en client, il vient de signer")
# -> Le modèle cherche d'abord Pierre Martin, puis appelle
# update_contact(contact_id="CT002", status="client",
# notes="Contrat signé en avril 2026")
# -> "Pierre Martin (CT002) est maintenant marqué comme client.
# J'ai ajouté une note sur la signature du contrat."
# Scénario 4 : Question sans fonction
chat("Combien de prospects avons-nous ?")
# -> Le modèle appelle search_contacts(query="prospect", field="status")
# -> "Vous avez actuellement 1 prospect : Sophie Laurent chez DataViz."
Passer en production sans tout réécrire
Le dictionnaire en mémoire disparaît évidemment dès qu’il y a plusieurs utilisateurs. Le remplacer par une vraie base de données ne demande pourtant de toucher qu’à une seule couche : la fonction interroge l’ORM ou l’API de votre CRM et retourne toujours la même chaîne JSON.
# Les fonctions appellent l'ORM ou l'API de votre CRM
def get_contact_production(contact_id: str) -> str:
"""Version production avec PostgreSQL."""
contact = db.session.query(Contact).filter_by(id=contact_id).first()
if contact:
return json.dumps(contact.to_dict(), ensure_ascii=False)
return json.dumps({"error": f"Contact {contact_id} introuvable"})
Le reste du code — tools, dispatch, boucle de conversation — reste identique. C’est la force de cette architecture : seules les fonctions métier changent, et la couche conversationnelle ignore tout de vos choix de persistance.
Points clés à retenir
- Le function calling transforme un LLM en interface naturelle pour un CRM
- Les opérations CRUD se mappent directement sur des fonctions avec JSON Schema
- Le modèle peut enchaîner recherche puis modification dans une même conversation
- L’architecture est découplée : changez la base de données sans toucher à la logique de conversation
- Le message système est crucial pour guider le modèle sur les confirmations et les informations manquantes