sMug@replicatorbe
~/Publications/Communautés/j-ai-audite-mon-propre-detecteur

J'ai audité mon propre détecteur

Communautés / /12 min de lecture

Un outil qui repère des approches d'adultes vers des mineurs dans les messages privés d'un chat. Comment il fonctionne, ce que coûtent réellement ses règles d'exclusion — et ce que j'ai trouvé le jour où je l'ai relu ligne à ligne en me demandant, pour chaque détecteur, s'il servait vraiment à quelque chose.

Un détecteur peut être élégant, testé, documenté, et ne pas être branché sur la décision finale. Personne ne s'en aperçoit tant que personne ne vérifie.

C'est la phrase que j'ai écrite en haut de mes notes, après avoir relu de bout en bout un outil que j'ai écrit moi-même, qui a tourné en production, et dont je pensais connaître le comportement. Je le raconte parce que l'écart entre ce qu'un système est censé faire et ce qu'il fait réellement est plus instructif que le système lui-même.

Le problème, tel qu'il se pose

Un chat francophone ouvert, sans inscription. J'ai décrit ailleurs comment on protège un réseau comme celui-là : la porte d'entrée, les vagues de connexions, les filtres de contenu. Ces protections-là travaillent à deux endroits — à l'entrée du réseau, avant même que la personne ait rejoint quoi que ce soit, et ensuite dans les salons.

Le problème dont je parle ici échappe aux deux. Les approches d'adultes vers des mineurs se passent en message privé, entre deux personnes déjà entrées et qui ne partagent peut-être aucun salon — là où la modération classique ne voit rien — et c'est précisément pour ça que le filtre du serveur sert aussi à router ces messages vers un robot plutôt qu'à les bloquer.

Et elles ne tiennent pas dans une phrase. C'est une séquence qui se déroule sur une session entière, dont chaque étape prise isolément est parfaitement anodine : on demande l'âge, puis la ville, puis les horaires des parents, puis on propose de continuer ailleurs. La littérature sur le sujet décrit cet enchaînement depuis longtemps, et il n'a rien de secret. Ce qui est répréhensible n'est pas le mot, c'est l'ordre dans lequel les mots arrivent.

Un filtre par mots-clés sur des messages isolés ne peut donc pas fonctionner. Pas « fonctionne mal » : ne peut pas.

Ce qu'un tel outil voit, et ce qu'il en garde

Autant le dire tout de suite, parce que c'est la première question qu'on se pose en lisant le paragraphe précédent. Le robot voit passer des messages privés. C'est une capacité lourde, et elle demande trois choses.

D'abord une raison proportionnée : elle tient au fait que l'abus visé ne se produit nulle part ailleurs, et qu'aucune autre couche ne peut le voir. Ensuite une limite claire sur ce qui reste : le contenu vit en mémoire le temps de l'analyse, il n'est ni réécrit sur un salon, ni stocké en base ; ce qui subsiste, ce sont des compteurs et des alertes. Enfin l'information des utilisateurs, affichée et non enfouie.

Un outil qui regarde des conversations privées sans pouvoir répondre à ces trois points-là n'est pas un outil de modération, c'est de la surveillance.

Reste une réserve, sans laquelle ce paragraphe serait malhonnête. Le filtre local ne tranche pas tout, et les cas qu'il laisse en suspens partent vers un second niveau d'arbitrage : l'API de modération d'OpenAI, la même dont j'ai décrit ailleurs les angles morts en français. Elle est gratuite, et ça m'enlève l'argument le plus commode — ce n'est pas le coût qui justifie de trancher chez soi d'abord, c'est qu'il n'y a aucune raison d'envoyer dehors ce qu'on sait décider à la maison. Ce qui part n'est d'ailleurs pas un compteur : c'est le message, le pseudonyme et l'adresse. C'est le seul endroit où du contenu quitte mon serveur, et cette couche a tourné en production.

Elle est désactivée par défaut dans le code. C'est la bonne valeur par défaut, et ça ne m'exonère de rien, puisque c'est moi qui l'ai activée. J'en tire deux conséquences. La première est technique et tient en deux lignes : l'adresse du service doit être configurable, pour qu'un modèle auto-hébergé puisse prendre sa place et que plus rien ne sorte. La seconde est qu'une couche pareille se déclare exactement comme le reste : gratuite ou non, ce n'est pas un détail d'implémentation, c'est un tiers de plus dans la conversation.

Scorer la session, pas le message

Toute l'architecture découle du constat précédent : le score s'accumule par session de conversation entre deux interlocuteurs, jamais par message.

Deux mécanismes en dérivent. Le premier : le même contenu ne vaut pas le même score selon la situation — le facteur dominant, et de très loin, est l'identification de l'interlocuteur comme mineur. Le second : plusieurs catégories différentes dans une même session pèsent plus lourd que la répétition d'une seule. C'est ce qui distingue une progression d'une insistance.

À côté de ça, une catégorie sans rapport mais qui pollue tout : l'arnaque sentimentale de masse, qui produit exactement les mêmes signaux d'approche.

Deux détections se passent complètement du vocabulaire, et ce sont les plus robustes. Le contact de masse compte les destinataires distincts sur des fenêtres glissantes : quelqu'un qui aborde beaucoup de monde se signale sans avoir écrit un seul mot suspect. La quasi-duplication compare les messages par empreinte et similarité : le même message d'approche reformulé reste reconnaissable. Celles-là ne se contournent pas en changeant de mots, seulement en changeant de comportement — ce qui est le but.

La moitié invisible : les exclusions

C'est le passage contre-intuitif, et c'est le vrai sujet.

Détecter est facile. Ne pas se tromper coûte cher. Pour chaque règle de détection, il faut sa symétrique. Une question sur l'âge n'a pas le même sens quand on parle d'un personnage de jeu, d'un animal, d'une voiture, d'un compte créé il y a longtemps ou d'une ancienneté professionnelle. Un refus de donner un contact ne doit pas compter comme une sollicitation.

Ce catalogue d'exclusions ne s'écrit pas à la conception. Il se paie en faux positifs, observés un par un, à mesure qu'ils tombent. C'est le véritable actif du système — écrire les règles prend une journée, les rendre justes prend des mois — et c'est ce qui sépare un outil qui a tourné d'un prototype.

Corollaire que j'assume : le système est explicitement conçu pour ne rien faire entre adultes consentants. Et le réseau avait exactement ce qu'il fallait pour le faire proprement : un mode détourné pour l'occasion, et un realname qui encode l'âge et le consentement déclarés de part et d'autre. Deux informations explicites, posées par les intéressés eux-mêmes — de quoi n'avoir jamais à deviner.

En relisant le code, j'ai découvert que le détecteur ne lit ni l'un ni l'autre. Le signal que le serveur lui transmet ne les transporte pas jusqu'à lui, et il ne les a jamais demandés. Alors il infère la majorité : des formulations de la conversation, et dans certains cas du seul pseudonyme. Le garde-fou que je croyais déclaré était une déduction.

Il faut aller au bout du raisonnement, parce que c'est là qu'il fait mal. Le contexte adulte abaisse les scores. Une majorité déduite à tort abaisse donc les scores de quelqu'un qu'il fallait protéger — un pseudonyme qui fait adulte suffit à alléger ce qu'on lui envoie. L'erreur ne se contente pas de rater sa cible, elle se retourne contre la bonne.

L'intention, elle, ne bouge pas : entre deux adultes qui se sont déclarés l'un et l'autre, ce n'est plus mon affaire. C'est même le seul endroit où j'accepte qu'un utilisateur désarme lui-même une détection — encore faut-il que ce soit lui qui le fasse, et pas le programme à sa place.

D'où le point le plus délicat : reconnaître qu'un utilisateur déclare son âge, c'est reconnaître des dizaines de formulations, dont beaucoup sont ambiguës. Chacune porte un coefficient de confiance, et rien ne se déclenche en dessous d'un niveau élevé. Le problème miroir est plus intéressant encore : l'adulte qui se prétend mineur. Deux erreurs symétriques, aux conséquences opposées — ne pas protéger un vrai mineur, ou sanctionner sur une fausse déclaration. Je n'ai pas de réponse satisfaisante à ça, seulement un arbitrage, et il consiste à ne jamais faire reposer une sanction sur cette seule donnée.

Enfin, quand quelqu'un revient sous un autre pseudo depuis une autre adresse, le rapprochement se fait sur des signaux faibles. J'ai écrit un article entier sur ce que vaut chacun d'eux ; la règle, ici, est la même que celle que j'ai apprise en modérant un réseau à quatorze ans : ce rapprochement alerte, il ne sanctionne pas. Un faisceau d'indices n'est pas une preuve, et une détection automatique qui se trompe fait bannir un innocent.

Ce que l'audit a trouvé

Voilà pour ce que le système est censé faire.

Je ne cherchais pas ça. Je cherchais pourquoi une alerte n'était jamais arrivée dans le salon de supervision — un détail, une demi-heure de travail au pire. J'ai fini par relire le tout, fichier par fichier, en me posant pour chaque détecteur une question bête : est-ce que sa sortie est lue par quelqu'un ?

Parce que je n'ai pas cherché des bugs. J'ai suivi chaque signal depuis l'endroit où il est produit jusqu'à la décision qu'il est censé provoquer. Ce n'est pas la même lecture : un bug finit par se voir, alors qu'un signal qui ne va nulle part ne casse rien, ne lève aucune erreur, et n'a aucune raison d'attirer le regard.

Le résultat est le morceau que j'aurais préféré ne pas écrire.

Ce que je croyaisCe que le code fait
Les seuils se règlent dans la configurationLe fichier n'est jamais lu ; les seuils réels sont figés dans le code
Le score qui agrège tous les détecteurs décide des sanctionsIl ne sert qu'aux journaux ; la décision relit le score de base
Le bonus de combinaison est appliqué une foisIl l'est à deux endroits du parcours. Le multiplicateur de récidive aussi
Une combinaison vaut un bonus donnéDeux tables divergentes coexistent : le score dépend du chemin de code
Le fichier de motifs chargé au démarrage sert à quelque choseIl est chargé, son absence fait planter le robot, et il n'est jamais utilisé
Les alertes partent sur le canal de supervisionLe code lit une clé, la configuration en documente une autre

Et la ligne qui explique toutes les autres : la suite de tests n'a jamais été verte, quelle que soit la version des outils. Couverture réelle mesurée : 14 %.

Reprenons la deuxième ligne, parce que c'est celle qui fait mal. Plusieurs détecteurs dont je viens d'expliquer le fonctionnement avec un certain contentement — dont ceux qui ne dépendent pas du vocabulaire — calculent correctement, journalisent correctement, et n'influencent pas les mesures prises. Ils marchent. Ils ne servent à rien.

À quoi il faut ajouter celui que j'ai déjà raconté plus haut, et qui reste le plus grave : le contexte adulte que je croyais lu sur le réseau était deviné.

Aucun de ces écarts n'est spectaculaire. Aucun ne vient d'une erreur d'algorithme. Ce sont sept endroits où deux morceaux de code ne se sont pas mis d'accord, et où rien ne vérifiait qu'ils le fassent. C'est exactement ce que j'écrivais à propos d'un filtre dont on ne compte pas les déclenchements : celui qui ne se déclenche plus ne manque à personne.

J'ai passé une partie de mes débuts à écrire des invariants de boucle et à vérifier qu'ils tenaient. Un détecteur est un invariant comme un autre — la seule différence, c'est que personne ne compile l'intention.

Pourquoi je ne publie ni les motifs, ni les seuils, ni les exclusions

Le code de ce projet ne sera pas mis en ligne tel quel, et je préfère dire pourquoi plutôt que laisser croire à une coquetterie.

Un motif de détection publié, c'est un motif contourné. Une valeur de seuil publiée, c'est la marche sur laquelle s'arrêter. Mais le fichier réellement dangereux n'est aucun des deux : ce sont les exclusions. Publier ce qui annule une détection, c'est publier la phrase à insérer pour passer au travers. Le catalogue qui m'a coûté le plus de travail est aussi celui qui rendrait le reste inutile en une après-midi.

Il y a le même raisonnement derrière un paramètre d'apparence anodine, une longueur minimale en deçà de laquelle deux messages ne sont pas comparés entre eux. Connu, il suffit à rendre invisible une campagne de contact de masse. Ne pas le publier ne coûte rien.

Ce qui peut sortir, en revanche, c'est tout ce qui précède : la méthode, la structure du raisonnement, les erreurs. Le découpage que je retiens tient en deux dépôts — un moteur public avec de quoi le faire tourner et un document de méthode, les règles dans un dépôt privé.

Ce que je retiens

  • La calibration est le travail. Le premier jet des règles tient dans une journée. Ce qui vient après, et qui n'a pas de fin, c'est de les rendre justes.
  • L'adversaire s'adapte, donc un détecteur figé se périme. Écrire un âge autrement, le suggérer par l'année de naissance ou le niveau scolaire : le cycle est permanent.
  • L'outil ne décide pas, il priorise l'attention humaine. C'est la seule position tenable quand on sait ce que vaut chaque indice.
  • Un automate qui s'emballe fait plus de dégâts qu'un abus. Refuser les masques de bannissement trop larges, assainir les entrées, et relire les expressions régulières — un motif mal formé gèle un service entier sur une seule ligne d'entrée.
  • Un détecteur juste et non branché vaut zéro. Et rien dans une suite de tests rouge depuis toujours ne viendra vous le dire.

Le dernier point est celui que je garde. J'ai pris l'habitude de vérifier une configuration avant de la recharger ; je n'ai jamais pris celle de revenir six mois plus tard vérifier que ce que j'avais écrit servait encore à quelque chose. Relire son propre travail ancien est désagréable et rentable, et je ne vois pas au nom de quoi ça s'arrêterait au code des autres.