Introduction
Dans cette V1, on pousse notre projet de SAV e-commerce un peu plus loin avec l’ajout de Symfony AI et d’un LLM accessible via OpenRouter. L’objectif : permettre à Bloppy de formuler des réponses plus naturelles et adaptées à la demande du client, pour améliorer l’expérience dans le chat.
Dans la V0, les réponses reposaient sur des textes prédéfinis. Ces textes restent notre référence : JEV oriente la demande, l’application fournit les faits et le LLM se charge de les reformuler. C’est cette répartition des rôles que nous allons explorer, avec ses avantages et ses limites.
Si vous souhaitez comprendre l’architecture du projet avant de poursuivre, je vous invite à lire le premier article consacré à la V0.
Prêt à faire évoluer Bloppy ? Alors, passons au concret.
Les ajouts prévus
Cette V1 fait évoluer Bloppy sur deux axes : la qualité de ses réponses et le suivi des décisions prises par JEV.
Pour la rédaction, on ajoute un modèle Gemini Flash, accessible via OpenRouter et intégré avec Symfony AI. Son rôle est précis : reformuler le texte fourni par l’application pour rendre les échanges plus naturels. JEV conserve l’orientation des demandes, et le texte type reste la référence pour les faits. Si la génération échoue, Bloppy répond avec ce texte type.
L’autre ajout concerne les données. En V0, nous ne conservions pas de trace exploitable de chaque décision de JEV. Désormais, chaque décision est enregistrée avec les informations qui lui ont été transmises. Elle peut ensuite être étiquetée à partir du retour d’un conseiller, d’un clic du client ou d’une relecture, puis exportée pour être analysée. L’objectif est notamment de pouvoir ajuster sur des données le seuil de confiance de 0,6, choisi « au jugé » dans la V0.
Une section « À relire » permet aussi d’examiner les demandes que Bloppy a conservées sans les transmettre à un conseiller. Sans cette relecture, nous risquerions d’évaluer uniquement les demandes transmises et de passer à côté de celles qui auraient dû l’être.
Nous reviendrons sur ces verdicts et leur étiquetage dans le prochain article. Pour commencer, concentrons-nous sur la rédaction : comment donner à Bloppy davantage de souplesse dans ses réponses tout en gardant les faits sous le contrôle de l’application ?
Symfony AI au cœur de la rédaction
Pour intégrer cette nouvelle étape, je reste dans l’écosystème Symfony avec Symfony AI. Même pour un POC, je souhaite isoler les responsabilités afin de pouvoir faire évoluer le projet progressivement. La rédaction des réponses est donc confiée à un service dédié : BloppyWriter.
Ce service reçoit la conversation et le texte type préparé par l’application. Il construit les messages à transmettre au modèle, lance la génération via la plateforme OpenRouter et renvoie le texte obtenu. Le modèle utilisé reste configurable grâce à la variable d’environnement BLOPPY_MODEL.
Le principe reste le même : le LLM sert l’application, et non l’inverse. Il intervient une fois les informations de référence déterminées. Les liens et les boutons restent gérés par l’application.
BloppyWriter : un service dédié à la rédaction
Le prompt définit le rôle de Bloppy, le ton attendu et les limites de sa réponse : du français, le vouvoiement et deux ou trois phrases simples. Il lui demande de s’appuyer uniquement sur le texte fourni, sans inventer de délai, de prix ou de geste commercial.
Les messages du client sont également transmis pour contextualiser la rédaction. Le texte type constitue la référence factuelle ; l’historique du client aide le modèle à comprendre la demande.
Avant l’appel, le service publie l’indicateur « Bloppy rédige sa réponse ». Il demande ensuite une reformulation avec une température de 0,3, choisie pour limiter la variabilité des réponses.
Si l’appel échoue ou que le modèle renvoie un texte vide, le service journalise l’erreur et retourne le texte type. La génération apporte ainsi une possibilité de reformulation, avec une réponse de secours déjà disponible.
Ce cadre reste toutefois une consigne donnée au modèle : dans cette version, aucun contrôle ne vérifie que le texte généré respecte effectivement tous les faits fournis. Nous reviendrons sur cette limite.
<?php
namespace App\Support;
use App\Entity\SupportConversation;
use App\Enum\MessageAuthor;
use Psr\Log\LoggerInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fait rédiger par un LLM (Symfony AI, via OpenRouter) le texte d'une réponse de Bloppy, à partir des seules
* informations du texte type. Les liens et les boutons ne passent pas par lui. Au moindre souci, le texte type reste.
* Le temps qu'il réponde, le client voit « Bloppy rédige sa réponse ».
*/
final class BloppyWriter
{
private const string PROMPT = <<<'PROMPT'
Tu es Bloppy, l'aide à la commande d'une boutique d'électroménager en ligne. Tu réponds au client en français,
en vouvoyant, en deux ou trois phrases simples.
Appuie-toi uniquement sur les informations ci-dessous : n'invente ni délai, ni prix, ni geste commercial.
N'écris ni lien, ni URL, ni Markdown : les liens utiles sont affichés sous ta réponse.
Informations : %s
PROMPT;
public function __construct(
#[Autowire(service: 'ai.platform.openrouter')]
private PlatformInterface $platform,
private SupportChatPublisher $publisher,
private LoggerInterface $logger,
#[Autowire(env: 'BLOPPY_MODEL')]
private string $model,
) {
}
/**
* @param string $facts le texte type, qui fait foi
*/
public function write(SupportConversation $conversation, string $facts): string
{
$messages = new MessageBag(Message::forSystem(\sprintf(self::PROMPT, $facts)));
foreach ($conversation->getMessages() as $message) {
if (MessageAuthor::Customer === $message->getAuthor()) {
$messages->add(Message::ofUser($message->getContent()));
}
}
try {
$this->publisher->typing($conversation, 'Bloppy rédige sa réponse');
$text = trim($this->platform->invoke($this->model, $messages, ['temperature' => 0.3])->asText());
if ('' === $text) {
throw new \UnexpectedValueException('The model returned an empty text.');
}
return $text;
} catch (\Throwable $e) {
$this->logger->error('Bloppy could not write its reply, keeping the template.', ['exception' => $e]);
return $facts;
}
}
}
Un modèle métier riche pour faire évoluer le POC
Même pour un POC, je souhaite garder une structure qui permette de faire évoluer le projet sans disperser les règles métier. Je reste donc sur une architecture MVC Symfony, avec un modèle métier riche et quelques inspirations du DDD tactique.
Concrètement, les objets métier ne se limitent pas à stocker des données :
JevVerdictexpose des méthodes commecorrect(),chooseByCustomer(),predictsResolved()etisResolvable(). La logique de comparaison entre la prédiction et le résultat observé reste ainsi dans l’entité.- Les enums regroupent les comportements associés à leurs valeurs, comme
SupportRoute::criteria()oulabel(). JevDecision, déclaréfinal readonly, représente une décision sous une forme immuable.- L’interface
ContextSourcedéfinit un contrat pour les sources de contexte, dont les implémentations sont branchées à l’aide de tags Symfony.
Le vocabulaire compte également : verdict, orientation, transmission, relecture. Ces termes se retrouvent dans le code pour rapprocher son organisation du fonctionnement réel du support. Cette recherche d’un vocabulaire partagé rejoint le principe de langage omniprésent du DDD.
Je parlerais donc ici de « MVC Symfony avec un modèle métier riche ». L’objectif reste concret : conserver les règles au bon endroit et pouvoir faire évoluer les sources de contexte, l’évaluation des décisions ou la rédaction des réponses sans tout mélanger.
Deux rôles : côté client et côté conseiller
Dans cette V1, on peut accéder au prototype en tant que client ou conseiller SAV. Pour faciliter les essais, l’écran de connexion permet de choisir un profil de démonstration, sans mot de passe.
Côté client, on retrouve ses commandes et ses demandes de support, avec un fil de discussion pour échanger avec Bloppy, puis avec un conseiller si nécessaire.
Côté SAV, l’interface donne une vue plus large : les demandes en attente, les conversations déjà prises en charge et une section « À relire » pour évaluer certaines orientations de JEV.
Quand un conseiller doit prendre le relais
Une fois la demande orientée vers un conseiller, l’application maintient cet état : les nouveaux messages du client ne relancent plus les réponses automatiques de Bloppy. Le client peut donc ajouter des précisions pendant l’attente, sans que l’assistant reprenne la main.
La capture illustre ce comportement : après l’annonce du transfert, le client pose une question sur la garantie. Son message rejoint le fil, mais Bloppy n’y répond pas. La conversation reste en attente d’un conseiller.
La demande est diffusée à l’ensemble des conseillers connectés et apparaît dans leur liste d’attente. Elle ne dépend donc pas de la disponibilité d’une personne désignée à l’avance : un conseiller disponible peut cliquer sur « Rejoindre » pour prendre le relais.
Turbo et Mercure permettent de répercuter cette prise en charge en temps réel dans les interfaces. L’échange se poursuit dans le même fil, avec les messages déjà envoyés par le client.
Une seule prise en charge, même en cas de concurrence
Plusieurs conseillers peuvent voir la même demande en attente et tenter de la rejoindre presque au même moment. L’attribution doit donc être contrôlée côté serveur.
Un test couvre ce scénario : Sophie charge une demande encore libre, mais Karim se l’attribue avant que sa tentative aboutisse. L’application conserve alors Karim comme conseiller, redirige Sophie et lui indique que la conversation est déjà prise. Sa tentative n’ajoute aucun message et ne déclenche aucune publication Mercure.
La prise en charge validée est ensuite diffusée via Mercure : le client voit quel conseiller rejoint la conversation, et la demande disparaît de la liste d’attente des conseillers grâce à Turbo.
Le conseiller, un garde-fou pour évaluer JEV
Dans cette V1, le rôle du conseiller va au-delà de la prise en charge du client : il peut également porter un regard critique sur l’orientation choisie par JEV.
Comme le montre la capture, un encart interne affiche la décision et son score de confiance. Ici, JEV a choisi de transmettre la demande à un conseiller avec une confiance de 91 %. Le conseiller peut alors indiquer quelle aurait été la bonne orientation : une FAQ, un suivi de commande ou une intervention humaine.
Cette évaluation intervient après la décision. Elle permet de confirmer l’orientation retenue ou d’enregistrer une correction, en conservant la décision initiale pour pouvoir les comparer. Un score de confiance élevé ne dispense donc pas d’une relecture.
C’est la place que je donne à l’humain dans cette boucle : prendre le relais dans la conversation, mais aussi contribuer à évaluer le système. Ces retours constituent des données exploitables pour analyser les erreurs de JEV et, à terme, ajuster le seuil de confiance sur des cas observés.
À ce stade, ces corrections ne réentraînent pas automatiquement JEV. Elles donnent à cette V1 une base pour mesurer la pertinence de ses décisions et guider les prochaines évolutions.
Exporter les verdicts pour analyser les décisions dans la durée
La relecture dans l’interface permet d’évaluer une demande à la fois. Pour prendre du recul sur les décisions de JEV, j’ai également ajouté une commande d’export CSV : app:jev:export.
Chaque ligne correspond à une décision et regroupe son contexte : l’orientation choisie par JEV, son score de confiance, les probabilités par orientation, les messages du client et les éventuelles étiquettes apportées par le client ou le conseiller. La prédiction initiale et la correction humaine restent distinctes, ce qui permet de les comparer.
On peut exporter l’ensemble des verdicts ou limiter l’extraction aux décisions étiquetées par un conseiller :
php bin/console app:jev:export > verdicts.csv
php bin/console app:jev:export --labelled > verdicts-etiquetes.csv
L’objectif est de disposer de données pour examiner les erreurs récurrentes et évaluer la pertinence du seuil de confiance. Parmi les demandes transmises, lesquelles auraient pu être traitées automatiquement ? À l’inverse, lesquelles auraient nécessité un conseiller alors que Bloppy les avait conservées ? Les relectures de la section « À relire » prennent ici tout leur intérêt.
Une évolution possible serait d’automatiser cette extraction avec Symfony Scheduler, puis d’envoyer le fichier par mail pour organiser une revue périodique. Pour le moment, la commande fournit l’export ; la planification et l’envoi restent à mettre en place.
Ce jeu de données prépare également la suite du projet : un comparatif avec Rubix ML, en s’appuyant sur les contextes enregistrés et les étiquettes collectées.
<?php
namespace App\Command;
use App\Entity\JevVerdict;
use App\Enum\SupportRoute;
use App\Repository\JevVerdictRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Sort les verdicts de JEV en CSV, une ligne par décision : le jeu de données du comparatif avec Rubix ML.
*/
#[AsCommand(name: 'app:jev:export', description: 'Exporte les verdicts de JEV et leurs étiquettes en CSV')]
final class ExportJevVerdictsCommand
{
public function __construct(
private JevVerdictRepository $verdicts,
private SerializerInterface $serializer,
) {
}
public function __invoke(
OutputInterface $output,
#[Option('Seulement les verdicts étiquetés par un conseiller')]
bool $labelled = false,
): int {
$rows = [];
foreach ($this->verdicts->findForExport($labelled) as $verdict) {
$rows[] = $this->row($verdict);
}
if ([] !== $rows) {
$output->write($this->serializer->serialize($rows, 'csv'));
}
return Command::SUCCESS;
}
/**
* @return array<string, string|int|float|null>
*/
private function row(JevVerdict $verdict): array
{
$row = [
'id' => $verdict->getId(),
'cree_le' => $verdict->getCreatedAt()->format(\DATE_ATOM),
'demande' => $verdict->getConversation()->getId(),
'route_jev' => $verdict->getRoute()->value,
'confiance' => $verdict->getConfidence(),
];
foreach (SupportRoute::cases() as $route) {
$row['proba_'.$route->value] = $verdict->getProbabilities()[$route->value] ?? null;
}
$state = $verdict->getState();
return $row + [
'rubrique_faq' => $verdict->getFaqTopic()->value,
'choix_client' => $verdict->getCustomerRoute()?->value,
'route_conseiller' => $verdict->getCorrectedRoute()?->value,
'jev_resolu' => (int) $verdict->predictsResolved(),
'conseiller_resolu' => null === $verdict->isResolvable() ? null : (int) $verdict->isResolvable(),
'messages_du_client' => implode("\n", $state['messages_du_client'] ?? []),
'state' => json_encode($state, \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR),
];
}
}La V1 : une expérience utilisateur plus aboutie
Dans cet enregistrement d’écran, je vous montre le parcours en action. Depuis la V0, qui posait les bases de l’orientation avec JEV et du relais humain, l’expérience a pris forme : Bloppy reformule ses réponses, un indicateur signale la rédaction en cours et le client peut continuer à écrire pendant qu’un conseiller prend le relais.
Côté SAV, les demandes arrivent en direct, les conseillers rejoignent les conversations et peuvent évaluer les orientations de JEV. On commence ainsi à construire une boucle de retour humain pour comprendre ce qui fonctionne et ce qui mérite d’être corrigé.
C’est ce que je voulais explorer avec cette V1 : intégrer l’IA à des étapes précises du parcours, avec un rôle défini et une solution de repli. Le résultat rend les échanges plus naturels et les décisions plus faciles à examiner. Les données collectées permettront ensuite de mesurer si ces choix améliorent réellement la qualité du service.
Conclusion
Avec cette V1, Bloppy franchit une nouvelle étape. Symfony AI et OpenRouter apportent la reformulation des réponses, tandis que les décisions de JEV sont enregistrées, soumises à la relecture des conseillers et exportables pour être analysées. La structure du projet prépare également les prochains ajouts, avec des responsabilités mieux délimitées.
C’est le fil conducteur de cette évolution : JEV oriente la demande, l’application fournit les faits, le LLM rédige et le conseiller prend le relais lorsque c’est nécessaire. Il peut aussi confirmer ou corriger l’orientation retenue pour alimenter notre évaluation.
Le cadre reste essentiel. Une réponse plus naturelle ne garantit pas une réponse exacte, et le retour au texte type en cas de panne ne protège pas contre un ajout inventé par le modèle. Cette V1 pose donc les bases d’un SAV plus abouti, tout en donnant davantage de moyens pour observer ses limites.
J’ai encore plusieurs pistes pour la suite, notamment exploiter les verdicts étiquetés pour comparer les approches avec Rubix ML. L’objectif sera de mesurer ce que chaque évolution apporte réellement au projet.
Et vous, avez-vous déjà combiné un modèle de décision et un LLM de rédaction en production ? Comment évaluez-vous leurs décisions et leurs réponses au quotidien ?