From 3eab29beb2f28e1591c473fc765f925fbb3747e5 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Tue, 9 Jun 2026 22:09:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(rag):=20module=20RAG=20on-device=20(ind?= =?UTF-8?q?=C3=A9pendant=20de=20l'engine),=20flag=20OFF=20par=20d=C3=A9fau?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/kazeia/config/ConfigStore.kt | 8 +- .../java/com/kazeia/llm/EngineLlmEngine.kt | 7 ++ .../src/main/java/com/kazeia/rag/Chunker.kt | 77 ++++++++++++ .../src/main/java/com/kazeia/rag/Embedder.kt | 116 ++++++++++++++++++ .../app/src/main/java/com/kazeia/rag/Rag.kt | 91 ++++++++++++++ .../app/src/main/java/com/kazeia/rag/RagDb.kt | 108 ++++++++++++++++ .../main/java/com/kazeia/rag/VectorIndex.kt | 46 +++++++ .../java/com/kazeia/service/KazeiaService.kt | 47 ++++++- .../telemetry/KazeiaTelemetryProvider.kt | 5 +- 9 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/Chunker.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/Embedder.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/RagDb.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/rag/VectorIndex.kt diff --git a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt index e96b2c5..27babd8 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt @@ -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), diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt index 6b7227d..78efa0e 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt @@ -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 diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/Chunker.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/Chunker.kt new file mode 100644 index 0000000..b0abd9f --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/Chunker.kt @@ -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: ]` 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 { + 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() + 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 { + val parts = ArrayList() + 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() + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/Embedder.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/Embedder.kt new file mode 100644 index 0000000..7659e4d --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/Embedder.kt @@ -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) + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt new file mode 100644 index 0000000..23bcf81 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt @@ -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() } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/RagDb.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/RagDb.kt new file mode 100644 index 0000000..441caa6 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/RagDb.kt @@ -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 { + val out = ArrayList() + 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) +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/rag/VectorIndex.kt b/kazeia-android/app/src/main/java/com/kazeia/rag/VectorIndex.kt new file mode 100644 index 0000000..b17a90e --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/rag/VectorIndex.kt @@ -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^2–10^3 chunks thérapeutiques, un + * patient), le brute-force est exact et coûte ~1–3 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() + private val vecs = ArrayList() + + 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 { + if (query.size != dim || ids.isEmpty()) return emptyList() + val hits = ArrayList(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) +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt index 0bee524..8b16022 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -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 diff --git a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt index 8f6934f..cda1d79 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt @@ -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")