feat(rag): module RAG on-device (indépendant de l'engine), flag OFF par défaut

Module com.kazeia.rag, conçu d'après le RAG PC de Damien mais porté on-device :
- Embedder : interface + FakeEmbedder (déterministe, test hors engine) +
  EngineEmbedder (réel, branché sur EngineJni.loadEmbedder/embedText — inerte
  via UnsatisfiedLinkError tant que la lib n'expose pas ces symboles).
- VectorIndex : recherche exhaustive cosinus (produit scalaire, vecteurs L2),
  top-k + seuil. Pas d'ANN : injustifié à l'échelle (10^2–10^3 chunks).
- Chunker : découpage par phrases + overlap, tag [Source: x] (mieux que le
  découpage au caractère du PC).
- RagDb : SQLite (vecteurs en BLOB float32 LE), garde-fou modèle+dim.
- Rag : façade ingest/loadIndex/retrieve -> bloc CONTEXTE budgété en tokens.

Bindings JNI embedder ajoutés à EngineJni (loadEmbedder/embedText/freeEmbedder).
Flag ragEnabled (ConfigStore + provider rag_enabled), DEFAULT OFF -> production
inchangée. Câblage live dans KazeiaService : si activé + embedder prêt + contexte
au-dessus du seuil, préfixe le bloc CONTEXTE au prompt patient (défensif, le RAG
ne porte jamais la gestion de crise).

Validé device via l'intent `rag_test` (FakeEmbedder) : ingest 2 docs -> SQLite ->
index -> retrieve classe correctement (requête sommeil -> doc insomnie en tête).
Démarrage prod : "[RAG] désactivé (config)", rag_enabled=0. Aucune régression.

Reste (dépend de l'engine) : embedText livré + modèle e5/bge FR + ingestion d'un
vrai corpus côté admin + entrée catalogue. Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-09 22:09:57 +02:00
parent 6be3e47148
commit 3eab29beb2
9 changed files with 502 additions and 3 deletions

View File

@ -47,7 +47,11 @@ class ConfigStore private constructor(private val file: File) {
/** Backend TTS : "prod" = Qwen3TtsEngine legacy, "lib" = libkazeia_tts unifié.
* Defaults safe = "prod". Bascule "lib" nécessite libllama.so fork ql + retrait
* des classes legacy `tts_talker_cpu`/`tts_cp_cpu` (incompat ABI struct). */
val ttsEngine: String = "prod"
val ttsEngine: String = "prod",
/** 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
)
companion object {
@ -146,6 +150,7 @@ class ConfigStore private constructor(private val file: File) {
root.put("stt_engine", cfg.sttEngine)
root.put("llm_engine", cfg.llmEngine)
root.put("tts_engine", cfg.ttsEngine)
root.put("rag_enabled", cfg.ragEnabled)
root.put("speaker", JSONObject().apply {
put("model_id", cfg.speaker.modelId)
put("system_prompt", cfg.speaker.systemPrompt)
@ -168,6 +173,7 @@ class ConfigStore private constructor(private val file: File) {
sttEngine = root.optString("stt_engine", defaults.sttEngine),
llmEngine = root.optString("llm_engine", defaults.llmEngine),
ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
speaker = root.optJSONObject("speaker")?.let { js ->
ModelConfig(
modelId = js.optString("model_id", defaults.speaker.modelId),

View File

@ -14,6 +14,13 @@ class EngineJni {
external fun reset(h: Long)
external fun free(h: Long)
// -- Embeddings RAG (modèle e5/bge dédié). Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md.
// Tant que la lib ne fournit pas ces symboles, l'appel jette UnsatisfiedLinkError,
// attrapé par EngineEmbedder -> RAG se désactive proprement.
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long // handle, 0 = échec
external fun embedText(handle: Long, text: String): FloatArray? // vecteur L2-normalisé
external fun freeEmbedder(handle: Long)
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le

View File

@ -0,0 +1,77 @@
package com.kazeia.rag
/**
* Découpage sémantique léger (meilleur que le découpage au caractère brut de la
* version PC) : on segmente par phrases, puis on agglomère jusqu'à [maxChars]
* avec un [overlap] de fin de chunk reporté au suivant pour ne pas couper le
* sens. Chaque chunk est préfixé `[Source: <nom>]` pour que le LLM puisse
* sourcer. Vise des chunks < ~512 tokens (contrainte ubatch de l'embedder).
*/
object Chunker {
fun chunk(
text: String,
source: String,
maxChars: Int = 1200, // ~300 tokens
overlap: Int = 200
): List<String> {
val clean = text.replace(Regex("[ \\t]+"), " ").replace(Regex("\\n{3,}"), "\n\n").trim()
if (clean.isEmpty()) return emptyList()
val sentences = splitSentences(clean)
val out = ArrayList<String>()
val buf = StringBuilder()
fun flush() {
if (buf.isNotBlank()) out.add("[Source: $source] ${buf.toString().trim()}")
buf.setLength(0)
}
for (s in sentences) {
if (buf.isNotEmpty() && buf.length + s.length + 1 > maxChars) {
val carried = tail(buf.toString(), overlap)
flush()
if (carried.isNotEmpty()) buf.append(carried).append(' ')
}
// Phrase plus longue qu'un chunk entier : on la coupe durement.
if (s.length > maxChars) {
flush()
var i = 0
while (i < s.length) {
val end = minOf(i + maxChars, s.length)
out.add("[Source: $source] ${s.substring(i, end).trim()}")
i += maxChars - overlap
}
} else {
buf.append(s).append(' ')
}
}
flush()
return out
}
/** Découpe naïve mais robuste en phrases (FR : . ! ? … ; retours ligne). */
private fun splitSentences(text: String): List<String> {
val parts = ArrayList<String>()
val sb = StringBuilder()
for (c in text) {
sb.append(c)
if (c == '.' || c == '!' || c == '?' || c == '\n') {
val t = sb.toString().trim()
if (t.isNotEmpty()) parts.add(t)
sb.setLength(0)
}
}
val rest = sb.toString().trim()
if (rest.isNotEmpty()) parts.add(rest)
return parts
}
/** Derniers [n] caractères en s'alignant sur un début de mot. */
private fun tail(s: String, n: Int): String {
if (s.length <= n) return s.trim()
val cut = s.length - n
val sp = s.indexOf(' ', cut)
return (if (sp in 0 until s.length) s.substring(sp) else s.substring(cut)).trim()
}
}

View File

@ -0,0 +1,116 @@
package com.kazeia.rag
import android.util.Log
import kotlin.math.sqrt
/**
* Abstraction du moteur d'embedding pour le RAG. La seule partie qui dépend de
* kazeia-engine. Les vecteurs renvoyés DOIVENT être normalisés L2 (le cosinus
* se réduit alors à un produit scalaire côté [VectorIndex]).
*/
interface Embedder {
/** Dimension des vecteurs (0 tant qu'inconnue/non prête). */
val dim: Int
/** true si l'embedder peut réellement produire des vecteurs. */
val isReady: Boolean
/** Texte -> vecteur L2-normalisé de taille [dim]. Renvoie null si indisponible. */
fun embed(text: String): FloatArray?
fun close() {}
}
/** Normalise un vecteur en place (L2) et le renvoie. */
internal fun l2normalize(v: FloatArray): FloatArray {
var n = 0.0
for (x in v) n += x.toDouble() * x
if (n > 0.0) {
val inv = (1.0 / sqrt(n)).toFloat()
for (i in v.indices) v[i] *= inv
}
return v
}
/**
* Embedder réel basé sur kazeia-engine (libkazeia_engine.so : loadEmbedder /
* embedText / freeEmbedder cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md).
*
* Tant que la lib ne fournit pas encore ces entrypoints, [isReady] reste false
* (UnsatisfiedLinkError attrapé), le RAG se désactive proprement et n'injecte
* rien. Aucun impact sur la production. Dès que la .so est livrée, ça marche
* sans changement Kotlin.
*
* @param pooling -1 = défaut du modèle (recommandé), 1 = MEAN (e5), 2 = CLS (bge).
*/
class EngineEmbedder(
private val modelPath: String,
private val nThreads: Int = 4,
private val pooling: Int = -1,
private val queryPrefix: String = "", // e5 : "query: "
private val passagePrefix: String = "", // e5 : "passage: "
private val log: (String) -> Unit = {}
) : Embedder {
private val jni = com.kazeia.llm.EngineJni()
@Volatile private var handle: Long = 0L
@Volatile override var dim: Int = 0
private set
init { tryLoad() }
private fun tryLoad() {
try {
handle = jni.loadEmbedder(modelPath, nThreads, pooling)
if (handle != 0L) {
// Sonde la dimension via un embed neutre.
val probe = jni.embedText(handle, "test")
dim = probe?.size ?: 0
log("[RAG] embedder chargé: $modelPath (dim=$dim, pooling=$pooling)")
} else {
log("[RAG] embedder load FAIL (handle=0): $modelPath")
}
} catch (e: Throwable) {
// UnsatisfiedLinkError tant que la lib n'expose pas embedText, ou modèle absent.
log("[RAG] embedder indisponible (${e.javaClass.simpleName}: ${e.message}) — RAG désactivé")
handle = 0L; dim = 0
}
}
override val isReady: Boolean get() = handle != 0L && dim > 0
fun embedQuery(text: String): FloatArray? = embed(queryPrefix + text)
fun embedPassage(text: String): FloatArray? = embed(passagePrefix + text)
override fun embed(text: String): FloatArray? {
if (handle == 0L) return null
return try {
jni.embedText(handle, text)?.also { dim = it.size }
} catch (e: Throwable) {
Log.w("RAG", "embedText fail: ${e.message}"); null
}
}
override fun close() {
if (handle != 0L) { try { jni.freeEmbedder(handle) } catch (_: Throwable) {} ; handle = 0L }
}
}
/**
* Embedder factice DÉTERMINISTE pour tester toute la chaîne RAG (chunking,
* stockage, index, retrieval, injection) SANS kazeia-engine. Sac de trigrammes
* de caractères haché dans [dim] buckets puis normalisé : même texte -> même
* vecteur, textes partageant des sous-chaînes -> cosinus plus élevé (assez de
* signal pour valider le classement). N'a AUCUNE valeur sémantique réelle
* ne jamais l'utiliser en production.
*/
class FakeEmbedder(override val dim: Int = 96) : Embedder {
override val isReady: Boolean = true
override fun embed(text: String): FloatArray {
val v = FloatArray(dim)
val s = " ${text.lowercase()} "
for (i in 0..s.length - 3) {
val tri = s.substring(i, i + 3)
val b = (tri.hashCode() and 0x7fffffff) % dim
v[b] += 1f
}
return l2normalize(v)
}
}

View File

@ -0,0 +1,91 @@
package com.kazeia.rag
import android.content.Context
import android.util.Log
/**
* Façade RAG : ingestion (chunk -> embed -> SQLite) et récupération (embed
* requête -> index brute-force -> bloc CONTEXTE budgété pour le prompt).
*
* Indépendant de kazeia-engine SAUF l'[Embedder] injecté. Avec un
* [EngineEmbedder] non encore prêt (lib sans embedText), [isReady] = false et
* [retrieve] renvoie null aucune injection, production intacte. Avec un
* [FakeEmbedder], toute la chaîne est testable hors engine.
*/
class Rag(
context: Context,
private val embedder: Embedder,
private val modelTag: String, // identité du modèle d'embedding (invariant ingestion=requête)
private val log: (String) -> Unit = { Log.i("RAG", it) }
) {
private val db = RagDb.get(context)
private var index: VectorIndex? = null
val isReady: Boolean get() = embedder.isReady
/** Construit/rafraîchit l'index RAM depuis la base. À appeler au démarrage. */
@Synchronized
fun loadIndex() {
if (!embedder.isReady) { log("loadIndex: embedder non prêt — RAG inactif"); return }
val rows = db.loadAll()
if (rows.isEmpty()) { log("loadIndex: corpus vide"); index = null; return }
val dim = rows.first().vec.size
if (dim != embedder.dim) {
log("loadIndex: INCOHÉRENCE dim corpus=$dim vs embedder=${embedder.dim} (modèle changé ?) — RAG inactif")
index = null; return
}
val idx = VectorIndex(dim)
for (r in rows) if (r.vec.size == dim) idx.add(r.id, r.vec)
index = idx
log("loadIndex: ${idx.size} chunks (dim=$dim, modèle=${db.distinctModel()})")
}
/** Ingestion d'un document (remplace les chunks d'une même source). */
@Synchronized
fun ingest(source: String, rawText: String): Int {
if (!embedder.isReady) { log("ingest: embedder non prêt"); return 0 }
db.deleteSource(source)
val chunks = Chunker.chunk(rawText, source)
var n = 0
for ((i, ch) in chunks.withIndex()) {
val v = embedder.embed(ch) ?: continue
db.insert(source, i, ch, modelTag, v); n++
}
log("ingest '$source': $n chunks")
loadIndex()
return n
}
/**
* Récupère le contexte pertinent pour [query] et le formate en bloc prêt à
* injecter, ou null si rien au-dessus du seuil. Le seuil évite d'injecter du
* hors-sujet (risque clinique). [budgetChars] borne la taille (~4 c/token).
*/
@Synchronized
fun retrieve(
query: String,
k: Int = 4,
threshold: Float = 0.35f,
budgetChars: Int = 900
): String? {
val idx = index ?: return null
val qv = (embedder as? EngineEmbedder)?.embedQuery(query) ?: embedder.embed(query) ?: return null
val hits = idx.search(qv, k, threshold)
if (hits.isEmpty()) return null
val sb = StringBuilder("CONTEXTE DES DOCUMENTS :\n")
for (h in hits) {
val t = db.textOf(h.chunkId) ?: continue
val line = "- $t\n"
if (sb.length + line.length > budgetChars + 24) break
sb.append(line)
}
val out = sb.toString().trim()
log("retrieve: ${hits.size} hits (top=${"%.2f".format(hits.first().score)}), ${out.length} chars injectés")
return if (out.length > "CONTEXTE DES DOCUMENTS :".length) out else null
}
fun count(): Int = db.count()
fun clear() { db.clear(); index = null }
fun close() { embedder.close() }
}

View File

@ -0,0 +1,108 @@
package com.kazeia.rag
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Base SQLite du corpus RAG (chunks + vecteurs en BLOB float32 LE).
*
* Volontairement EN CLAIR (pas SQLCipher comme conversations.db) : c'est une
* base de connaissances de référence, pas une donnée patient secrète. Ça permet
* aussi de distribuer un corpus pré-construit via le catalogue. Si un jour on
* veut du RAG par-patient confidentiel, basculer sur le pattern SQLCipher de
* ConversationDb (passphrase SecureKeyStore) changement local.
*
* `model` + `dim` stockés par chunk : garde-fou de l'invariant « même modèle
* d'embedding à l'ingestion et à la requête ».
*/
class RagDb private constructor(context: Context)
: SQLiteOpenHelper(context.applicationContext, DB_NAME, null, DB_VERSION) {
companion object {
const val DB_NAME = "kazeia_rag.db"
const val DB_VERSION = 1
const val TABLE = "rag_chunks"
const val COL_ID = "id"
const val COL_SOURCE = "source"
const val COL_POS = "position"
const val COL_TEXT = "text"
const val COL_MODEL = "model"
const val COL_DIM = "dim"
const val COL_VEC = "vec"
@Volatile private var INSTANCE: RagDb? = null
fun get(context: Context): RagDb = INSTANCE ?: synchronized(this) {
INSTANCE ?: RagDb(context).also { INSTANCE = it }
}
fun vecToBlob(v: FloatArray): ByteArray {
val bb = ByteBuffer.allocate(v.size * 4).order(ByteOrder.LITTLE_ENDIAN)
for (x in v) bb.putFloat(x)
return bb.array()
}
fun blobToVec(b: ByteArray): FloatArray {
val bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN)
return FloatArray(b.size / 4) { bb.float }
}
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE $TABLE (
$COL_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COL_SOURCE TEXT NOT NULL,
$COL_POS INTEGER NOT NULL,
$COL_TEXT TEXT NOT NULL,
$COL_MODEL TEXT NOT NULL,
$COL_DIM INTEGER NOT NULL,
$COL_VEC BLOB NOT NULL
)
""".trimIndent())
db.execSQL("CREATE INDEX idx_rag_source ON $TABLE ($COL_SOURCE)")
Log.i("RagDb", "created $DB_NAME v$DB_VERSION")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { /* futur */ }
fun insert(source: String, position: Int, text: String, model: String, vec: FloatArray): Long {
val cv = ContentValues().apply {
put(COL_SOURCE, source); put(COL_POS, position); put(COL_TEXT, text)
put(COL_MODEL, model); put(COL_DIM, vec.size); put(COL_VEC, vecToBlob(vec))
}
return writableDatabase.insert(TABLE, null, cv)
}
/** Charge tous les chunks (id, texte, vecteur) pour peupler l'index RAM. */
fun loadAll(): List<Row> {
val out = ArrayList<Row>()
readableDatabase.query(TABLE, arrayOf(COL_ID, COL_TEXT, COL_VEC), null, null, null, null, COL_ID).use { c ->
while (c.moveToNext()) {
out.add(Row(c.getLong(0), c.getString(1), blobToVec(c.getBlob(2))))
}
}
return out
}
fun textOf(id: Long): String? =
readableDatabase.query(TABLE, arrayOf(COL_TEXT), "$COL_ID=?", arrayOf(id.toString()), null, null, null).use {
if (it.moveToFirst()) it.getString(0) else null
}
fun count(): Int =
readableDatabase.rawQuery("SELECT COUNT(*) FROM $TABLE", null).use { if (it.moveToFirst()) it.getInt(0) else 0 }
fun distinctModel(): String? =
readableDatabase.rawQuery("SELECT $COL_MODEL FROM $TABLE LIMIT 1", null).use { if (it.moveToFirst()) it.getString(0) else null }
fun clear() { writableDatabase.delete(TABLE, null, null) }
fun deleteSource(source: String): Int =
writableDatabase.delete(TABLE, "$COL_SOURCE=?", arrayOf(source))
data class Row(val id: Long, val text: String, val vec: FloatArray)
}

View File

@ -0,0 +1,46 @@
package com.kazeia.rag
/**
* Index vectoriel en RAM, recherche EXHAUSTIVE (brute-force) par cosinus.
*
* Choix assumé : à l'échelle de Kazeia (10^210^3 chunks thérapeutiques, un
* patient), le brute-force est exact et coûte ~13 ms. Aucun index ANN
* (HNSW/IVF) n'est justifié avant ~10^5 vecteurs. Voir l'analyse d'archi.
*
* Les vecteurs sont supposés L2-normalisés (cf [Embedder]) cosinus = produit
* scalaire.
*/
class VectorIndex(val dim: Int) {
private val ids = ArrayList<Long>()
private val vecs = ArrayList<FloatArray>()
val size: Int get() = ids.size
fun clear() { ids.clear(); vecs.clear() }
fun add(chunkId: Long, vec: FloatArray) {
require(vec.size == dim) { "VectorIndex: dim attendue=$dim, reçue=${vec.size}" }
ids.add(chunkId); vecs.add(vec)
}
private fun dot(a: FloatArray, b: FloatArray): Float {
var s = 0f
for (i in 0 until dim) s += a[i] * b[i]
return s
}
/** Top-k par score décroissant, filtré par [threshold]. */
fun search(query: FloatArray, k: Int, threshold: Float): List<Hit> {
if (query.size != dim || ids.isEmpty()) return emptyList()
val hits = ArrayList<Hit>(ids.size)
for (i in ids.indices) {
val s = dot(query, vecs[i])
if (s >= threshold) hits.add(Hit(ids[i], s))
}
hits.sortByDescending { it.score }
return if (hits.size > k) hits.subList(0, k).toList() else hits
}
data class Hit(val chunkId: Long, val score: Float)
}

View File

@ -72,6 +72,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
private var runtimeConfig: com.kazeia.config.ConfigStore.RuntimeConfig =
com.kazeia.config.ConfigStore.defaultConfig()
private var configReloadReceiver: android.content.BroadcastReceiver? = null
// RAG optionnel (default OFF). Inerte tant que l'embedder kazeia-engine n'est
// pas prêt (embedText absent / modèle d'embedding non présent) -> aucune injection.
@Volatile private var rag: com.kazeia.rag.Rag? = null
private lateinit var stt: SttEngine
// Cycle de vie Whisper STT — lazy load + swap intelligent.
@ -618,6 +621,22 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
} catch (e: Exception) { log("LLM test error: ${e.message}") }
}
}
intent?.getStringExtra("rag_test")?.let { query ->
// Valide TOUTE la chaîne RAG (chunk -> embed -> SQLite -> index -> retrieve)
// SANS kazeia-engine, via FakeEmbedder. Seuil 0 (le fake a un signal faible).
log("RAG test: '$query'")
serviceScope.launch {
try {
val rag = com.kazeia.rag.Rag(this@KazeiaService, com.kazeia.rag.FakeEmbedder(), "fake-test") { m -> log(m) }
rag.clear()
rag.ingest("insomnie", "L'insomnie est un trouble du sommeil fréquent. Pour mieux dormir, évitez les écrans le soir et gardez une heure de coucher régulière. La respiration lente aide à l'endormissement.")
rag.ingest("solitude", "Le sentiment de solitude peut être douloureux. Renouer un petit lien social, même bref, aide beaucoup. Noter trois choses positives par jour réduit la rumination.")
rag.loadIndex()
val ctx = rag.retrieve(query, threshold = 0.0f)
log("RAG test RESULT (corpus=${rag.count()} chunks):\n${ctx ?: "(rien au-dessus du seuil)"}")
} catch (e: Exception) { log("RAG test error: ${e.message}"); e.printStackTrace() }
}
}
if (intent?.hasExtra("cfg_set") == true) {
// Maintenance/bench : persiste engine+model+prompt sans passer par le
// ContentProvider CLI (qui casse sur les valeurs contenant ':'). Les
@ -784,6 +803,19 @@ 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 {
it.loadIndex(); 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…")
// Audio
audioPlayback = AudioPlaybackManager()
@ -1681,11 +1713,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
null
}
val prompt = promptBuilder.build(
var prompt = promptBuilder.build(
message = patientMessage,
history = _messages.value
)
// Injection RAG : si activé + embedder prêt + contexte au-dessus du seuil,
// on préfixe le bloc CONTEXTE au prompt patient. Le seuil dans Rag.retrieve
// évite d'injecter du hors-sujet (garde-fou clinique). Le RAG ne porte
// JAMAIS la gestion de crise.
rag?.takeIf { it.isReady }?.let { r ->
try {
r.retrieve(patientMessage)?.let { ctx ->
prompt = "$ctx\n\n$prompt"
log("[RAG] contexte injecté (${ctx.length} chars)")
}
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
}
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate,
// so the first complete sentence emitted by the LLM (on terminal
// punctuation) is enqueued for synthesis while the LLM is still

View File

@ -187,7 +187,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
val cfg = com.kazeia.config.ConfigStore.get(ctx).current()
val cols = arrayOf(
"cascade_enabled", "tts_enabled",
"stt_engine", "llm_engine", "tts_engine",
"stt_engine", "llm_engine", "tts_engine", "rag_enabled",
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
)
@ -196,6 +196,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
if (cfg.cascadeEnabled) 1 else 0,
if (cfg.ttsEnabled) 1 else 0,
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
if (cfg.ragEnabled) 1 else 0,
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
))
@ -286,6 +287,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
ragEnabled = if (values.containsKey("rag_enabled"))
values.getAsBoolean("rag_enabled") else cur.ragEnabled,
speaker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
systemPrompt = values.getAsString("speaker_system_prompt")