Introduction
Comme vu précédemment, on a bien avancé sur notre projet de classe virtuelle.
On a commencé par implémenter un système de vérification de présence en temps réel, migré notre stack sur FrankenPHP pour le gain de performance, et ajouté un chat live entre élèves et profs. Quelques fonctionnalités annexes sont venues peaufiner l’expérience globale — bref, le POC commence à ressembler à un vrai produit.
Et maintenant ?
Place à la V3, centrée sur deux nouveautés : la modération en temps réel et le scoring automatique.
Dès qu’un utilisateur saisit du texte dans un champ, celui-ci est instantanément soumis à un modèle de langage via Symfony AI : il évalue le ton, détecte les propos inappropriés ou agressifs, et décide si le message peut passer. En parallèle, on introduit Rubix ML, un moteur de scoring interne, pour analyser, pondérer et classer les contenus selon nos propres critères.
Prêt à plonger dans les entrailles de cette V3 du POC ? Allons voir comment Symfony et un soupçon d’IA peuvent rendre nos formulaires plus intelligents — sans sacrifier la simplicité du code.
Le comment
Nous avons déjà la brique essentielle : la messagerie.
Mais grâce à Symfony AI, on peut désormais brancher facilement un LLM (Large Language Model) pour ajouter un premier niveau de modération automatisée. L’idée, c’est d’expérimenter en mode POC — donc pas de production encore, mais déjà du concret.
En temps réel, la priorité absolue, c’est de réduire la friction.
Dès que l’utilisateur arrête de taper, on envoie la valeur à l’API OpenAI.
Le système “await” nous permet d’avoir une vérification quasi instantanée : le texte part, le modèle analyse, et la réponse revient avant même que l’utilisateur ait cliqué sur “Envoyer”.
L’IA évalue la qualité du message :
– Est-il approprié ?
– Contient-il un ton agressif, déplacé, ou borderline ?
Si c’est le cas, le contenu est flaggué automatiquement, non pas par un humain, mais par un modèle GPT-5 en API. Et c’est important, car les utilisateurs finaux deviennent de plus en plus malins pour contourner les filtres automatiques.
Mais, soyons honnêtes : ce n’est pas parfait.
Les modèles de langage peuvent se tromper, manquer de contexte ou sur-interpréter certains propos. C’est là que Rubix ML entre en scène : un scoring interne, entraîné sur nos propres données, vient affiner le jugement. Le but ? Avoir une double lecture : celle du LLM pour la sémantique, et celle de Rubix ML pour la cohérence interne à la plateforme.
Rubix ML, c’est nul (enfin… pas tant que ça)
Pourquoi s’embêter avec Rubix ML alors qu’on a déjà OpenAI branché ?
C’est la question logique. Et pourtant, la réponse tient en trois lettres : PHP.
Sur ce projet, on reste dans notre écosystème — notre langage préféré — et on applique le bon vieux principe KISS(Keep It Simple, Stupid).
Notre besoin est double :
- Le message envoyé par un utilisateur est-il approprié ?
- Ce même message est-il juste grammaticalement ?
Là où OpenAI apporte une couche de modération contextuelle et linguistique globale, Rubix ML vient ajouter une analyse locale, adaptée à notre propre environnement.
En d’autres termes, on entraîne un modèle sur nos propres données, issues du terrain : messages d’élèves, échanges avec les profs, discussions internes.
Résultat :
- On peut scorer les utilisateurs sur leur régularité, leur ton, leur justesse d’expression.
- On détecte aussi les lacunes verbales pour, plus tard, proposer un accompagnement ciblé.
- Et surtout, on garde une traçabilité locale, sans dépendre d’un service externe pour chaque décision.
C’est là qu’intervient Symfony Scheduler : toutes les heures, il déclenche un ré-entraînement automatique du modèle Rubix ML. Les nouveaux messages servent de jeu d’apprentissage, affinant ainsi la précision du scoring à chaque itération.
Au bout du compte, notre application ne repose pas uniquement sur un grand modèle généraliste ; elle possède un outil de scoring fine-tuned, formé localement et spécifiquement pour notre usage.
Une brique maison, rapide, cohérente et parfaitement intégrée à notre stack PHP.
Le fonctionnement interne
Sous le capot, c’est Rubix ML qui orchestre l’intelligence locale du système.
On utilise ici l’algorithme AdaBoost, un ensemble de petits arbres de décision qui “boostent” leur précision en apprenant les erreurs des autres. L’idée : plusieurs cerveaux faibles valent mieux qu’un seul très fort.
En clair, ça fonctionne en trois temps :
- Apprentissage (Training)
Rubix ML analyse tous les messages existants — ceux autorisés et ceux bloqués par la modération OpenAI.
Il apprend à reconnaître les patterns : vocabulaire, ton, fautes, longueur, structure, etc. - Prédiction (Scoring)
À chaque nouveau message, il prédit :- si le message serait bloqué ou autorisé ;
- son score de qualité (0 à 100) ;
- et son niveau de toxicité (0 à 1).
- Stockage et suivi
Les prédictions sont stockées dans une table dédiée (message_ml_scores) :
score, confiance, version du modèle, temps de calcul…
Le chat reste indépendant : si Rubix ML tombe, le chat continue de tourner.
Les caractéristiques analysées par le modèle
Rubix ML observe dix variables par message, parmi lesquelles :
la qualité grammaticale, le sentiment, la toxicité, le nombre de fautes, la longueur, la présence d’une question, etc.
Chaque variable sert de “signal” pour le modèle AdaBoost, qui combine le tout pour décider.
L’entraînement automatique (Scheduler)
Toutes les heures, Symfony Scheduler relance un petit cycle de vie du modèle :
il récupère les nouveaux messages, entraîne le modèle, puis génère les prédictions pour ceux qui n’ont pas encore été scorés.
Durée moyenne : 50 ms pour 80 messages.
Résultat : un modèle qui s’améliore de lui-même à chaque itération, sans action humaine.
Et côté profs ?
Un dashboard Symfony affiche la santé du modèle :
- version, date du dernier entraînement, confiance moyenne, taux de blocage, etc.
- et un tableau des dernières prédictions, avec un score de confiance et un aperçu du message.
À terme, ce système permettra d’identifier les étudiants ayant des difficultés d’expression ou des comportements problématiques — tout en restant 100 % local et RGPD friendly.
Le service MessageScoringService — le cœur battant
C’est ici que tout se joue.
Le service MessageScoringService agit comme cerveau central de la logique de qualité : il reçoit le message brut, appelle le modèle de langage pour une première évaluation, puis calcule localement une série de scores linguistiques et comportementaux.
Rôle général
Ce service fait deux choses :
- Il prépare et enrichit le message avant stockage dans l’entité
Message(grâce aux résultats d’analyse venant du modèle GPT-5 mini via l’API). - Il génère des métriques internes exploitables par Rubix ML : qualité, sentiment, toxicité, engagement, catégorie, orthographe, etc.
Autrement dit, MessageScoringService est la couche d’interprétation : le pont entre la sémantique et la donnée structurée.
Comment ça marche ?
Lorsqu’un utilisateur envoie un message :
- Le texte est d’abord analysé par l’API de modération OpenAI / GPT-5 mini, qui détermine si le message est autorisé.
- Ensuite, le service calcule ses propres scores en PHP pur :
- un Quality Score (0 – 100) basé sur la longueur, la ponctuation et la clarté ;
- un Sentiment Score (–1 à +1) selon les mots positifs ou négatifs ;
- un Toxicity Score (0 – 1) via une détection de mots agressifs ou de majuscules excessives ;
- une prédiction d’engagement, c’est-à-dire la probabilité que le message reçoive une réponse ;
- une classification de catégorie : question, technique, feedback, salutation ou discussion ;
- enfin, une analyse grammaticale détaillée, avec détection des erreurs et suggestions.
Chaque message ressort donc avec une carte d’identité linguistique complète : propre, mesurable et exploitable.
Gestion des erreurs et robustesse
Si l’analyse échoue (latence API, problème réseau…), le service retourne des valeurs par défaut : un message ne bloque jamais la logique métier.
Tout est logué ; rien ne casse.
Exemple de logique interne
Le calcul de la qualité illustre bien la philosophie KISS :
// Extrait du service
if ($wordCount >= 10 && $wordCount <= 200) {
$score += 20;
}
if (preg_match('/[.!?]/', $content)) {
$score += 10;
}
if ($content === strtoupper($content) && strlen($content) > 10) {
$score -= 20; // Shouting détecté
}Une grammaire de règles simples, humaines, mais suffisante pour générer un signal statistique riche pour Rubix ML.
Intégration avec Symfony AI
Le deuxième service AI, basé sur Symfony\AI\Client, orchestre la requête au modèle GPT-5 mini.
Il hydrate ensuite l’entité Message avec les résultats retournés : c’est la passerelle entre ton code PHP et le modèle de langage distant.
Le MessageScoringService, lui, reste local : il complète l’analyse et prépare les données à injecter dans le pipeline Rubix ML.
Le service en détails
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
class MessageScoringService
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly string $modelsPath = 'var/models'
) {}
/**
* Score a message and return multiple metrics
*/
public function score(string $content): array
{
try {
$grammarAnalysis = $this->analyzeGrammar($content);
return [
'quality_score' => $this->calculateQualityScore($content),
'sentiment_score' => $this->calculateSentimentScore($content),
'toxicity_score' => $this->calculateToxicityScore($content),
'engagement_prediction' => $this->predictEngagement($content),
'category' => $this->classifyCategory($content),
'word_count' => str_word_count($content),
'has_question' => $this->detectQuestion($content),
'grammar_score' => $grammarAnalysis['score'],
'spelling_errors' => $grammarAnalysis['spelling_errors'],
'grammar_issues' => $grammarAnalysis['issues'],
];
} catch (\Exception $e) {
$this->logger->error('Message scoring failed', [
'error' => $e->getMessage()
]);
// Return default scores on error
return [
'quality_score' => 50.0,
'sentiment_score' => 0.0,
'toxicity_score' => 0.0,
'engagement_prediction' => 0.5,
'category' => 'discussion',
'word_count' => str_word_count($content),
'has_question' => str_contains($content, '?'),
'grammar_score' => 50.0,
'spelling_errors' => 0,
'grammar_issues' => [],
];
}
}
/**
* Quality Score (0-100)
* Based on: grammar, length, clarity, relevance
*/
private function calculateQualityScore(string $content): float
{
$score = 50; // Base score
// Length factor (prefer 10-200 words)
$wordCount = str_word_count($content);
if ($wordCount >= 10 && $wordCount <= 200) {
$score += 20;
} elseif ($wordCount < 10) {
$score -= 10;
}
// Punctuation (indicates proper sentences)
if (preg_match('/[.!?]/', $content)) {
$score += 10;
}
// Capitalization (proper grammar)
if (preg_match('/^[A-Z]/', $content)) {
$score += 10;
}
// Question marks (engaged students)
if (str_contains($content, '?')) {
$score += 10;
}
// All caps (shouting - reduce score)
if ($content === strtoupper($content) && strlen($content) > 10) {
$score -= 20;
}
// Multiple exclamation marks (reduce score)
if (substr_count($content, '!') > 2) {
$score -= 10;
}
return (float) max(0, min(100, $score));
}
/**
* Sentiment Score (-1 to +1)
* -1 = very negative, 0 = neutral, +1 = very positive
*/
private function calculateSentimentScore(string $content): float
{
// Simple keyword-based sentiment
$positiveWords = ['good', 'great', 'excellent', 'thanks', 'helpful', 'understand', 'clear', 'love', 'perfect', 'awesome'];
$negativeWords = ['bad', 'difficult', 'confused', 'don\'t understand', 'hard', 'hate', 'boring', 'stupid', 'terrible'];
$lower = strtolower($content);
$positive = 0;
$negative = 0;
foreach ($positiveWords as $word) {
if (str_contains($lower, $word)) $positive++;
}
foreach ($negativeWords as $word) {
if (str_contains($lower, $word)) $negative++;
}
if ($positive + $negative === 0) return 0.0;
return (float) (($positive - $negative) / ($positive + $negative));
}
/**
* Toxicity Score (0-1)
* 0 = not toxic, 1 = very toxic
*/
private function calculateToxicityScore(string $content): float
{
$toxicPatterns = [
'profanity' => ['stupid', 'dumb', 'idiot', 'hate', 'shut up', 'shut-up'],
'aggression' => ['go away', 'leave me alone', 'get lost'],
];
$score = 0.0;
$lower = strtolower($content);
foreach ($toxicPatterns['profanity'] as $word) {
if (str_contains($lower, $word)) $score += 0.3;
}
foreach ($toxicPatterns['aggression'] as $phrase) {
if (str_contains($lower, $phrase)) $score += 0.4;
}
// All caps (shouting)
if ($content === strtoupper($content) && strlen($content) > 20) {
$score += 0.2;
}
return (float) min(1, $score);
}
/**
* Engagement Prediction (0-1)
* Likelihood this message will get responses
*/
private function predictEngagement(string $content): float
{
$score = 0.0;
// Questions typically get responses
if (str_contains($content, '?')) {
$score += 0.4;
}
// @mentions
if (str_contains($content, '@')) {
$score += 0.3;
}
// Longer messages tend to get more engagement
$wordCount = str_word_count($content);
if ($wordCount >= 20 && $wordCount <= 100) {
$score += 0.2;
}
// Contains code or technical content
if (preg_match('/```|\bfunction\b|\bclass\b/', $content)) {
$score += 0.1;
}
return (float) min(1, $score);
}
/**
* Category Classification
*/
private function classifyCategory(string $content): string
{
// Simple rule-based classification
if (str_contains($content, '?')) {
return 'question';
}
if (preg_match('/```|code|function|class/i', $content)) {
return 'technical';
}
if (preg_match('/thanks|thank you|helpful/i', $content)) {
return 'feedback';
}
if (preg_match('/hello|hi|hey|good morning|good afternoon/i', $content)) {
return 'greeting';
}
return 'discussion';
}
private function detectQuestion(string $content): bool
{
return str_contains($content, '?') ||
preg_match('/\b(how|what|when|where|why|who|can|could|would|should)\b/i', $content) > 0;
}
/**
* Analyze grammar and syntax quality
* Returns: score (0-100), spelling_errors count, and array of issues
*/
private function analyzeGrammar(string $content): array
{
$score = 100; // Start with perfect score
$issues = [];
$spellingErrors = 0;
// 1. Check capitalization at start
if (!preg_match('/^[A-Z]/', trim($content))) {
$score -= 10;
$issues[] = 'Missing capitalization at sentence start';
}
// 2. Check for sentence-ending punctuation
$trimmed = trim($content);
if (strlen($trimmed) > 5 && !preg_match('/[.!?]$/', $trimmed)) {
$score -= 10;
$issues[] = 'Missing sentence-ending punctuation';
}
// 3. Check for double spaces
if (preg_match('/ +/', $content)) {
$score -= 5;
$issues[] = 'Multiple consecutive spaces';
}
// 4. Check for space before punctuation (common error)
if (preg_match('/\s+[.,!?;:]/', $content)) {
$score -= 5;
$issues[] = 'Space before punctuation';
}
// 5. Check for missing space after punctuation
if (preg_match('/[.,!?;:][a-zA-Z]/', $content)) {
$score -= 5;
$issues[] = 'Missing space after punctuation';
}
// 6. Check for common spelling mistakes (basic)
$commonMistakes = [
'/\bteh\b/i' => 'the',
'/\byuo\b/i' => 'you',
'/\brecieve\b/i' => 'receive',
'/\boccured\b/i' => 'occurred',
'/\bseperate\b/i' => 'separate',
'/\bdefinately\b/i' => 'definitely',
'/\baccomodate\b/i' => 'accommodate',
];
foreach ($commonMistakes as $pattern => $correct) {
if (preg_match($pattern, $content)) {
$spellingErrors++;
$score -= 8;
$issues[] = 'Possible spelling error detected';
}
}
// 7. Check for run-on sentences (very long without punctuation)
$sentences = preg_split('/[.!?]+/', $content);
foreach ($sentences as $sentence) {
if (str_word_count($sentence) > 30) {
$score -= 10;
$issues[] = 'Possible run-on sentence (too long)';
break;
}
}
// 8. Check for improper capitalization in middle of sentence (random caps)
if (preg_match('/[a-z][A-Z]/', $content) && !preg_match('/\b[A-Z][a-z]+\b/', $content)) {
$score -= 5;
$issues[] = 'Inconsistent capitalization';
}
// 9. Bonus points for proper grammar indicators
if (preg_match('/^[A-Z]/', $content) && preg_match('/[.!?]$/', $content)) {
$score += 5; // Bonus for complete sentences
}
// 10. Penalize all-lowercase
if ($content === strtolower($content) && strlen($content) > 10) {
$score -= 15;
$issues[] = 'No capitalization used';
}
// 11. Penalize all-uppercase (shouting)
if ($content === strtoupper($content) && strlen($content) > 10) {
$score -= 20;
$issues[] = 'All caps (shouting)';
}
return [
'score' => (float) max(0, min(100, $score)),
'spelling_errors' => $spellingErrors,
'issues' => $issues,
];
}
}




Résultats : la modération en action
Une fois tout branché — Symfony AI côté modération et le MessageScoringService côté scoring — le résultat est immédiat dans l’interface du chat.
Grâce à FrankenPHP et au controller stimulus, la modération s’effectue au fil de la frappe : l’utilisateur voit la réponse du modèle en direct, sans rechargement.
Exemple 1 — Message sain ✅
Un message positif (“bonjour, cool je suis très heureux de suivre ce cours”) passe sans encombre.
Le système affiche un retour vert : Message looks good!
Exemple 2 — Message inapproprié ❌
Un propos insultant est immédiatement intercepté par la modération.
Résultat : bannière rouge, bouton Send désactivé, et mention explicite de la raison (Explicit profanity and insult).
Exemple 3 — Risque de violence ⚠️
Certaines phrases sensibles (“vive les bombes !!!”) déclenchent un warning jaune : Potential glorification of violence.
Le message n’est pas bloqué mais signalé, laissant au contexte le soin de décider.
Exemple 4 — Propos grossiers modérés 🟡
Un message légèrement vulgaire (“tu es trop con !”) passe en mode mild profanity : le système signale la tournure sans bloquer totalement.
Ce que cela démontre
Cette série de captures illustre trois points essentiels :
- la latence quasi nulle de la modération grâce à FrankenPHP (le modèle répond avant la soumission) ;
- une gradation intelligente des alertes, de l’avertissement à l’interdiction ferme ;
- et la complémentarité parfaite entre OpenAI pour le temps réel et Rubix ML pour l’analyse en profondeur.
Conclusion
Encore une fois, ce POC montre qu’une idée simple peut aller très loin.
PHP prouve qu’il sait encore innover : avec la bonne architecture, il peut parler IA, faire du temps réel et scorer des comportements sans jamais quitter son écosystème.
Pas besoin de Python pour jouer avec des modèles intelligents.
L’architecture n’est peut-être pas parfaite, mais le résultat est clair :
- OpenAI agit comme un garde-fou instantané, gérant la modération en direct ;
- Rubix ML s’occupe du scoring et de l’apprentissage en arrière-plan ;
- et tout cela tourne tranquillement dans Symfony, orchestré par le Scheduler.
Ce qu’on a construit, c’est une IA multi-niveaux :
temps réel pour modérer, asynchrone pour apprendre.
Un équilibre entre puissance et sobriété, parfaitement adapté à un environnement de classe numérique.
Et désormais, grâce à Symfony AI — le nouveau projet officiel du framework — cette intégration devient encore plus naturelle.
Les composants proposés (Chat, Agent, Platform, Store) vont permettre d’aller plus loin : faire dialoguer directement nos services avec des modèles, orchestrer des agents, stocker du contexte, ou même combiner plusieurs fournisseurs IA sans réécrire le code.
Bref, Symfony n’est plus seulement un framework web : il devient un framework d’intelligence applicative.
PHP reste donc bien vivant, et cette V3 en est la preuve :
du temps réel, du machine learning, de la modération, le tout dans une seule stack.
Et ce n’est qu’un début — la prochaine étape ?
Peut-être un agent IA pédagogique embarqué directement dans la plateforme, grâce à Symfony AI.
Cet article t’a plu ?
Sur mon blog il y a de tout mais surtout du Symfony. Du coup si tu aimes bien ça jette un oeil sur cet article.
Laisser un commentaire