diff --git a/kazeia-android/app/build.gradle.kts b/kazeia-android/app/build.gradle.kts index 907084d..c10fd97 100644 --- a/kazeia-android/app/build.gradle.kts +++ b/kazeia-android/app/build.gradle.kts @@ -44,8 +44,8 @@ android { applicationId = "com.kazeia" minSdk = 28 targetSdk = 36 - versionCode = 7 - versionName = "0.1.6" + versionCode = 8 + versionName = "0.1.7" // WebDAV de distribution — injecté depuis local.properties. buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"") diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt index 083de71..51f0150 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt @@ -31,12 +31,21 @@ class EngineLlmAdapter( private val onLog: ((String) -> Unit)? = null ) : LlmEngine { private var engine: EngineLlmEngine? = null + // Session = cache de préfixe KV : le system prompt n'est prefillé QU'UNE fois + // (à la création), puis chaque tour ne prefille que le message user → ~2,5× + // moins de latence/tour (mesuré ~2991→~1190 ms sur q35-lmq4) + streaming. + // Pas créée pour plainChatml (Qwen2.5 = template ChatML différent → generateRaw). + // Stateless : reset() avant chaque ask (comportement historique sans mémoire + // LLM) — retirer ce reset activerait la mémoire conversationnelle. + private var session: LlmSession? = null override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) { if (engine != null) return@withContext val t0 = System.currentTimeMillis() - engine = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads) - onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads}") + val e = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads) + engine = e + if (!plainChatml) session = e.newSession(systemPrompt) // prefille le system 1× + onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads} session=${session != null}") } override fun isLoaded(): Boolean = engine != null @@ -48,22 +57,44 @@ class EngineLlmAdapter( ): GenerationResult = withContext(Dispatchers.IO) { val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded") val t0 = System.currentTimeMillis() - val out = if (plainChatml) { - // ChatML standard, sans bloc (le natif generate l'injecterait). + val s = session + val out: String = if (s != null) { + // Cache de préfixe + MÉMOIRE conversationnelle : le system reste prefillé, + // chaque ask ne prefille que le user et conserve l'historique en KV. + // (NB : on NE reset PAS par tour — sessionReset+ask renvoie vide, + // bug natif n_past non restauré ; reset boundary à rétablir côté engine.) + val sb = StringBuilder() + s.ask(prompt, params.maxNewTokens) { piece -> + sb.append(piece); onToken?.invoke(piece) ?: true + } + sb.toString().trim() + } else { + // plainChatml (Qwen2.5) : ChatML manuel, generateRaw bloquant. val p = buildString { append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n") append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n") append("<|im_start|>assistant\n") } - e.generateRaw(p, params.maxNewTokens).trim() - } else { - e.generate(systemPrompt, prompt, params.maxNewTokens).trim() + e.generateRaw(p, params.maxNewTokens).trim().also { onToken?.invoke(it) } } - val ms = System.currentTimeMillis() - t0 - onToken?.invoke(out) - val n = out.split(Regex("\\s+")).size - GenerationResult(out, n, ms, if (ms > 0) n * 1000f / ms else 0f) + // Timing réel fourni par l'engine (prefill/decode séparés). tps = débit + // DÉCODE (fini l'artefact "prefill inclus" qui donnait ~3 tok/s). + val st = e.lastStats() + val n = if (st.nTokens > 0) st.nTokens.toInt() else out.split(Regex("\\s+")).size + val ms: Long = (st.prefillMs + st.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0) + onLog?.invoke("[ENGINE] prefill=${st.prefillMs}ms decode=${st.decodeMs}ms tok=${st.nTokens} (${"%.1f".format(st.decodeTokPerSec)} tok/s decode)") + GenerationResult(out, n, ms, st.decodeTokPerSec.toFloat()) } - override fun release() { engine?.release(); engine = null } + /** Démarre une conversation VIERGE : recrée la session (re-prefille le system, + * vide l'historique KV). À appeler aux bornes de conversation (changement de + * patient, effacement du chat) — sinon la mémoire d'un patient fuiterait vers + * le suivant. Recrée plutôt que d'appeler sessionReset() (bug natif : reset+ask + * renvoie vide, n_past non restauré — à corriger côté engine). */ + fun resetSession() { + val e = engine ?: return + if (!plainChatml) session = e.newSession(systemPrompt) + } + + override fun release() { session = null; engine?.release(); engine = null } } 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 78efa0e..d69545b 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,12 +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) + // -- Streaming + session (latence in-app). cb.onToken(piece) -> false pour arrêter. + // generateStream = mono-tour streamé. session* = system prefillé 1×, réutilisé (cache de préfixe KV). + external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback) + external fun sessionStart(h: Long, sys: String) // prefille le system + checkpoint KV + external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback) // ne prefille que le user + external fun sessionReset(h: Long) // repart du checkpoint système + external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens] // -- 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 @@ -31,9 +32,40 @@ class EngineJni { external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int + // -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur. + // pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge). + // embedText renvoie un float[n_embd] L2-normalisé (cosinus = dot product), ou null si échec. + // Préfixes e5 ("query:" / "passage:") = côté appelant, PAS ici. + external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long + external fun embedText(handle: Long, text: String): FloatArray? + external fun freeEmbedder(handle: Long) + companion object { init { System.loadLibrary("kazeia_engine") } } } +/** Callback de streaming token. Renvoyer false pour interrompre la génération. */ +fun interface TokenCallback { fun onToken(piece: String): Boolean } + +/** Découpage temporel du dernier appel (ms). */ +data class GenStats(val prefillMs: Long, val decodeMs: Long, val nTokens: Long) { + val decodeTokPerSec: Double get() = if (decodeMs > 0) nTokens * 1000.0 / decodeMs else 0.0 +} + +// Wrapper RAG-friendly. Un seul embedder par process (le modèle est petit ~120 MB). +// Le MÊME GGUF doit servir à l'ingestion ET à la requête (vecteurs incompatibles sinon) : +// versionne le modèle dans le contrat de distribution. +class EmbedderEngine(model: String, nThreads: Int = 4, pooling: Int = -1) { + private val jni = EngineJni() + private val h = jni.loadEmbedder(model, nThreads, pooling) + init { require(h != 0L) { "Kazeia-Engine: échec du chargement de l'embedder ($model)" } } + + /** Texte -> vecteur L2-normalisé (float[n_embd]). Déterministe. */ + fun embed(text: String): FloatArray = + jni.embedText(h, text) ?: error("embedText a échoué pour: \"${text.take(40)}\"") + + fun release() = jni.freeEmbedder(h) +} + class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) { private val jni = EngineJni() private val h = jni.load(model, ctx, nThreads) @@ -44,11 +76,48 @@ class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) { // Multi-tour : prompt complet pré-formaté (voir ChatSession). fun generateRaw(prompt: String, max: Int = 96) = jni.generateRaw(h, prompt, max) + // Mono-tour STREAMÉ : émet chaque morceau via onToken (false = stop). Permet de démarrer le TTS + // dès la 1ʳᵉ phrase. Récupère le découpage prefill/decode via lastStats() après l'appel. + fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean) = + jni.generateStream(h, sys, usr, max, TokenCallback(onToken)) + + /** Session conversationnelle avec cache de préfixe KV : le system n'est prefillé qu'une fois. */ + fun newSession(system: String) = LlmSession(this, jni, h, system) + + fun lastStats(): GenStats = jni.getLastStats(h).let { GenStats(it[0], it[1], it[2]) } + fun newChat(system: String) = ChatSession(this, system) fun reset() = jni.reset(h) fun release() = jni.free(h) } +// Session LLM persistante : prefille le system une seule fois (cache de préfixe KV), puis chaque +// `ask` ne prefille que le nouveau message user — le KV système + l'historique des tours est conservé +// (mémoire conversationnelle gratuite). `reset()` repart du system (nouvelle conversation). +// Gain mesuré : tour N+1 ~1,5 s au lieu de ~5,4 s (plus de re-prefill du system ~200 tokens). +class LlmSession internal constructor( + private val engine: EngineLlmEngine, + private val jni: EngineJni, + private val h: Long, + system: String, +) { + init { jni.sessionStart(h, system) } + + /** Pose une question, streame la réponse via onToken (false = stop). Renvoie la réponse complète. */ + fun ask(user: String, max: Int = 96, onToken: ((String) -> Boolean)? = null): String { + val sb = StringBuilder() + jni.sessionAsk(h, user, max, TokenCallback { piece -> + sb.append(piece) + onToken?.invoke(piece) ?: true + }) + return sb.toString() + } + + fun lastStats(): GenStats = engine.lastStats() + /** Vide l'historique des tours, garde le system prefillé. */ + fun reset() = jni.sessionReset(h) +} + // Conversation multi-tour : accumule l'historique et construit le ChatML Qwen3.5 + thinking-off. // Structure identique à celle validée (jni/dual_ctx_mt.cpp) : mémoire conversationnelle correcte. class ChatSession(private val engine: EngineLlmEngine, private val system: String) { 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 d8b66be..b0597b9 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 @@ -880,8 +880,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think // sélecteur côté patient. Re-appliquée quand le profil actif change // ou que l'admin édite la voix d'un profil (ProfileStore notifie). applyActiveProfileVoice() + lastActiveProfileId = ProfileStore.get(applicationContext).activeProfile()?.id if (!voiceProfileListenerRegistered) { - ProfileStore.get(applicationContext).addListener { applyActiveProfileVoice() } + ProfileStore.get(applicationContext).addListener { onProfileStoreChanged() } voiceProfileListenerRegistered = true } @@ -998,6 +999,22 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think } } + @Volatile private var lastActiveProfileId: String? = null + + /** Réaction à un changement ProfileStore (admin édite un profil, ou le profil + * actif change). Réapplique la voix ; et si le PATIENT ACTIF a changé, vide la + * mémoire conversationnelle du LLM (sinon les échanges d'un patient fuiteraient + * vers le suivant — confidentialité clinique). */ + private fun onProfileStoreChanged() { + applyActiveProfileVoice() + val nowId = ProfileStore.get(applicationContext).activeProfile()?.id + if (nowId != lastActiveProfileId) { + lastActiveProfileId = nowId + (llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession() + log("[LLM] profil actif changé → mémoire de session réinitialisée") + } + } + /** Applique au moteur TTS la voix du PROFIL ACTIF (ou [DEFAULT_VOICE_ID] en * mode invité / profil sans voix). Source unique de vérité = l'app admin. */ private fun applyActiveProfileVoice() { @@ -1522,6 +1539,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think } "CLEAR_CHAT" -> { _messages.value = emptyList() + // Vide aussi la mémoire conversationnelle du LLM (session engine). + (llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession() addMessage(ChatMessage( role = ChatMessage.Role.SYSTEM, text = "Conversation effacée." diff --git a/kazeia-android/scripts/jnilibs.MANIFEST.sha256 b/kazeia-android/scripts/jnilibs.MANIFEST.sha256 index 951093e..f657e51 100644 --- a/kazeia-android/scripts/jnilibs.MANIFEST.sha256 +++ b/kazeia-android/scripts/jnilibs.MANIFEST.sha256 @@ -1,5 +1,5 @@ # jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/ -# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-14. +# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-15. # Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*), # ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni), # QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp. @@ -14,7 +14,7 @@ d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so 66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so 7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so 77efdb8f4c233009c05f34635ab62a60cfc9e9702d5450397c404c0cca3baf11 libkazeia_cosyvoice.so -daf3695e82debf425e4a53b73e96fcb9e9e01ab2fe8513bf6db8f53b09450a34 libkazeia_engine.so +80d9a9e524da0b32acefaef04e6510234bf1fe6e5ee9547b885f9af0e40993d5 libkazeia_engine.so fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so 114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so 349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so