fix(safety,rag): correctifs critiques code-review

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) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-11 16:07:51 +02:00
parent 32669ae039
commit e1d9c94751
3 changed files with 62 additions and 18 deletions

View File

@ -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

View File

@ -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<Regex> = 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) }
}
}

View File

@ -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