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:
parent
4508b8fe2e
commit
fd9e04a0cb
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,44 +159,43 @@ 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)
|
||||
val threshold = config?.ragThreshold ?: 0.82f
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(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
|
||||
)
|
||||
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)
|
||||
) {
|
||||
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)
|
||||
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",
|
||||
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)
|
||||
}
|
||||
|
|
@ -176,36 +204,97 @@ 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue