From e1d9c94751e40dd9f7545012064b403ffff84b53 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 11 Jun 2026 16:07:51 +0200 Subject: [PATCH] fix(safety,rag): correctifs critiques code-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrisisGuard (sécurité vitale) : - normalize() : suppression accents (NFD) + apostrophes→espace + compactage, pour matcher la sortie STT FR (souvent sans accents) et la frappe libre. - PATTERNS étendus : formulations directes manquantes (« je veux mourir », « idées suicidaires/noires », « disparaître », « me pendre », « passer à l'acte », « plus la force de vivre »…). « me pendre » autonome (aucun usage bénin) ; « me noyer » reste gardé (idiome « se noyer dans le travail »). - Tests étendus : 12 positifs + 4 négatifs (idiomes « mourir de rire/faim », « idées plein la tête », « passe à la pharmacie »). 11/11 verts. Rag.retrieve() : un chunk plus long que le budget ne fait plus retomber tout le bloc à null — on garantit ≥1 hit (tronqué si besoin) puis on empile tant qu'il reste du budget. Évite la perte de contexte pertinent. Rag.close() : @Synchronized — sérialise avec retrieve()/searchScored() pour qu'un teardown (toggle RAG off) ne libère pas le contexte natif de l'embedder pendant une requête provider en cours (évite le SIGSEGV). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/main/java/com/kazeia/rag/Rag.kt | 20 +++++++--- .../java/com/kazeia/safety/CrisisGuard.kt | 39 ++++++++++++++----- .../java/com/kazeia/safety/CrisisGuardTest.kt | 21 +++++++++- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt index 9c15d1b..a3eec3e 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt @@ -122,16 +122,21 @@ class Rag( val hits = idx.search(qv, k, threshold) if (hits.isEmpty()) return null + // On garantit l'injection d'AU MOINS le meilleur hit (tronqué si besoin), + // puis on empile les suivants tant qu'il reste du budget. Un chunk long ne + // doit jamais faire retomber tout le bloc à null (perte de contexte pertinent). val sb = StringBuilder("CONTEXTE DES DOCUMENTS :\n") + val header = sb.length for (h in hits) { - val t = db.textOf(h.chunkId) ?: continue - val line = "- $t\n" - if (sb.length + line.length > budgetChars + 24) break - sb.append(line) + val t0 = db.textOf(h.chunkId) ?: continue + val room = budgetChars + header - sb.length // place restante pour ce chunk + if (room < 24) break // plus de place utile + val t = if (t0.length + 3 > room) t0.take(room - 4).trimEnd() + "…" else t0 + sb.append("- ").append(t).append('\n') } val out = sb.toString().trim() log("retrieve: ${hits.size} hits (top=${"%.2f".format(hits.first().score)}), ${out.length} chars injectés") - return if (out.length > "CONTEXTE DES DOCUMENTS :".length) out else null + return if (sb.length > header) out else null } fun count(): Int = db.count() @@ -139,7 +144,10 @@ class Rag( fun pendingCount(): Int = db.docsNeedingIngest().size fun dim(): Int = embedder.dim fun clear() { db.clear(); index = null } - fun close() { embedder.close() } + // @Synchronized : sérialise avec searchScored()/retrieve() (mêmes verrous) pour + // qu'un teardown (toggle RAG off) ne libère pas le contexte natif de l'embedder + // pendant qu'une requête du provider (test admin) est en cours → évite le SIGSEGV. + @Synchronized fun close() { embedder.close() } /** Récupération AVEC scores, pour le test/preview admin (n'injecte rien). */ @Synchronized diff --git a/kazeia-android/app/src/main/java/com/kazeia/safety/CrisisGuard.kt b/kazeia-android/app/src/main/java/com/kazeia/safety/CrisisGuard.kt index 1689542..1af997a 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/safety/CrisisGuard.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/safety/CrisisGuard.kt @@ -24,20 +24,39 @@ object CrisisGuard { "S'il te plaît, parle dès maintenant à une personne de confiance, ou appelle le 112. " + "Tu n'es pas seul, et ta vie compte." - // Motifs (regex, sur le message en minuscules). Tunables par le clinicien. + // Motifs (regex, appliqués sur le message NORMALISÉ : minuscules SANS accents, + // cf normalize() — le STT FR supprime souvent les accents). Donc on écrit les + // motifs sans accents. Tunables/extensibles par le clinicien (jamais retirer un + // positif sans test). Couvre suicide, mort voulue, disparition, auto-agression, + // fardeau, passage à l'acte. private val PATTERNS: List = listOf( - "me suicider", "\\bsuicide", "me tuer", "me foutre en l['e ]air", - "en finir", "mettre fin à (mes? jours?|ma vie)", - "envie de mourir", "envie d['e ]en finir", "(plus|pas) envie de vivre", - "(ne |n')?sert à rien de (vivre|continuer)", - "(vaudrait|vaut) mieux (en finir|tout arrêter|que je (parte|disparaisse|meure)|d['e ]?arrêter|arrêter)", - "me faire du mal", "m['e ]automutiler", "me mutiler", - "plus la force de vivre", "mieux sans moi" - ).map { Regex(it, RegexOption.IGNORE_CASE) } + "me suicider", "suicide", "suicidaire", "idee[s]? noire", + "me tuer", "me foutre en l", "en finir", + "mettre fin a (mes? jours?|ma vie|tout)", + "(envie|veux|voudrais|aimerais) (de |d )?mourir", + "(ne |n )?(veux|veut|voudrais) plus vivre", + "(plus|pas|aucune) envie de vivre", "envie d en finir", + "(ne |n )?sert (a|plus) rien de (vivre|continuer)", + "(vaudrait|vaut) mieux (en finir|tout arreter|que je (parte|disparaisse|meure)|d ?arreter|arreter)", + "me faire du mal", "m ?automutiler", "me mutiler", "me scarifier", + "plus la force de vivre", "mieux sans moi", "tous mieux sans moi", + "me pendre", // aucun usage bénin → autonome + "(veux|voudrais|envie de) (disparaitre|en finir|me noyer)", + "me jeter (par|sous|dans|du)", "passer a l acte" + ).map { Regex(it) } + + /** minuscules + suppression des accents (NFD → retrait des diacritiques) + + * apostrophes (droites/typographiques) → espace + espaces compactés. Permet + * d'écrire les motifs sans accents ni apostrophes (robuste au STT et à la frappe). */ + private fun normalize(s: String): String = + java.text.Normalizer.normalize(s.lowercase(), java.text.Normalizer.Form.NFD) + .replace(Regex("\\p{Mn}+"), "") + .replace(Regex("['’‘]"), " ") + .replace(Regex("\\s+"), " ") /** true si le message évoque une idéation suicidaire / auto-agression. */ fun detect(message: String): Boolean { - val m = message.lowercase() + val m = normalize(message) return PATTERNS.any { it.containsMatchIn(m) } } } diff --git a/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt b/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt index 0499d1f..394e83e 100644 --- a/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt +++ b/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt @@ -50,7 +50,20 @@ class CrisisGuardTest { "je vais m'automutiler ce soir", // épuisement vital / fardeau "je n'ai plus la force de vivre", - "ils seraient mieux sans moi" + "ils seraient mieux sans moi", + // formulations directes les plus fréquentes (trous de rappel comblés 2026-06-11) + "je veux mourir", + "je voudrais mourir ce soir", + "je ne veux plus vivre", + "j'ai des idées suicidaires", + "j'ai des idées noires en ce moment", + "je veux disparaître", + "je pense à me pendre", + "je vais passer à l'acte", + // accents supprimés (sortie STT fréquente) — DOIVENT matcher quand même + "je veux mettre fin a mes jours", + "ca sert a rien de vivre", + "je nai plus la force de vivre" ) @Test @@ -72,7 +85,11 @@ class CrisisGuardTest { "la journée a été dure mais ça va aller", "je n'ai plus la force de faire le ménage", // « plus la force de … » ≠ « … de vivre » "mon père est mort l'année dernière, il me manque", // parler de la mort d'autrui ≠ idéation - "ce film parle d'un homme qui veut tout plaquer" + "ce film parle d'un homme qui veut tout plaquer", + "c'était à mourir de rire hier soir", // idiome « mourir de rire » ≠ « veux mourir » + "je vais mourir de faim si je ne mange pas", // idiome « mourir de faim » + "j'ai des idées plein la tête pour le projet", // « idées » sans « noires »/« suicidaires » + "il faut que je passe à la pharmacie" // « passe à » ≠ « passer à l'acte » ) @Test