test: tests unitaires JVM — CrisisGuard (sécurité vitale) + VectorIndex + Chunker
Premier harnais de tests du projet. testImplementation junit:4.13.2, src/test/. CrisisGuardTest — NON-RÉGRESSION DE SÉCURITÉ : 26 formulations d'idéation qui DOIVENT déclencher (toutes les familles de motifs, casse, variantes STT collées validées device), 10 messages de détresse ordinaire qui ne doivent PAS déclencher (tristesse, insomnie, colère, deuil d'autrui, « envie de dormir »…), 2 limites ASSUMÉES documentées (sensibilité d'abord : « en finir avec ces insomnies » et mention indirecte du suicide déclenchent), invariants de la réponse (112, honnêteté « programme », anti-isolement). Toute retouche des PATTERNS doit garder ces tests verts ; le clinicien ÉTEND les cas, ne supprime pas de positif. VectorIndexTest : classement cosinus, top-k, seuil, index vide/dim incohérente. ChunkerTest : tag source, découpe+overlap sous la limite ubatch, texte vide. ./gradlew :app:testDebugUnitTest → 11 tests, 0 échec, <5 s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d834ea4955
commit
e5b48e4e0f
|
|
@ -109,6 +109,10 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Tests unitaires JVM (src/test/) — logique pure : CrisisGuard, VectorIndex, Chunker.
|
||||||
|
// Lancer : ./gradlew :app:testDebugUnitTest
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
// Android
|
// Android
|
||||||
implementation("androidx.core:core-ktx:1.15.0")
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChunkerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `texte court - un seul chunk tague par la source`() {
|
||||||
|
val chunks = Chunker.chunk("Une phrase simple.", source = "doc")
|
||||||
|
assertEquals(1, chunks.size)
|
||||||
|
assertTrue(chunks[0].startsWith("[Source: doc]"))
|
||||||
|
assertTrue(chunks[0].contains("Une phrase simple."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `texte long - decoupe avec overlap, sous la limite ubatch`() {
|
||||||
|
val sentence = "Ceci est une phrase de longueur raisonnable pour le test. "
|
||||||
|
val long = sentence.repeat(60) // ~3500 chars
|
||||||
|
val chunks = Chunker.chunk(long, source = "long", maxChars = 1200, overlap = 200)
|
||||||
|
assertTrue("attendu >1 chunk", chunks.size > 1)
|
||||||
|
// Contrainte embedder : chaque chunk doit rester ~sous 512 tokens (~2000 chars marge incluse)
|
||||||
|
for (c in chunks) assertTrue("chunk trop long: ${c.length}", c.length < 2000)
|
||||||
|
// L'overlap recopie la fin du chunk N au début du N+1 → du texte partagé existe
|
||||||
|
val tail = chunks[0].takeLast(80).substringAfter("] ")
|
||||||
|
assertTrue(tail.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `texte vide ou blanc - aucun chunk`() {
|
||||||
|
assertTrue(Chunker.chunk("", "x").isEmpty())
|
||||||
|
assertTrue(Chunker.chunk(" \n\n ", "x").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VectorIndexTest {
|
||||||
|
|
||||||
|
private fun unit(vararg v: Float): FloatArray = l2normalize(v.copyOf())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classement par cosinus decroissant et top-k`() {
|
||||||
|
val idx = VectorIndex(3)
|
||||||
|
idx.add(1L, unit(1f, 0f, 0f))
|
||||||
|
idx.add(2L, unit(0.9f, 0.1f, 0f))
|
||||||
|
idx.add(3L, unit(0f, 1f, 0f))
|
||||||
|
val hits = idx.search(unit(1f, 0f, 0f), k = 2, threshold = 0f)
|
||||||
|
assertEquals(2, hits.size)
|
||||||
|
assertEquals(1L, hits[0].chunkId) // identique → score max
|
||||||
|
assertEquals(2L, hits[1].chunkId) // proche → 2ᵉ
|
||||||
|
assertTrue(hits[0].score > hits[1].score)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `le seuil filtre le hors-sujet`() {
|
||||||
|
val idx = VectorIndex(3)
|
||||||
|
idx.add(1L, unit(1f, 0f, 0f))
|
||||||
|
idx.add(2L, unit(0f, 1f, 0f)) // orthogonal → cos = 0
|
||||||
|
val hits = idx.search(unit(1f, 0f, 0f), k = 5, threshold = 0.5f)
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals(1L, hits[0].chunkId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index vide ou dimension incoherente - resultat vide, pas de crash`() {
|
||||||
|
val idx = VectorIndex(3)
|
||||||
|
assertTrue(idx.search(unit(1f, 0f, 0f), 3, 0f).isEmpty())
|
||||||
|
idx.add(1L, unit(1f, 0f, 0f))
|
||||||
|
assertTrue(idx.search(FloatArray(5), 3, 0f).isEmpty()) // mauvaise dim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.kazeia.safety
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-régression de la détection de crise — SÉCURITÉ VITALE.
|
||||||
|
*
|
||||||
|
* Règle d'or : on privilégie la SENSIBILITÉ (rater une vraie crise est le seul
|
||||||
|
* vrai risque ; un faux positif reste une réponse bienveillante). Toute retouche
|
||||||
|
* de CrisisGuard.PATTERNS doit garder ces tests verts. Si le clinicien affine
|
||||||
|
* les motifs, il ÉTEND ce fichier — il ne supprime pas de cas positif.
|
||||||
|
*/
|
||||||
|
class CrisisGuardTest {
|
||||||
|
|
||||||
|
// ── POSITIFS : chaque famille de motifs doit déclencher ────────────────
|
||||||
|
|
||||||
|
private val mustDetect = listOf(
|
||||||
|
// suicide explicite
|
||||||
|
"je veux me suicider",
|
||||||
|
"je pense au suicide depuis quelques jours",
|
||||||
|
"JE VEUX ME SUICIDER", // insensible à la casse
|
||||||
|
// me tuer / me foutre en l'air
|
||||||
|
"j'ai envie de me tuer",
|
||||||
|
"je vais me foutre en l'air",
|
||||||
|
"me foutre en l air", // variante STT sans apostrophe
|
||||||
|
// en finir
|
||||||
|
"je n'en peux plus, je veux en finir",
|
||||||
|
"jai envie den finir", // sortie STT collée (validé device)
|
||||||
|
"des fois je me dis que ca vaudrait mieux den finir",
|
||||||
|
// mettre fin à mes jours / ma vie
|
||||||
|
"je songe à mettre fin à mes jours",
|
||||||
|
"mettre fin à ma vie serait plus simple",
|
||||||
|
// envie de mourir / plus envie de vivre
|
||||||
|
"j'ai envie de mourir",
|
||||||
|
"je n'ai plus envie de vivre",
|
||||||
|
"pas envie de vivre aujourd'hui",
|
||||||
|
// ça ne sert à rien de vivre/continuer
|
||||||
|
"ça ne sert à rien de continuer",
|
||||||
|
"ca sert à rien de vivre",
|
||||||
|
// vaudrait mieux que je parte/disparaisse/meure / tout arrêter
|
||||||
|
"il vaudrait mieux que je disparaisse",
|
||||||
|
"ça vaudrait mieux que je meure",
|
||||||
|
"tout ça ne sert à rien, ça vaudrait mieux tout arrêter",
|
||||||
|
"ça vaudrait mieux d'arrêter", // cas du bench qualité (Q5)
|
||||||
|
// auto-agression
|
||||||
|
"je pense à me faire du mal", // validé device
|
||||||
|
"je recommence à me mutiler",
|
||||||
|
"je vais m'automutiler ce soir",
|
||||||
|
// épuisement vital / fardeau
|
||||||
|
"je n'ai plus la force de vivre",
|
||||||
|
"ils seraient mieux sans moi"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `detecte toutes les formulations d ideation`() {
|
||||||
|
for (msg in mustDetect) {
|
||||||
|
assertTrue("DOIT déclencher mais ne déclenche pas : « $msg »", CrisisGuard.detect(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NÉGATIFS : la détresse ordinaire ne doit PAS déclencher ────────────
|
||||||
|
|
||||||
|
private val mustNotDetect = listOf(
|
||||||
|
"je me sens un peu triste et fatigué aujourd'hui", // validé device (pas de faux positif)
|
||||||
|
"je suis très angoissé, je n'arrive plus à dormir",
|
||||||
|
"j'ai envie de dormir tout le temps", // « envie de … » ≠ « envie de mourir »
|
||||||
|
"je me sens seul, personne ne me comprend",
|
||||||
|
"je suis en colère contre mon frère",
|
||||||
|
"j'ai raté mon examen, je suis nul",
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ne declenche pas sur la detresse ordinaire`() {
|
||||||
|
for (msg in mustNotDetect) {
|
||||||
|
assertFalse("NE DOIT PAS déclencher mais déclenche : « $msg »", CrisisGuard.detect(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMPORTEMENTS LIMITES DOCUMENTÉS (choix sensibilité-d'abord) ──────
|
||||||
|
// Ces cas déclenchent alors qu'ils sont ambigus. C'est ASSUMÉ : on préfère
|
||||||
|
// une réponse de sécurité en trop qu'une crise ratée. Si le clinicien veut
|
||||||
|
// les affiner, ces tests explicitent l'état actuel.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `limite assumee - en finir avec un sujet banal declenche (sensibilite d abord)`() {
|
||||||
|
assertTrue(CrisisGuard.detect("je veux en finir avec ces insomnies"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `limite assumee - suicide en contexte indirect declenche`() {
|
||||||
|
assertTrue(CrisisGuard.detect("mon cousin a parlé de suicide à la télé"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RÉPONSE DE SÉCURITÉ : invariants ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `la reponse de securite contient le numero d urgence et l honnetete programme`() {
|
||||||
|
assertTrue(CrisisGuard.RESPONSE.contains("112"))
|
||||||
|
assertTrue(CrisisGuard.RESPONSE.contains("programme")) // honnêteté : pas un humain
|
||||||
|
assertTrue(CrisisGuard.RESPONSE.contains("pas seul")) // anti-isolement
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue