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
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,46 +159,122 @@ 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(
|
items(docs, key = { it.source }) { d ->
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
Card(modifier = Modifier.fillMaxWidth().clickable { onOpenDoc(d.source) }, shape = RoundedCornerShape(10.dp)) {
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
Column(Modifier.padding(14.dp)) {
|
||||||
) {
|
Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall)
|
||||||
items(docs, key = { it.source }) { d ->
|
val st = if (d.chunkCount > 0) "${d.chunkCount} fragment(s)" else "⚠ à indexer"
|
||||||
Card(
|
Text("${d.charCount} caractères · $st",
|
||||||
modifier = Modifier.fillMaxWidth().clickable { onOpen(d.source) },
|
style = MaterialTheme.typography.bodySmall,
|
||||||
shape = RoundedCornerShape(10.dp)
|
color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError)
|
||||||
) {
|
}
|
||||||
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)
|
@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
|
@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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue