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
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<KazeiaRagClient.RagDoc>? = 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<KazeiaRagClient.RagDoc>? = 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<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 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

View File

@ -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<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(
val source: String,
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.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<KazeiaConfigClient.Config?>(null) }
var status by remember { mutableStateOf<KazeiaRagClient.RagStatus?>(null) }
var docs by remember { mutableStateOf<List<KazeiaRagClient.RagDoc>?>(null) }
var editing by remember { mutableStateOf<Editing?>(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<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(
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<KazeiaRagClient.RagDoc>,
testQuery: String,
testResults: List<KazeiaRagClient.ScoredHit>?,
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<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)
}
}
}
@ -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)
}
}
}

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
* 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),

View File

@ -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<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
// 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()
}
}
}
/**

View File

@ -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<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 {
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")