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.
- Normaliser l’écriture réelle plutôt qu’écrire chaque variante : apostrophes manquantes, abréviations SMS, lettres répétées.
- Raisonner par indices : un objet de danger et un signe de présence, dans n’importe quel ordre.
- 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) :
| Version | Situations détectées | Faux déclenchements |
|---|---|---|
| Normalisation et premiers indices (8-9 octobre) | 4/24 | 2/24 |
| + négation et dangers reconnus par leurs signes (10 octobre) | 24/24 | 0/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.
- Chaque repli rappelle le 112. Indisponible, occupé, plafond atteint, hors ligne : la réponse de repli finit par « Si quelqu’un est en danger maintenant, appelez le 112. »
- Le modèle n’écrit plus de numéros. Il les abîmait en les recopiant. Il nomme désormais le contact, et l’écran affiche le numéro depuis l’annuaire ; tout autre numéro qu’il écrirait, hors numéros nationaux, est masqué.
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
- Une détection d’urgence ne doit pas dépendre du LLM. Une fonction déterministe sur l’appareil fonctionne même quand le modèle est hors service.
- Un détecteur de danger se règle sur le rappel. Une fausse alerte coûte une carte de numéros, une alerte manquée peut coûter une vie.
- Des tests écrits par l’auteur des motifs ne mesurent pas le rappel. Il faut un corpus rédigé sans lire le code, versé tel quel et rejoué en CI.
- Un LLM ne doit pas recopier un numéro d’urgence. L’interface l’affiche depuis un annuaire, le modèle se contente de nommer le contact.