From fd9e04a0cbe9a688c778ae41778e252fe387b39a Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 11 Jun 2026 07:53:31 +0200 Subject: [PATCH] feat(rag): gestionnaire RAG complet dans l'app admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran RAG admin passait du simple CRUD de documents à un gestionnaire complet. Côté patient : - ConfigStore : ragThreshold (0.82) + ragTopK (3) persistés ; le retrieve live les lit. - KazeiaService : buildRag()/teardownRag() extraits → toggle RAG À CHAUD via handleConfigChange (build/teardown sans restart). RagHolder partage l'instance vivante (embedder + index) avec le ContentProvider (même process). - Provider : config expose rag_threshold/rag_top_k (+ update) ; nouveaux chemins /rag_status (enabled, ready, model, dim, doc/chunk/pending counts) et /rag_query (test de récupération : candidats classés AVEC scores, seuil 0, k=8). Côté admin (RagScreen refondu, 4 sections) : - État : switch activer/désactiver + badge embedder prêt + modèle/dim/compteurs. - Réglages : slider seuil + stepper top-k, persistés. - Test de récupération : requête simulée → fragments classés + scores, marqués ✓ injecté / ✗ filtré selon le seuil courant (cale le seuil visuellement). - Documents : liste + ajout/édition/suppression (existant). + KazeiaConfigClient/KazeiaRagClient/RagRepository étendus. Validé device : status (ready=1, e5-small, 7 docs/7 fragments) ; toggle OFF→teardown / ON→rebuild sans restart ; test "je n'arrive pas à dormir" → sommeil 0.849 en tête, "colère contre mon frère" → colere 0.821 ; admin se lance sans crash. Co-Authored-By: Claude Opus 4.8 --- .../admin/data/repository/RagRepository.kt | 29 +- .../admin/data/source/KazeiaConfigClient.kt | 18 +- .../admin/data/source/KazeiaRagClient.kt | 37 +++ .../java/com/kazeia/admin/ui/rag/RagScreen.kt | 255 ++++++++++++------ .../java/com/kazeia/config/ConfigStore.kt | 11 +- .../app/src/main/java/com/kazeia/rag/Rag.kt | 19 ++ .../src/main/java/com/kazeia/rag/RagHolder.kt | 11 + .../java/com/kazeia/service/KazeiaService.kt | 53 ++-- .../telemetry/KazeiaTelemetryProvider.kt | 44 +++ 9 files changed, 364 insertions(+), 113 deletions(-) create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/RagHolder.kt diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/RagRepository.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/RagRepository.kt index a12532c..bc1a6d0 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/RagRepository.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/RagRepository.kt @@ -1,15 +1,30 @@ package com.kazeia.admin.data.repository import android.content.Context +import com.kazeia.admin.data.source.KazeiaConfigClient import com.kazeia.admin.data.source.KazeiaRagClient -/** Repository corpus RAG : liste/ajout/suppression des documents via le provider. */ +/** + * Passerelle unique de l'écran RAG admin : documents (corpus), config RAG + * (activation / seuil / top-k), statut du moteur, et test de récupération. + */ class RagRepository(context: Context) { - private val client = KazeiaRagClient(context.contentResolver) + private val rag = KazeiaRagClient(context.contentResolver) + private val config = KazeiaConfigClient(context.contentResolver) - /** null = Kazeia patient injoignable. */ - fun docs(): List? = client.queryDocs() - fun docText(source: String): String? = client.docText(source) - fun save(source: String, text: String): Boolean = client.upsertDoc(source, text) - fun delete(source: String): Boolean = client.deleteDoc(source) + // -- Documents -- + fun docs(): List? = rag.queryDocs() + fun docText(source: String): String? = rag.docText(source) + fun save(source: String, text: String): Boolean = rag.upsertDoc(source, text) + fun delete(source: String): Boolean = rag.deleteDoc(source) + + // -- Statut + test -- + fun status(): KazeiaRagClient.RagStatus? = rag.status() + fun test(query: String): List = rag.test(query) + + // -- Config RAG -- + fun config(): KazeiaConfigClient.Config? = config.queryConfig() + fun setEnabled(enabled: Boolean): Boolean = config.updateConfig(ragEnabled = enabled) + fun setThreshold(t: Float): Boolean = config.updateConfig(ragThreshold = t) + fun setTopK(k: Int): Boolean = config.updateConfig(ragTopK = k) } diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt index 418c4aa..d304d21 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt @@ -25,7 +25,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { val speakerTemperature: Float, val thinkerModelId: String, val thinkerSystemPrompt: String, - val thinkerTemperature: Float + val thinkerTemperature: Float, + val ragEnabled: Boolean = false, + val ragThreshold: Float = 0.82f, + val ragTopK: Int = 3 ) data class ModelInfo( @@ -51,7 +54,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")), thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")), thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")), - thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")) + thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")), + ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false }, + ragThreshold = c.getColumnIndex("rag_threshold").let { if (it >= 0) c.getFloat(it) else 0.82f }, + ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 } ) } }.getOrNull() @@ -86,7 +92,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { speakerTemperature: Float? = null, thinkerModelId: String? = null, thinkerSystemPrompt: String? = null, - thinkerTemperature: Float? = null + thinkerTemperature: Float? = null, + ragEnabled: Boolean? = null, + ragThreshold: Float? = null, + ragTopK: Int? = null ): Boolean { val v = ContentValues() cascadeEnabled?.let { v.put("cascade_enabled", it) } @@ -97,6 +106,9 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { thinkerModelId?.let { v.put("thinker_model_id", it) } thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) } thinkerTemperature?.let { v.put("thinker_temperature", it) } + ragEnabled?.let { v.put("rag_enabled", it) } + ragThreshold?.let { v.put("rag_threshold", it) } + ragTopK?.let { v.put("rag_top_k", it) } if (v.size() == 0) return true return runCatching { resolver.update(CONFIG_URI, v, null, null) > 0 diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaRagClient.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaRagClient.kt index 07e0c5b..18e4e39 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaRagClient.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaRagClient.kt @@ -14,8 +14,45 @@ class KazeiaRagClient(private val resolver: ContentResolver) { companion object { private const val AUTHORITY = "com.kazeia.provider" val RAG_URI: Uri = Uri.parse("content://$AUTHORITY/rag") + val RAG_STATUS_URI: Uri = Uri.parse("content://$AUTHORITY/rag_status") + val RAG_QUERY_URI: Uri = Uri.parse("content://$AUTHORITY/rag_query") } + data class RagStatus( + val enabled: Boolean, val ready: Boolean, val model: String, val dim: Int, + val docCount: Int, val chunkCount: Int, val pending: Int + ) + + data class ScoredHit(val source: String, val score: Float, val text: String) + + fun status(): RagStatus? = runCatching { + resolver.query(RAG_STATUS_URI, null, null, null, null)?.use { c -> + if (!c.moveToFirst()) return@use null + RagStatus( + enabled = c.getInt(c.getColumnIndexOrThrow("enabled")) == 1, + ready = c.getInt(c.getColumnIndexOrThrow("ready")) == 1, + model = c.getString(c.getColumnIndexOrThrow("model")) ?: "", + dim = c.getInt(c.getColumnIndexOrThrow("dim")), + docCount = c.getInt(c.getColumnIndexOrThrow("doc_count")), + chunkCount = c.getInt(c.getColumnIndexOrThrow("chunk_count")), + pending = c.getInt(c.getColumnIndexOrThrow("pending")) + ) + } + }.getOrNull() + + /** Test de récupération : candidats classés avec scores (seuil 0 côté patient). */ + fun test(query: String): List = runCatching { + resolver.query(RAG_QUERY_URI, null, null, arrayOf(query), null)?.use { c -> + val out = mutableListOf() + while (c.moveToNext()) out += ScoredHit( + source = c.getString(c.getColumnIndexOrThrow("source")), + score = c.getFloat(c.getColumnIndexOrThrow("score")), + text = c.getString(c.getColumnIndexOrThrow("text")) + ) + out + } ?: emptyList() + }.getOrDefault(emptyList()) + data class RagDoc( val source: String, val charCount: Int, diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/rag/RagScreen.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/rag/RagScreen.kt index 5aa207d..01835b9 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/rag/RagScreen.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/rag/RagScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -28,9 +29,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -50,9 +53,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.kazeia.admin.R import com.kazeia.admin.data.repository.RagRepository +import com.kazeia.admin.data.source.KazeiaConfigClient import com.kazeia.admin.data.source.KazeiaRagClient import com.kazeia.admin.ui.theme.StateError import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -66,11 +71,23 @@ fun RagScreen() { val scope = rememberCoroutineScope() val snackbar = remember { SnackbarHostState() } + var config by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(null) } var docs by remember { mutableStateOf?>(null) } var editing by remember { mutableStateOf(null) } + var offline by remember { mutableStateOf(false) } - suspend fun refresh() { docs = withContext(Dispatchers.IO) { repo.docs() } } - LaunchedEffect(Unit) { refresh() } + var testQuery by remember { mutableStateOf("") } + var testResults by remember { mutableStateOf?>(null) } + + suspend fun reload() = withContext(Dispatchers.IO) { + val c = repo.config() + offline = c == null + config = c + status = repo.status() + docs = repo.docs() + } + LaunchedEffect(Unit) { reload() } Scaffold( topBar = { @@ -80,9 +97,9 @@ fun RagScreen() { ) }, floatingActionButton = { - if (editing == null && docs != null) { + if (editing == null && !offline) { FloatingActionButton(onClick = { editing = Editing("", "", true) }) { - Icon(Icons.Outlined.Add, contentDescription = "Ajouter") + Icon(Icons.Outlined.Add, contentDescription = "Ajouter un document") } } }, @@ -90,35 +107,47 @@ fun RagScreen() { ) { padding -> val mod = Modifier.fillMaxSize().padding(padding) when { - docs == null -> OfflineCard(mod) + offline -> OfflineCard(mod) editing != null -> DocEditor( - editing = editing!!, - modifier = mod, + editing = editing!!, modifier = mod, onChange = { editing = it }, onCancel = { editing = null }, onSave = { e -> scope.launch { val ok = withContext(Dispatchers.IO) { repo.save(e.source.trim(), e.text) } - if (ok) { editing = null; refresh() } - snackbar.showSnackbar( - if (ok) "Document enregistré — réindexation en cours côté Kazeia." - else "Échec : Kazeia patient injoignable.", - duration = SnackbarDuration.Short - ) + if (ok) { editing = null; delay(400); reload() } + snackbar.showSnackbar(if (ok) "Document enregistré — réindexation en cours." else "Échec : Kazeia injoignable.", duration = SnackbarDuration.Short) } }, onDelete = { src -> scope.launch { val ok = withContext(Dispatchers.IO) { repo.delete(src) } - if (ok) { editing = null; refresh() } + if (ok) { editing = null; delay(400); reload() } snackbar.showSnackbar(if (ok) "Document supprimé." else "Échec suppression.", duration = SnackbarDuration.Short) } } ) - else -> DocList( - docs = docs!!, - modifier = mod, - onOpen = { src -> + else -> Dashboard( + config = config, status = status, docs = docs ?: emptyList(), + testQuery = testQuery, testResults = testResults, modifier = mod, + onToggleEnabled = { on -> + scope.launch { + withContext(Dispatchers.IO) { repo.setEnabled(on) } + snackbar.showSnackbar(if (on) "RAG activé — chargement de l'embedder…" else "RAG désactivé.", duration = SnackbarDuration.Short) + delay(1500); reload() + } + }, + onThreshold = { t -> scope.launch { withContext(Dispatchers.IO) { repo.setThreshold(t) }; reload() } }, + onTopK = { k -> scope.launch { withContext(Dispatchers.IO) { repo.setTopK(k) }; reload() } }, + onTestQueryChange = { testQuery = it }, + onRunTest = { + scope.launch { + val r = withContext(Dispatchers.IO) { repo.test(testQuery.trim()) } + testResults = r + if (r.isEmpty()) snackbar.showSnackbar("Aucun résultat (RAG inactif ou corpus vide).", duration = SnackbarDuration.Short) + } + }, + onOpenDoc = { src -> scope.launch { val t = withContext(Dispatchers.IO) { repo.docText(src) } ?: "" editing = Editing(src, t, false) @@ -130,46 +159,122 @@ fun RagScreen() { } @Composable -private fun DocList( +private fun Dashboard( + config: KazeiaConfigClient.Config?, + status: KazeiaRagClient.RagStatus?, docs: List, + testQuery: String, + testResults: List?, modifier: Modifier, - onOpen: (String) -> Unit + onToggleEnabled: (Boolean) -> Unit, + onThreshold: (Float) -> Unit, + onTopK: (Int) -> Unit, + onTestQueryChange: (String) -> Unit, + onRunTest: () -> Unit, + onOpenDoc: (String) -> Unit ) { - Column(modifier) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - shape = RoundedCornerShape(12.dp) - ) { - Text( - "Corpus de connaissances injecté au prompt du Speaker (RAG). Chaque document est " + - "découpé et vectorisé par Kazeia. \"à indexer\" signifie que le RAG est désactivé " + - "ou que l'embedder n'est pas prêt côté patient.", - Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium - ) + val threshold = config?.ragThreshold ?: 0.82f + LazyColumn( + modifier = modifier, + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { StatusCard(config, status, onToggleEnabled) } + item { SettingsCard(config, onThreshold, onTopK) } + item { TestCard(testQuery, testResults, threshold, onTestQueryChange, onRunTest) } + item { + Text("Documents du corpus (${docs.size})", + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp)) } if (docs.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant) - } + item { Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { - LazyColumn( - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(docs, key = { it.source }) { d -> - Card( - modifier = Modifier.fillMaxWidth().clickable { onOpen(d.source) }, - shape = RoundedCornerShape(10.dp) - ) { - Column(Modifier.padding(14.dp)) { - Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.heightIn(min = 4.dp)) - val status = if (d.chunkCount > 0) "${d.chunkCount} fragment(s) indexé(s)" else "⚠ à indexer" - Text("${d.charCount} caractères · $status", - style = MaterialTheme.typography.bodySmall, - color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError) + items(docs, key = { it.source }) { d -> + Card(modifier = Modifier.fillMaxWidth().clickable { onOpenDoc(d.source) }, shape = RoundedCornerShape(10.dp)) { + Column(Modifier.padding(14.dp)) { + Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall) + val st = if (d.chunkCount > 0) "${d.chunkCount} fragment(s)" else "⚠ à indexer" + Text("${d.charCount} caractères · $st", + style = MaterialTheme.typography.bodySmall, + color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError) + } + } + } + } + } +} + +@Composable +private fun StatusCard(config: KazeiaConfigClient.Config?, status: KazeiaRagClient.RagStatus?, onToggle: (Boolean) -> Unit) { + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("RAG (mémoire documentaire)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Switch(checked = config?.ragEnabled == true, onCheckedChange = onToggle) + } + val ready = status?.ready == true + val badge = when { + config?.ragEnabled != true -> "désactivé" + ready -> "actif · embedder prêt" + else -> "activé · embedder NON prêt (modèle absent ?)" + } + Text(badge, style = MaterialTheme.typography.bodyMedium, + color = if (config?.ragEnabled == true && !ready) StateError else MaterialTheme.colorScheme.onSurfaceVariant) + status?.let { s -> + Text("Modèle : ${s.model.ifEmpty { "—" }} (dim ${s.dim}) · ${s.docCount} doc · ${s.chunkCount} fragments" + + if (s.pending > 0) " · ${s.pending} à indexer" else "", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun SettingsCard(config: KazeiaConfigClient.Config?, onThreshold: (Float) -> Unit, onTopK: (Int) -> Unit) { + if (config == null) return + var localT by remember(config.ragThreshold) { mutableStateOf(config.ragThreshold) } + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Réglages de récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text("Seuil de similarité : ${"%.2f".format(localT)}", style = MaterialTheme.typography.bodyMedium) + Slider(value = localT, onValueChange = { localT = it }, valueRange = 0.5f..0.95f, + onValueChangeFinished = { onThreshold(localT) }) + Text("Plus haut = plus strict (moins de contexte injecté, mais plus pertinent).", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { + Text("Nombre max de fragments : ${config.ragTopK}", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = { if (config.ragTopK > 1) onTopK(config.ragTopK - 1) }) { Text("–", style = MaterialTheme.typography.titleLarge) } + IconButton(onClick = { if (config.ragTopK < 8) onTopK(config.ragTopK + 1) }) { Text("+", style = MaterialTheme.typography.titleLarge) } + } + } + } +} + +@Composable +private fun TestCard( + query: String, results: List?, threshold: Float, + onChange: (String) -> Unit, onRun: () -> Unit +) { + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Tester la récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = query, onValueChange = onChange, modifier = Modifier.fillMaxWidth(), + label = { Text("Message patient simulé") }, singleLine = false) + Button(onClick = onRun, enabled = query.trim().length >= 3) { Text("Tester") } + results?.let { res -> + if (res.isEmpty()) { + Text("Aucun candidat.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Text("Candidats (✓ = injecté au seuil ${"%.2f".format(threshold)}) :", style = MaterialTheme.typography.bodySmall) + res.forEach { h -> + val pass = h.score >= threshold + Column(Modifier.padding(vertical = 2.dp)) { + Text("${if (pass) "✓" else "✗"} ${h.source} · ${"%.3f".format(h.score)}", + style = MaterialTheme.typography.bodyMedium, + color = if (pass) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (pass) FontWeight.SemiBold else FontWeight.Normal) + Text(h.text.take(120), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -180,32 +285,16 @@ private fun DocList( @Composable private fun DocEditor( - editing: Editing, - modifier: Modifier, - onChange: (Editing) -> Unit, - onCancel: () -> Unit, - onSave: (Editing) -> Unit, - onDelete: (String) -> Unit + editing: Editing, modifier: Modifier, + onChange: (Editing) -> Unit, onCancel: () -> Unit, onSave: (Editing) -> Unit, onDelete: (String) -> Unit ) { - Column( - modifier.verticalScroll(rememberScrollState()).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = editing.source, - onValueChange = { onChange(editing.copy(source = it)) }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Titre / source (identifiant unique)") }, - enabled = editing.isNew, - singleLine = true - ) - OutlinedTextField( - value = editing.text, - onValueChange = { onChange(editing.copy(text = it)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 280.dp), - label = { Text("Contenu du document") }, - supportingText = { Text("${editing.text.length} caractères") } - ) + Column(modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(value = editing.source, onValueChange = { onChange(editing.copy(source = it)) }, + modifier = Modifier.fillMaxWidth(), label = { Text("Titre / source (identifiant unique)") }, + enabled = editing.isNew, singleLine = true) + OutlinedTextField(value = editing.text, onValueChange = { onChange(editing.copy(text = it)) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 280.dp), label = { Text("Contenu du document") }, + supportingText = { Text("${editing.text.length} caractères") }) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (!editing.isNew) { IconButton(onClick = { onDelete(editing.source) }) { @@ -214,10 +303,8 @@ private fun DocEditor( } Spacer(Modifier.weight(1f)) TextButton(onClick = onCancel) { Text("Annuler") } - Button( - onClick = { onSave(editing) }, - enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10 - ) { Text("Enregistrer") } + Button(onClick = { onSave(editing) }, + enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10) { Text("Enregistrer") } } } } @@ -226,10 +313,8 @@ private fun DocEditor( private fun OfflineCard(modifier: Modifier) { Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) { Card(colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))) { - Text( - "Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.", - Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium - ) + Text("Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.", + Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) } } } diff --git a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt index b39c1c2..8b978a7 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt @@ -51,7 +51,12 @@ class ConfigStore private constructor(private val file: File) { /** RAG (récupération de contexte injecté au prompt). Default OFF : tant que * kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré, * l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */ - val ragEnabled: Boolean = false + val ragEnabled: Boolean = false, + /** Seuil de similarité cosinus pour injecter un chunk (e5 : base ~0.80, + * pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */ + val ragThreshold: Float = 0.82f, + /** Nombre max de chunks récupérés/injectés par tour. */ + val ragTopK: Int = 3 ) companion object { @@ -157,6 +162,8 @@ class ConfigStore private constructor(private val file: File) { root.put("llm_engine", cfg.llmEngine) root.put("tts_engine", cfg.ttsEngine) root.put("rag_enabled", cfg.ragEnabled) + root.put("rag_threshold", cfg.ragThreshold.toDouble()) + root.put("rag_top_k", cfg.ragTopK) root.put("speaker", JSONObject().apply { put("model_id", cfg.speaker.modelId) put("system_prompt", cfg.speaker.systemPrompt) @@ -180,6 +187,8 @@ class ConfigStore private constructor(private val file: File) { llmEngine = root.optString("llm_engine", defaults.llmEngine), ttsEngine = root.optString("tts_engine", defaults.ttsEngine), ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled), + ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(), + ragTopK = root.optInt("rag_top_k", defaults.ragTopK), speaker = root.optJSONObject("speaker")?.let { js -> ModelConfig( modelId = js.optString("model_id", defaults.speaker.modelId), 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 a8f55c2..9c15d1b 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 @@ -135,6 +135,25 @@ class Rag( } fun count(): Int = db.count() + fun docCount(): Int = db.listDocs().size + fun pendingCount(): Int = db.docsNeedingIngest().size + fun dim(): Int = embedder.dim fun clear() { db.clear(); index = null } fun close() { embedder.close() } + + /** Récupération AVEC scores, pour le test/preview admin (n'injecte rien). */ + @Synchronized + fun searchScored(query: String, k: Int, threshold: Float): List { + val idx = index ?: return emptyList() + val qv = (embedder as? EngineEmbedder)?.embedQuery(query) ?: embedder.embed(query) ?: return emptyList() + return idx.search(qv, k, threshold).mapNotNull { h -> + db.textOf(h.chunkId)?.let { t -> + val src = Regex("^\\[Source: ([^\\]]+)\\]").find(t)?.groupValues?.get(1) ?: "?" + val body = t.replace(Regex("^\\[Source: [^\\]]+\\]\\s*"), "") + ScoredChunk(src, h.score, body) + } + } + } + + data class ScoredChunk(val source: String, val score: Float, val text: String) } diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/RagHolder.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/RagHolder.kt new file mode 100644 index 0000000..95da457 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/RagHolder.kt @@ -0,0 +1,11 @@ +package com.kazeia.rag + +/** + * Partage l'instance Rag vivante (construite par KazeiaService, qui détient + * l'embedder + l'index en RAM) avec le ContentProvider — même process com.kazeia. + * Permet à l'app admin de lancer un test de récupération sans recharger un 2ᵉ + * modèle d'embedding côté provider. + */ +object RagHolder { + @Volatile var instance: Rag? = null +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt index 856f8e3..7fd5006 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -844,20 +844,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think // RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la // lib n'expose pas encore embedText ou si le modèle d'embedding est absent // (isReady=false -> retrieve() renvoie null -> aucune injection). - rag = if (runtimeConfig.ragEnabled) try { - val embModel = "${KazeiaApplication.MODELS_DIR}/embed-e5-small.gguf" - val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1, - queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) }) - com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }.also { - // Auto-ingestion du corpus au 1er démarrage (base vide). Le corpus - // (catalogue Nextcloud en prod) est déposé sous .../kazeia/rag_corpus/. - if (it.isReady && it.count() == 0 && it.listDocs().isEmpty()) - it.ingestDir("${KazeiaApplication.MODELS_DIR}/../rag_corpus") - it.reingestMissing() // embed les docs admin ajoutés hors-ligne (sans chunks) - log("[RAG] activé (ready=${it.isReady}, chunks=${it.count()})") - } - } catch (e: Throwable) { log("[RAG] init échec: ${e.message}"); null } - else { log("[RAG] désactivé (config)"); null } + rag = buildRag() _loadingState.value = LoadingState(80, "Audio…") // Audio @@ -1780,9 +1767,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think // JAMAIS la gestion de crise. rag?.takeIf { it.isReady }?.let { r -> try { - // Seuil ~0.82 calé sur e5 (cosinus de base ~0.80 ; pertinent ~0.88) pour - // ne PAS injecter de hors-sujet. k=3. À tuner sur corpus réel (logs "top="). - r.retrieve(patientMessage, k = 3, threshold = 0.82f)?.let { ctx -> + // Seuil + top-k pilotés depuis l'admin (défauts e5 : 0.82 / 3). + r.retrieve(patientMessage, k = runtimeConfig.ragTopK, threshold = runtimeConfig.ragThreshold)?.let { ctx -> prompt = "$ctx\n\n$prompt" log("[RAG] contexte injecté (${ctx.length} chars)") } @@ -2076,6 +2062,31 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think } } + /** Construit l'instance RAG (embedder e5 + index) si ragEnabled. Pose RagHolder. */ + private fun buildRag(): com.kazeia.rag.Rag? { + if (!runtimeConfig.ragEnabled) { log("[RAG] désactivé (config)"); com.kazeia.rag.RagHolder.instance = null; return null } + return try { + val embModel = "${KazeiaApplication.MODELS_DIR}/embed-e5-small.gguf" + val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1, + queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) }) + com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }.also { + // Auto-ingestion du corpus seed au 1er démarrage (base + docs vides). + if (it.isReady && it.count() == 0 && it.listDocs().isEmpty()) + it.ingestDir("${KazeiaApplication.MODELS_DIR}/../rag_corpus") + it.reingestMissing() // embed les docs admin ajoutés hors-ligne + com.kazeia.rag.RagHolder.instance = it + log("[RAG] activé (ready=${it.isReady}, chunks=${it.count()})") + } + } catch (e: Throwable) { log("[RAG] init échec: ${e.message}"); com.kazeia.rag.RagHolder.instance = null; null } + } + + private fun teardownRag() { + rag?.let { try { it.close() } catch (_: Exception) {} } + rag = null + com.kazeia.rag.RagHolder.instance = null + log("[RAG] teardown (désactivé à chaud)") + } + private fun registerRagReloadReceiver() { val r = object : android.content.BroadcastReceiver() { override fun onReceive(c: android.content.Context?, i: Intent?) { @@ -2139,6 +2150,14 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think stopThinkerEngine() } // ON après OFF : le Thinker sera lazy-loadé au prochain tour via ensureThinkerEngine() + + // Toggle RAG à chaud (build/teardown sans restart). Le seuil/top-k sont + // lus au moment du retrieve → pas de rebuild nécessaire pour eux. + if (new.ragEnabled != old.ragEnabled) { + serviceScope.launch { + if (new.ragEnabled) rag = buildRag() else teardownRag() + } + } } /** diff --git a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt index 4b76d6d..a6ed9e5 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt @@ -40,6 +40,8 @@ class KazeiaTelemetryProvider : ContentProvider() { const val PATH_CONVERSATIONS = "conversations" const val PATH_DIST_CONFIG = "dist_config" const val PATH_RAG = "rag" + const val PATH_RAG_STATUS = "rag_status" + const val PATH_RAG_QUERY = "rag_query" const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG" const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES" const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG" @@ -60,6 +62,8 @@ class KazeiaTelemetryProvider : ContentProvider() { private const val CODE_DIST_CONFIG = 14 private const val CODE_RAG = 15 private const val CODE_RAG_ITEM = 16 + private const val CODE_RAG_STATUS = 17 + private const val CODE_RAG_QUERY = 18 // Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp // sur tablette dev, stockage externe app sinon). Non-const. @@ -91,6 +95,8 @@ class KazeiaTelemetryProvider : ContentProvider() { addURI(AUTHORITY, PATH_DIST_CONFIG, CODE_DIST_CONFIG) addURI(AUTHORITY, PATH_RAG, CODE_RAG) addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM) + addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS) + addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY) } override fun onCreate(): Boolean = true @@ -116,9 +122,43 @@ class KazeiaTelemetryProvider : ContentProvider() { CODE_DIST_CONFIG -> distConfigCursor() CODE_RAG -> ragCursor() CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "") + CODE_RAG_STATUS -> ragStatusCursor() + CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection) else -> null } + private fun ragStatusCursor(): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending") + val cursor = MatrixCursor(cols) + val cfg = com.kazeia.config.ConfigStore.get(ctx).current() + val rag = com.kazeia.rag.RagHolder.instance + val db = com.kazeia.rag.RagDb.get(ctx) + cursor.addRow(arrayOf( + if (cfg.ragEnabled) 1 else 0, + if (rag?.isReady == true) 1 else 0, + runCatching { db.distinctModel() ?: "" }.getOrDefault(""), + rag?.dim() ?: 0, + runCatching { db.listDocs().size }.getOrDefault(0), + runCatching { db.count() }.getOrDefault(0), + runCatching { db.docsNeedingIngest().size }.getOrDefault(0) + )) + return cursor + } + + /** Test de récupération : renvoie les meilleurs candidats AVEC scores (seuil 0, + * k large) pour que l'admin voie le classement et cale le seuil. */ + private fun ragQueryCursor(queryText: String?): Cursor { + val cursor = MatrixCursor(arrayOf("source", "score", "text")) + val q = queryText?.takeIf { it.isNotBlank() } ?: return cursor + val rag = com.kazeia.rag.RagHolder.instance ?: return cursor + runCatching { + for (h in rag.searchScored(q, k = 8, threshold = 0f)) + cursor.addRow(arrayOf(h.source, h.score, h.text)) + } + return cursor + } + private fun ragDocCursor(source: String): Cursor { val ctx = context ?: return MatrixCursor(arrayOf("source", "text")) val cursor = MatrixCursor(arrayOf("source", "text")) @@ -216,6 +256,7 @@ class KazeiaTelemetryProvider : ContentProvider() { val cols = arrayOf( "cascade_enabled", "tts_enabled", "stt_engine", "llm_engine", "tts_engine", "rag_enabled", + "rag_threshold", "rag_top_k", "speaker_model_id", "speaker_system_prompt", "speaker_temperature", "thinker_model_id", "thinker_system_prompt", "thinker_temperature" ) @@ -225,6 +266,7 @@ class KazeiaTelemetryProvider : ContentProvider() { if (cfg.ttsEnabled) 1 else 0, cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine, if (cfg.ragEnabled) 1 else 0, + cfg.ragThreshold, cfg.ragTopK, cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature )) @@ -317,6 +359,8 @@ class KazeiaTelemetryProvider : ContentProvider() { ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine, ragEnabled = if (values.containsKey("rag_enabled")) values.getAsBoolean("rag_enabled") else cur.ragEnabled, + ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold, + ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK, speaker = com.kazeia.config.ConfigStore.ModelConfig( modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, systemPrompt = values.getAsString("speaker_system_prompt")