Aller au contenu
Sylvain Joffraud l4w
Retour

Assistant LLM : la détection d’urgence sans le modèle

Pour la sécurité d’un assistant LLM, la détection d’urgence ne doit pas dépendre du modèle : sur l’assistant du Journal des Nuisances, elle est déterministe, tourne sur l’appareil avant tout appel, et affiche une carte de numéros d’urgence même si le serveur du modèle ne répond pas. Ce garde-fou déterministe avait pourtant un défaut que les tests ne voyaient pas : il comparait des expressions entières. « Ça pue le gaz » ou « envie den finir » ne déclenchaient rien. La recette du 7 octobre l’a révélé. Sur un corpus écrit sans lire le code, une première refonte n’attrapait encore que 4 situations sur 24 ; la seconde, 24 sur 24, sans fausse alerte.

Pourquoi un garde-fou déterministe plutôt que le LLM ?

L’assistant du Journal des Nuisances, un produit que j’ai co-fondé, répond grâce à un modèle local servi par Ollama sur pc-llm, un PC Snapdragon en CPU, derrière une Supabase Edge Function. Le modèle peut être occupé ou en panne, l’appareil hors ligne, au moment même où quelqu’un écrit qu’il y a de la fumée dans l’escalier.

La détection d’exposition est donc une fonction TypeScript pure, detecterExposition(), qui classe un message en danger, represailles, detresse ou rien. Si elle reconnaît quelque chose, l’écran affiche la carte des numéros avant toute réponse du modèle. Et une fonction pure se teste.

La détection d’urgence par expressions entières

La première version normalisait la casse, les accents et les apostrophes, puis cherchait des expressions :

const DANGER: RegExp[] = [
  /\bodeur de (gaz|brule)/,
  /\bfuite de gaz/,
  /\b(fumee noire|beaucoup de fumee|de la fumee (sort|partout|dans l'escalier))/,
  // …
];
const DETRESSE: RegExp[] = [/\ben finir\b/, /\bsuicid/ /* … */];

« Ça pue le gaz » ne contient ni « odeur de gaz » ni « fuite de gaz ». « J’ai envie den finir », sans apostrophe, échoue sur \ben finir\b : il n’y a pas de frontière de mot entre le d et le e. Une apostrophe décidait d’une idée suicidaire.

Le banc d’essai ne pouvait pas le voir : les cas de test et les motifs avaient été écrits avec les mêmes mots.

Régler sur le rappel, pas sur la précision

Le rappel est la part des vraies situations à risque que le détecteur attrape ; la précision, la part de ses alertes qui sont justes. La règle retenue est écrite en commentaire au-dessus des motifs :

Une fausse alerte coûte une carte de numéros ; une alerte manquée peut coûter une vie.

Trois changements en découlent.

  1. Normaliser l’écriture réelle plutôt qu’écrire chaque variante : apostrophes manquantes, abréviations SMS, lettres répétées.
  2. Raisonner par indices : un objet de danger et un signe de présence, dans n’importe quel ordre.
  3. Lire la négation : « il n’y a aucune fumée, c’est une odeur de crêpes » affichait la carte. On retire le seul nom nié, pas la phrase.

Un extrait, volontairement partiel et simplifié :

export function normaliser(texte: string): string {
  return texte
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .replace(/[’`´]/g, "'")
    .toLowerCase()
    .replace(/([a-z])\1{2,}/g, "$1")       // « aaaide » → « aide »
    .replace(/\bden\b/g, "d'en")
    .replace(/\bjai\b/g, "j'ai")
    .replace(/\b([cdjlmnst]) (?=[aeiouyh])/g, "$1'"); // « d en » → « d'en »
}

// Gaz + un signe d'odeur ou de fuite, dans n'importe quel ordre.
const GAZ = /^(?=.*\bgaz\b)(?=.*\b(pue|sent|odeur|fuite|respirer)\b)/s;

Les contre-tests protègent le seuil : « ce bruit me rend fou » ou la fumée de cigarette ne déclenchent rien.

Un corpus écrit sans lire le code, rejoué en CI

Le corpus de rappel réunit des phrases écrites sans avoir lu le fichier des motifs (securite.ts), versées telles quelles, fautes comprises, avec une consigne : ne jamais les ajuster aux motifs. Il en compte 111 : celles de la recette du 7 octobre par le product owner, 48 rédigées par un autre LLM sans accès au code, et une campagne de test.

Un test Vitest (it.each) rejoue chaque phrase en CI : un échec veut dire qu’une carte de sécurité ne s’afficherait pas. Sur les 48 phrases du 9 octobre (24 situations à risque, 24 contre-exemples), il a fallu en plus lire la négation et reconnaître les dangers par leurs signes (une prise qui grésille, une porte brûlante, une menace rapportée) :

VersionSituations détectéesFaux déclenchements
Normalisation et premiers indices (8-9 octobre)4/242/24
+ négation et dangers reconnus par leurs signes (10 octobre)24/240/24

Ces motifs ayant été écrits en voyant le corpus, 24 phrases écrites à part les éprouvent dans les deux sens.

Le filet sous le filet, et les numéros

Deux mesures ne dépendent d’aucune détection.

Le banc qui juge les réponses du modèle a ses propres pièges, détaillés dans l’article sur le LLM-juge et le time-out fetch failed.

Lessons learned

Questions fréquentes

Pourquoi ne pas confier la détection d’urgence au LLM lui-même ?
Parce que le LLM peut être indisponible, saturé ou hors ligne au moment précis où la personne décrit un danger, et que sa réponse varie d’un appel à l’autre. Une détection déterministe tourne sur l’appareil avant tout appel au modèle : la carte des numéros d’urgence s’affiche même si le serveur du modèle ne répond pas, et le même texte donne toujours le même résultat, ce qui se teste.
Faut-il régler un détecteur d’urgence sur le rappel ou sur la précision ?
Sur le rappel : manquer une vraie urgence coûte bien plus cher qu’afficher une carte de numéros à tort. Concrètement, on accepte quelques fausses alertes, on raisonne par indices (gaz plus odeur, fumée hors cigarette) plutôt que par phrases exactes, et on protège seulement le seuil par des contre-tests du type « ce bruit me rend fou », qui ne doit rien déclencher.
Comment tester une liste de motifs regex sans biais ?
Avec un corpus écrit par quelqu’un qui n’a pas lu les motifs, versé tel quel, fautes comprises, et rejoué en CI à chaque commit. Un banc dont les cas et les motifs sont rédigés par la même personne, avec les mêmes mots, mesure surtout sa propre cohérence. Le corpus doit aussi contenir des contre-exemples pour mesurer les faux déclenchements.
Un LLM peut-il recopier un numéro de téléphone sans erreur ?
Pas de façon fiable : sur l’assistant du Journal des Nuisances, le modèle abîmait des numéros en les recopiant depuis son contexte. La parade est de ne plus lui transmettre les numéros : il nomme le contact, et l’interface affiche le numéro depuis l’annuaire. Tout autre numéro écrit par le modèle, hors numéros nationaux, est masqué à l’affichage.

Partager cet article :

Article précédent
rsync `--link-dest` sans `-H` : les snapshots explosent
Article suivant
MLX `Attempting to eval an array` : `.item()` en compilé