feat(rag): gestionnaire RAG complet dans l'app admin

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 <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-11 07:53:31 +02:00
parent 4508b8fe2e
commit fd9e04a0cb
9 changed files with 364 additions and 113 deletions

View File

@ -1,15 +1,30 @@
package com.kazeia.admin.data.repository package com.kazeia.admin.data.repository
import android.content.Context import android.content.Context
import com.kazeia.admin.data.source.KazeiaConfigClient
import com.kazeia.admin.data.source.KazeiaRagClient 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) { 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. */ // -- Documents --
fun docs(): List<KazeiaRagClient.RagDoc>? = client.queryDocs() fun docs(): List<KazeiaRagClient.RagDoc>? = rag.queryDocs()
fun docText(source: String): String? = client.docText(source) fun docText(source: String): String? = rag.docText(source)
fun save(source: String, text: String): Boolean = client.upsertDoc(source, text) fun save(source: String, text: String): Boolean = rag.upsertDoc(source, text)
fun delete(source: String): Boolean = client.deleteDoc(source) fun delete(source: String): Boolean = rag.deleteDoc(source)
// -- Statut + test --
fun status(): KazeiaRagClient.RagStatus? = rag.status()
fun test(query: String): List<KazeiaRagClient.ScoredHit> = 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)
} }

View File

@ -25,7 +25,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
val speakerTemperature: Float, val speakerTemperature: Float,
val thinkerModelId: String, val thinkerModelId: String,
val thinkerSystemPrompt: 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( data class ModelInfo(
@ -51,7 +54,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")), speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")),
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")), thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")), 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() }.getOrNull()
@ -86,7 +92,10 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
speakerTemperature: Float? = null, speakerTemperature: Float? = null,
thinkerModelId: String? = null, thinkerModelId: String? = null,
thinkerSystemPrompt: String? = null, thinkerSystemPrompt: String? = null,
thinkerTemperature: Float? = null thinkerTemperature: Float? = null,
ragEnabled: Boolean? = null,
ragThreshold: Float? = null,
ragTopK: Int? = null
): Boolean { ): Boolean {
val v = ContentValues() val v = ContentValues()
cascadeEnabled?.let { v.put("cascade_enabled", it) } 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) } thinkerModelId?.let { v.put("thinker_model_id", it) }
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) } thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
thinkerTemperature?.let { v.put("thinker_temperature", 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 if (v.size() == 0) return true
return runCatching { return runCatching {
resolver.update(CONFIG_URI, v, null, null) > 0 resolver.update(CONFIG_URI, v, null, null) > 0

View File

@ -14,8 +14,45 @@ class KazeiaRagClient(private val resolver: ContentResolver) {
companion object { companion object {
private const val AUTHORITY = "com.kazeia.provider" private const val AUTHORITY = "com.kazeia.provider"
val RAG_URI: Uri = Uri.parse("content://$AUTHORITY/rag") 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<ScoredHit> = runCatching {
resolver.query(RAG_QUERY_URI, null, null, arrayOf(query), null)?.use { c ->
val out = mutableListOf<ScoredHit>()
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( data class RagDoc(
val source: String, val source: String,
val charCount: Int, val charCount: Int,

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -28,9 +29,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -50,9 +53,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.kazeia.admin.R import com.kazeia.admin.R
import com.kazeia.admin.data.repository.RagRepository 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.data.source.KazeiaRagClient
import com.kazeia.admin.ui.theme.StateError import com.kazeia.admin.ui.theme.StateError
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -66,11 +71,23 @@ fun RagScreen() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
var status by remember { mutableStateOf<KazeiaRagClient.RagStatus?>(null) }
var docs by remember { mutableStateOf<List<KazeiaRagClient.RagDoc>?>(null) } var docs by remember { mutableStateOf<List<KazeiaRagClient.RagDoc>?>(null) }
var editing by remember { mutableStateOf<Editing?>(null) } var editing by remember { mutableStateOf<Editing?>(null) }
var offline by remember { mutableStateOf(false) }
suspend fun refresh() { docs = withContext(Dispatchers.IO) { repo.docs() } } var testQuery by remember { mutableStateOf("") }
LaunchedEffect(Unit) { refresh() } var testResults by remember { mutableStateOf<List<KazeiaRagClient.ScoredHit>?>(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( Scaffold(
topBar = { topBar = {
@ -80,9 +97,9 @@ fun RagScreen() {
) )
}, },
floatingActionButton = { floatingActionButton = {
if (editing == null && docs != null) { if (editing == null && !offline) {
FloatingActionButton(onClick = { editing = Editing("", "", true) }) { 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 -> ) { padding ->
val mod = Modifier.fillMaxSize().padding(padding) val mod = Modifier.fillMaxSize().padding(padding)
when { when {
docs == null -> OfflineCard(mod) offline -> OfflineCard(mod)
editing != null -> DocEditor( editing != null -> DocEditor(
editing = editing!!, editing = editing!!, modifier = mod,
modifier = mod,
onChange = { editing = it }, onChange = { editing = it },
onCancel = { editing = null }, onCancel = { editing = null },
onSave = { e -> onSave = { e ->
scope.launch { scope.launch {
val ok = withContext(Dispatchers.IO) { repo.save(e.source.trim(), e.text) } val ok = withContext(Dispatchers.IO) { repo.save(e.source.trim(), e.text) }
if (ok) { editing = null; refresh() } if (ok) { editing = null; delay(400); reload() }
snackbar.showSnackbar( snackbar.showSnackbar(if (ok) "Document enregistré — réindexation en cours." else "Échec : Kazeia injoignable.", duration = SnackbarDuration.Short)
if (ok) "Document enregistré — réindexation en cours côté Kazeia."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
} }
}, },
onDelete = { src -> onDelete = { src ->
scope.launch { scope.launch {
val ok = withContext(Dispatchers.IO) { repo.delete(src) } 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) snackbar.showSnackbar(if (ok) "Document supprimé." else "Échec suppression.", duration = SnackbarDuration.Short)
} }
} }
) )
else -> DocList( else -> Dashboard(
docs = docs!!, config = config, status = status, docs = docs ?: emptyList(),
modifier = mod, testQuery = testQuery, testResults = testResults, modifier = mod,
onOpen = { src -> 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 { scope.launch {
val t = withContext(Dispatchers.IO) { repo.docText(src) } ?: "" val t = withContext(Dispatchers.IO) { repo.docText(src) } ?: ""
editing = Editing(src, t, false) editing = Editing(src, t, false)
@ -130,44 +159,43 @@ fun RagScreen() {
} }
@Composable @Composable
private fun DocList( private fun Dashboard(
config: KazeiaConfigClient.Config?,
status: KazeiaRagClient.RagStatus?,
docs: List<KazeiaRagClient.RagDoc>, docs: List<KazeiaRagClient.RagDoc>,
testQuery: String,
testResults: List<KazeiaRagClient.ScoredHit>?,
modifier: Modifier, modifier: Modifier,
onOpen: (String) -> Unit onToggleEnabled: (Boolean) -> Unit,
onThreshold: (Float) -> Unit,
onTopK: (Int) -> Unit,
onTestQueryChange: (String) -> Unit,
onRunTest: () -> Unit,
onOpenDoc: (String) -> Unit
) { ) {
Column(modifier) { val threshold = config?.ragThreshold ?: 0.82f
Card( LazyColumn(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
shape = RoundedCornerShape(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( item { StatusCard(config, status, onToggleEnabled) }
"Corpus de connaissances injecté au prompt du Speaker (RAG). Chaque document est " + item { SettingsCard(config, onThreshold, onTopK) }
"découpé et vectorisé par Kazeia. \"à indexer\" signifie que le RAG est désactivé " + item { TestCard(testQuery, testResults, threshold, onTestQueryChange, onRunTest) }
"ou que l'embedder n'est pas prêt côté patient.", item {
Modifier.padding(16.dp), Text("Documents du corpus (${docs.size})",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
) modifier = Modifier.padding(top = 4.dp))
} }
if (docs.isEmpty()) { if (docs.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { item { Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else { } 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 -> items(docs, key = { it.source }) { d ->
Card( Card(modifier = Modifier.fillMaxWidth().clickable { onOpenDoc(d.source) }, shape = RoundedCornerShape(10.dp)) {
modifier = Modifier.fillMaxWidth().clickable { onOpen(d.source) },
shape = RoundedCornerShape(10.dp)
) {
Column(Modifier.padding(14.dp)) { Column(Modifier.padding(14.dp)) {
Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall) Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.heightIn(min = 4.dp)) val st = if (d.chunkCount > 0) "${d.chunkCount} fragment(s)" else "⚠ à indexer"
val status = if (d.chunkCount > 0) "${d.chunkCount} fragment(s) indexé(s)" else "⚠ à indexer" Text("${d.charCount} caractères · $st",
Text("${d.charCount} caractères · $status",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError) color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError)
} }
@ -175,37 +203,98 @@ private fun DocList(
} }
} }
} }
}
@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<KazeiaRagClient.ScoredHit>?, 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)
}
}
}
}
}
} }
} }
@Composable @Composable
private fun DocEditor( private fun DocEditor(
editing: Editing, editing: Editing, modifier: Modifier,
modifier: Modifier, onChange: (Editing) -> Unit, onCancel: () -> Unit, onSave: (Editing) -> Unit, onDelete: (String) -> Unit
onChange: (Editing) -> Unit,
onCancel: () -> Unit,
onSave: (Editing) -> Unit,
onDelete: (String) -> Unit
) { ) {
Column( Column(modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
modifier.verticalScroll(rememberScrollState()).padding(16.dp), OutlinedTextField(value = editing.source, onValueChange = { onChange(editing.copy(source = it)) },
verticalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.fillMaxWidth(), label = { Text("Titre / source (identifiant unique)") },
) { enabled = editing.isNew, singleLine = true)
OutlinedTextField( OutlinedTextField(value = editing.text, onValueChange = { onChange(editing.copy(text = it)) },
value = editing.source, modifier = Modifier.fillMaxWidth().heightIn(min = 280.dp), label = { Text("Contenu du document") },
onValueChange = { onChange(editing.copy(source = it)) }, supportingText = { Text("${editing.text.length} caractères") })
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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (!editing.isNew) { if (!editing.isNew) {
IconButton(onClick = { onDelete(editing.source) }) { IconButton(onClick = { onDelete(editing.source) }) {
@ -214,10 +303,8 @@ private fun DocEditor(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
TextButton(onClick = onCancel) { Text("Annuler") } TextButton(onClick = onCancel) { Text("Annuler") }
Button( Button(onClick = { onSave(editing) },
onClick = { onSave(editing) }, enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10) { Text("Enregistrer") }
enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10
) { Text("Enregistrer") }
} }
} }
} }
@ -226,10 +313,8 @@ private fun DocEditor(
private fun OfflineCard(modifier: Modifier) { private fun OfflineCard(modifier: Modifier) {
Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) { Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) {
Card(colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))) { Card(colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))) {
Text( Text("Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.",
"Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.", Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium
)
} }
} }
} }

View File

@ -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 /** 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é, * 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. */ * 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 { companion object {
@ -157,6 +162,8 @@ class ConfigStore private constructor(private val file: File) {
root.put("llm_engine", cfg.llmEngine) root.put("llm_engine", cfg.llmEngine)
root.put("tts_engine", cfg.ttsEngine) root.put("tts_engine", cfg.ttsEngine)
root.put("rag_enabled", cfg.ragEnabled) 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 { root.put("speaker", JSONObject().apply {
put("model_id", cfg.speaker.modelId) put("model_id", cfg.speaker.modelId)
put("system_prompt", cfg.speaker.systemPrompt) 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), llmEngine = root.optString("llm_engine", defaults.llmEngine),
ttsEngine = root.optString("tts_engine", defaults.ttsEngine), ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled), 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 -> speaker = root.optJSONObject("speaker")?.let { js ->
ModelConfig( ModelConfig(
modelId = js.optString("model_id", defaults.speaker.modelId), modelId = js.optString("model_id", defaults.speaker.modelId),

View File

@ -135,6 +135,25 @@ class Rag(
} }
fun count(): Int = db.count() 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 clear() { db.clear(); index = null }
fun close() { embedder.close() } 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<ScoredChunk> {
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)
} }

View File

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

View File

@ -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 // 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 // lib n'expose pas encore embedText ou si le modèle d'embedding est absent
// (isReady=false -> retrieve() renvoie null -> aucune injection). // (isReady=false -> retrieve() renvoie null -> aucune injection).
rag = if (runtimeConfig.ragEnabled) try { rag = buildRag()
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 }
_loadingState.value = LoadingState(80, "Audio…") _loadingState.value = LoadingState(80, "Audio…")
// 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. // JAMAIS la gestion de crise.
rag?.takeIf { it.isReady }?.let { r -> rag?.takeIf { it.isReady }?.let { r ->
try { try {
// Seuil ~0.82 calé sur e5 (cosinus de base ~0.80 ; pertinent ~0.88) pour // Seuil + top-k pilotés depuis l'admin (défauts e5 : 0.82 / 3).
// ne PAS injecter de hors-sujet. k=3. À tuner sur corpus réel (logs "top="). r.retrieve(patientMessage, k = runtimeConfig.ragTopK, threshold = runtimeConfig.ragThreshold)?.let { ctx ->
r.retrieve(patientMessage, k = 3, threshold = 0.82f)?.let { ctx ->
prompt = "$ctx\n\n$prompt" prompt = "$ctx\n\n$prompt"
log("[RAG] contexte injecté (${ctx.length} chars)") 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() { private fun registerRagReloadReceiver() {
val r = object : android.content.BroadcastReceiver() { val r = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: Intent?) { 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() stopThinkerEngine()
} }
// ON après OFF : le Thinker sera lazy-loadé au prochain tour via ensureThinkerEngine() // 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()
}
}
} }
/** /**

View File

@ -40,6 +40,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
const val PATH_CONVERSATIONS = "conversations" const val PATH_CONVERSATIONS = "conversations"
const val PATH_DIST_CONFIG = "dist_config" const val PATH_DIST_CONFIG = "dist_config"
const val PATH_RAG = "rag" 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_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES" const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG" 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_DIST_CONFIG = 14
private const val CODE_RAG = 15 private const val CODE_RAG = 15
private const val CODE_RAG_ITEM = 16 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 // Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
// sur tablette dev, stockage externe app sinon). Non-const. // 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_DIST_CONFIG, CODE_DIST_CONFIG)
addURI(AUTHORITY, PATH_RAG, CODE_RAG) addURI(AUTHORITY, PATH_RAG, CODE_RAG)
addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM) 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 override fun onCreate(): Boolean = true
@ -116,9 +122,43 @@ class KazeiaTelemetryProvider : ContentProvider() {
CODE_DIST_CONFIG -> distConfigCursor() CODE_DIST_CONFIG -> distConfigCursor()
CODE_RAG -> ragCursor() CODE_RAG -> ragCursor()
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "") CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
CODE_RAG_STATUS -> ragStatusCursor()
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
else -> null else -> null
} }
private fun ragStatusCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
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 { private fun ragDocCursor(source: String): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf("source", "text")) val ctx = context ?: return MatrixCursor(arrayOf("source", "text"))
val cursor = MatrixCursor(arrayOf("source", "text")) val cursor = MatrixCursor(arrayOf("source", "text"))
@ -216,6 +256,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
val cols = arrayOf( val cols = arrayOf(
"cascade_enabled", "tts_enabled", "cascade_enabled", "tts_enabled",
"stt_engine", "llm_engine", "tts_engine", "rag_enabled", "stt_engine", "llm_engine", "tts_engine", "rag_enabled",
"rag_threshold", "rag_top_k",
"speaker_model_id", "speaker_system_prompt", "speaker_temperature", "speaker_model_id", "speaker_system_prompt", "speaker_temperature",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature" "thinker_model_id", "thinker_system_prompt", "thinker_temperature"
) )
@ -225,6 +266,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
if (cfg.ttsEnabled) 1 else 0, if (cfg.ttsEnabled) 1 else 0,
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine, cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
if (cfg.ragEnabled) 1 else 0, if (cfg.ragEnabled) 1 else 0,
cfg.ragThreshold, cfg.ragTopK,
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
)) ))
@ -317,6 +359,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine, ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
ragEnabled = if (values.containsKey("rag_enabled")) ragEnabled = if (values.containsKey("rag_enabled"))
values.getAsBoolean("rag_enabled") else cur.ragEnabled, 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( speaker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
systemPrompt = values.getAsString("speaker_system_prompt") systemPrompt = values.getAsString("speaker_system_prompt")