Compare commits

...

30 Commits

Author SHA1 Message Date
Kazeia Team 3b58fcb467 feat(tts): CosyVoice rapide (hiftf16, RTF 0.88 + cache) + guard init — v0.1.10
Nouveau jeu CosyVoice livré par l'engine (mêmes 5 symboles JNI, façade inchangée,
toujours auto-contenu) : 2 .so swappés (libkazeia_cosyvoice + libcosyvoice ;
libomp/libc++_shared identiques). Modèle cv3_distilled_30k_hiftf16.gguf (953 Mo)
remplace cv3_distilled_30k.gguf — MODEL mis à jour dans l'adaptateur.

Validé device : charge OK, synthèse OK, cache de voix entre appels →
1er son tour 1 ~10,6s → tour 2 ~6,7s (-37%). (RTF dev annoncé 0.88 ; sur cette
tablette thermiquement saturée on mesure ~1,1-1,7 — à revérifier device froid.)

Bonus robustesse : guard `if (!::voiceCommands.isInitialized) return false` dans
handleVoiceCommand — une entrée arrivée avant la fin de l'init du service ne
crashe plus (UninitializedPropertyAccessException, vu en test en tirant un intent
trop tôt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:50:42 +02:00
Kazeia Team db0e56b26c perf(tts): NE PAS streamer LLM→CosyVoice (contention CPU) — documenté — v0.1.9
« Reste optionnel » : tenté le streaming LLM→TTS pour CosyVoice (synthèse de la
1ʳᵉ phrase pendant que le LLM décode, comme le chemin Qwen TTS-B).

Mesuré device : CONTRE-PRODUCTIF. LLM decode (6 threads CPU) et synthèse
CosyVoice (8 threads CPU) saturent tous les cœurs ; les chevaucher écroule le
LLM (17 → 0,55 tok/s, tour 1,1s → 41s, 1er son à 43s). Reverté.

Le streaming LLM→TTS n'a de sens que si les 2 étages utilisent des compute
distincts (Qwen3 NPU + TTS CPU). Pour CosyVoice (tout CPU) : laisser le LLM
finir vite PUIS synthesizeAndPlay, qui streame déjà phrase-par-phrase EN INTERNE
(synthèse N+1 pendant lecture N, sans contention). Net diff = un commentaire
garde-fou dans KazeiaService pour ne pas re-tenter ; code de streaming retiré.

Re-validé batch : LLM 17,1 tok/s (1,9s), TTS 1er son ~8s (RTF CosyVoice inhérent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:29:25 +02:00
Kazeia Team b822974b99 fix(llm): sessionReset natif corrigé (archi hybride) — reset de borne propre — v0.1.8
Bug : sessionReset()+sessionAsk() renvoyait VIDE. Cause = q35-lmq4 est l'archi
hybride Qwen3.5 (DeltaNet/SSM) ; llama_memory_seq_rm d'un suffixe « Returns false
if a partial sequence cannot be removed » (l'état récurrent ne se rembobine pas
partiellement). Le code ignorait ce retour → KV incohérent → tour suivant vide.

Fix natif (kazeia-engine dist/jni/kazeia_engine_jni.cpp, .so rebuildé CPU-only,
drop-in) : sessionReset teste le retour de seq_rm ; s'il échoue (récurrent/
hybride) → reset COMPLET (llama_memory_clear + re-prefill du system mémorisé) +
llama_sampler_reset. sessionStart mémorise désormais les tokens system.

App : EngineLlmAdapter.resetSession() rebascule sur le session.reset() natif
(au lieu du contournement newSession). Validé device : après reset, le tour
n'est plus vide ET la mémoire est effacée (ne connaît plus le prénom),
prefill ~285ms, decode ~17 tok/s, tours suivants OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:26:00 +02:00
Kazeia Team 8d548b1ca6 feat(llm): session cache-préfixe KV + streaming + mémoire conv. — v0.1.7
Intègre les 3 APIs livrées par l'engine (libkazeia_engine.so drop-in, façade
EngineLlmEngine.kt : generateStream / newSession / LlmSession / GenStats).

EngineLlmAdapter : crée une LlmSession au load (system prefillé 1×), et chaque
generate() fait session.ask() en STREAMING (onToken câblé au pipeline existant).
Le system n'est plus re-prefillé à chaque tour.

Mesuré device (q35-lmq4, t=6, v0.1.7) :
- tour : 2991→~1100 ms (prefill 2700→~300 ms, system caché). Decode réel
  17 tok/s (le « 3,2 » était l'artefact prefill-inclus ; tps vient maintenant
  de getLastStats, débit décode).
- streaming callback OK sous vraie JVM (le seul point que le dev n'avait pas
  pu tester) — aucun crash.
- mémoire conversationnelle (bonus) : rappelle « Richard » sur 3 tours.

Confidentialité : la mémoire est vidée aux bornes de conversation —
EngineLlmAdapter.resetSession() (recrée la session) appelé sur CLEAR_CHAT et
sur changement de profil actif (onProfileStoreChanged). Validé : après switch
de profil, le LLM ne connaît plus le prénom.

⚠ Bug natif à corriger côté engine : sessionReset()+sessionAsk() renvoie vide
(n_past non restauré à n_sys après seq_rm). Contourné en recréant la session
(newSession). Le mode mémoire (ask→ask sans reset) est, lui, OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:12:11 +02:00
Kazeia Team 2acf3922c9 feat(llm): défaut kazeia-engine GGUF (lib), plus de .pte au runtime — v0.1.6
Directive 2026-06-15 : si on passe par kazeia-engine, on n'utilise plus de .pte.

- ConfigStore.llmEngine défaut "prod"(.pte) → "lib" (EngineLlmAdapter +
  libkazeia_engine + GGUF). Le .pte n'est plus chargé au runtime ; il reste
  câblé en repli d'urgence seulement (et sur cette plateforme son runner QNN
  échoue de toute façon : « Failed to load llm runner [1] »).
- ModelRegistry.defaultSpeaker() "qwen3-4b-seq1024"(.pte) → "qwen3.5-4b" (GGUF,
  q35-lmq4) : config cohérente, le mode lib charge sp.ggufPath() directement
  sans repli, zéro référence .pte.

Validé device (release v0.1.6, SM8750) : libkazeia_engine charge q35-lmq4.gguf
en 1.8 s (backend='lib'), 2 générations FR thérapeutiques cohérentes
(« C'est compréhensible, je suis là avec toi… » / « C'est vraiment épuisant,
je te comprends. »). Règle aussi le mode perroquet observé avec le .pte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:15:27 +02:00
Kazeia Team 359252bf5e feat(voice): voix pilotée par profil (app admin) — retrait du sélecteur patient — v0.1.5
La voix n'est plus choisie côté patient ; elle est définie PAR PROFIL dans l'app
admin (le champ Profile.voiceId + le dropdown admin existaient déjà ; seul le
runtime ne l'appliquait pas).

App patient :
- ChatActivity : suppression de setupVoiceSelector() + des listes voiceFiles/
  voiceNames/voiceColors. activity_chat.xml : suppression de la voiceBar (spinner),
  l'orbe se contraint désormais sous tvStatus (couleur = défaut service).
- KazeiaService : nouvelle source unique = le profil actif.
  - applyActiveProfileVoice() lit ProfileStore.activeProfile().voiceId (défaut
    DEFAULT_VOICE_ID="damien" en invité), appelée à l'init ET via un listener
    ProfileStore → propagation LIVE quand l'admin édite la voix / change de profil.
  - setVoiceId(id) résout selon le moteur actif : CosyVoice → cosyvoice/<id>.cvps ;
    Qwen3/lib → ../voix/<id>.wav. setVoice(pathOrId) conservé (intents test) délègue.

Validé device (release v0.1.5, SM8750, moteur cosyvoice) :
- plus de barre de voix dans l'UI patient (confirmé).
- profil voice_id=elodie → service charge 'elodie' (≠ défaut) ; changement live
  via provider voice_id=damien → ré-appliqué sans redémarrage.
- moteur 'lib' charge toujours (pas de régression du stack gelé).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:51:42 +02:00
Kazeia Team cca38ef50f feat(tts): intégrer libs CosyVoice3 auto-contenues + validation device — v0.1.4
Blocage libggml levé côté engine : nouvelles libs auto-contenues (ggml statique
+ visibilité hidden dans libcosyvoice.so, 0 symbole ggml_ exporté). 3 .so ajoutés
à jniLibs/arm64-v8a/ (libkazeia_cosyvoice, libcosyvoice ~20 Mo, libc++_shared ;
libomp déjà présent identique). Manifest régénéré (26 libs).

Validé on-device (release v0.1.4, SM8750) :
- chargement libkazeia_cosyvoice.so OK, modèle cv3_distilled_30k.gguf chargé en
  997 ms, voix 'damien' chargée, aucune collision libggml ni crash.
- nativeSynthesize (le marshalling JNI String→FloatArray, seul point non testé
  par le dev) CONFIRMÉ : phrase FR → 214080 samples 24 kHz (8.92 s), RTF ≈ 1.0.
  Audio sain (RMS 2303, pic 19741, 51% non-silence), voix clonée audible.

jniLibs gitignorés (whitelist) : seul le manifest est versionné. Tarball
build-artifacts à régénérer pour les machines tierces (suivi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:31:42 +02:00
Kazeia Team ed61737199 feat(tts): adapter CosyVoice3 + dispatch ttsEngine="cosyvoice" (câblage, libs en attente)
Câblage Kotlin/APK du moteur TTS clonage vocal CosyVoice3 distillé livré par
kazeia-engine (dist/cosyvoice). Mirror du pattern sttEngine/ttsEngine="lib".

- CosyVoiceTtsEngine.kt : CosyVoiceJni (5 symboles) + adapter implémentant
  com.kazeia.core.TtsEngine. Cache de voix (.cvps), synthèse phrase-par-phrase,
  streaming AudioTrack MODE_STREAM en ENCODING_PCM_FLOAT @24 kHz (1er son joué
  pendant que la phrase suivante se génère ; RTF~1.4 masqué par le streaming),
  suspension jusqu'au drain réel (marker) pour garder le micro coupé (anti-écho).
- KazeiaService : branche when(ttsEngine) { "cosyvoice" -> ... } à l'init.
- ConfigStore : doc du nouveau backend.

⚠ NON validé device : BLOQUEUR côté libs engine. Les .so livrés embarquent leurs
propres libggml*.so (SONAME identiques au stack LLM/TTS gelé, 535 symboles ggml_*
en collision) → interposition ELF dans le namespace JNI (RTLD_GLOBAL) : le premier
libggml chargé (LLM, au boot) gagnerait, libcosyvoice se lierait au mauvais ggml →
crash. Ni écraser ni omettre les libggml app ne marche (consommés par libllama/
libkazeia_engine/libkazeia_tts gelés). Fix attendu : ggml statique + visibilité
hidden dans libcosyvoice.so côté engine. Code prêt à valider dès libs corrigées.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:53:30 +02:00
Kazeia Team 16d56bfd4d fix: défaut ttsEngine="lib" (crash-loop install fraîche) + ragCorpusDir externe-d'abord — v0.1.3
Deux bugs de prod trouvés en validant le cycle OTA corpus r2 :

1. ttsEngine default "prod" → toute install fraîche (release patient) chargeait
   Qwen3TtsEngine legacy → SIGSEGV déterministe dans llama_context ctor
   (libtts_talker_cpu compilé contre llama-upstream, libllama.so embarqué =
   fork ql depuis 2026-06-02, dérive ABI llama_context_params). La tablette
   release crash-loopait au démarrage du service depuis la migration ; la
   tablette dev ne le voyait pas (config tts_engine=lib persistée).
   → default "lib" (seul backend compatible avec les jniLibs livrés).

2. Le corpus RAG était résolu via MODELS_DIR/../rag_corpus : sur tablette dev,
   MODELS_DIR = legacy /data/local/tmp (modèles adb) → syncDir lisait l'ancien
   corpus alors que l'OTA dépose le composant rag_corpus dans le stockage
   externe. → KazeiaPaths.ragCorpusDir avec priorité INVERSE des modèles :
   l'externe (installeur/OTA) prime dès qu'il est non vide, fallback legacy.

versionCode 4 / 0.1.3, publié catalog v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:32:16 +02:00
Kazeia Team 747848a475 feat(rag): syncDir — synchro corpus fichiers→base à chaque démarrage
L'auto-ingestion ne jouait qu'à base vide (1er démarrage) : une MAJ OTA du
composant rag_corpus déposait les nouveaux fichiers mais le RAG continuait de
servir l'ancien corpus, sans aucun déclencheur (sauf intent manuel rag_ingest).

Rag.syncDir(dir) : ingère tout fichier .txt/.md absent de la base ou dont le
texte diffère du brut stocké (comparaison exacte). Idempotent, n'embed que le
delta → appelé à CHAQUE buildRag, remplace le cas spécial « base vide ».
Ne supprime jamais (docs admin et docs retirés du dossier restent — suppression
via l'admin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:51:30 +02:00
Kazeia Team 3761e03d52 feat(rag): corpus psychoéducation FR étendu à 18 fiches (corpus-seed-r2)
Le « corpus de Damien » (/opt/Kazeia/Kaz/knowledge_base) s'est révélé être des
fichiers de test de la machinerie RAG (licornes, Wikipédia Musk, scrape Tomatis)
— aucune valeur clinique. Corpus écrit ici à la place : +12 fiches (crise
d'angoisse, pensées automatiques, émotions, estime de soi, auto-compassion,
colère, deuil, stress, relaxation musculaire progressive, pleine conscience,
anxiété sociale, demander de l'aide) en plus des 6 existantes.

Lignes éditoriales (README) : une fiche = un thème + une technique concrète,
~1 chunk (maxChars=1200), vouvoiement neutre, jamais de médication/diagnostic/
crise suicidaire (CrisisGuard gère la crise hors LLM). À valider par le
clinicien avant activation patient.

Publié : composant catalogue rag_corpus → corpus-seed-r2, catalog v4 en ligne.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:49:36 +02:00
Kazeia Team d7e90c16c4 fix(safety): rappel « mieux mort » + regex normalize compilées une fois
Code-review (2e passe) :
- Faux négatif CrisisGuard : « je serais/suis mieux mort », « mieux mort que
  vivant » ne déclenchaient aucun motif. Ajout du motif « mieux mort » + 3 cas
  de test. (Sécurité vitale — sensibilité d'abord.)
- normalize() recompilait 3 Regex à chaque appel (1×/message patient) :
  hoistées en constantes (MARKS/APOS/SPACES).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:22:27 +02:00
Kazeia Team 7669a79f4f fix(rag): ingestion atomique + build/teardown sérialisés (code-review)
Rag.ingest : embed D'ABORD, n'écrit la base qu'ensuite, via la nouvelle
RagDb.replaceSource (delete+insert dans UNE transaction). Un échec transitoire
d'embedding ne détruit plus les chunks existants (auparavant deleteSource était
appelé avant la boucle d'embed → corpus tronqué/vidé en silence, non rattrapé
par reingestMissing qui ne reprend que les docs à zéro chunk).

KazeiaService.buildRag/teardownRag : synchronized(ragLock) + buildRag idempotent
(retourne l'instance existante si déjà active). onCreate et un toggle config RAG
peuvent se chevaucher (deux coroutines serviceScope) → sans verrou, deux
EngineEmbedder natifs étaient créés et le perdant fuyait (jamais close()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:10:29 +02:00
Kazeia Team e1d9c94751 fix(safety,rag): correctifs critiques code-review
CrisisGuard (sécurité vitale) :
- normalize() : suppression accents (NFD) + apostrophes→espace + compactage,
  pour matcher la sortie STT FR (souvent sans accents) et la frappe libre.
- PATTERNS étendus : formulations directes manquantes (« je veux mourir »,
  « idées suicidaires/noires », « disparaître », « me pendre », « passer à
  l'acte », « plus la force de vivre »…). « me pendre » autonome (aucun usage
  bénin) ; « me noyer » reste gardé (idiome « se noyer dans le travail »).
- Tests étendus : 12 positifs + 4 négatifs (idiomes « mourir de rire/faim »,
  « idées plein la tête », « passe à la pharmacie »). 11/11 verts.

Rag.retrieve() : un chunk plus long que le budget ne fait plus retomber tout
le bloc à null — on garantit ≥1 hit (tronqué si besoin) puis on empile tant
qu'il reste du budget. Évite la perte de contexte pertinent.

Rag.close() : @Synchronized — sérialise avec retrieve()/searchScored() pour
qu'un teardown (toggle RAG off) ne libère pas le contexte natif de l'embedder
pendant une requête provider en cours (évite le SIGSEGV).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:07:51 +02:00
Kazeia Team 32669ae039 docs: emplacement Nextcloud du tarball jniLibs + recette clone→build complète
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:45:03 +02:00
Kazeia Team d47206bff8 release: bump v0.1.1 (versionCode 2) — premier cycle OTA réel validé
Migration tablette dev debug→release + cycle OTA complet exercé de bout en bout
pour la première fois :
- uninstall debug → install release v1 (signé clé Kazeia, cf RELEASE_SIGNING.md)
- bump versionCode 2 / versionName 0.1.1, assembleRelease (verifyJniLibs OK)
- publication Nextcloud : APK v2 + composants RAG (e5 + corpus) + catalog v3
- sur device : Onboarding détecte « Mise à jour disponible v0.1.1 », télécharge
  (106 Mo, SHA-256 vérifié), PackageInstaller installe in-place → versionCode=2.

PREUVE CLÉ (la raison d'être de la signature release) : un marqueur de config
posé en v1 (rag_enabled=1, rag_threshold=0.85) a SURVÉCU à la mise à jour v2 →
signature cohérente entre versions → une MAJ ne détruit pas les données patient.

Note : catalog.spec.json/dist non suivis (miroir data). versionCode=2 est la
seule trace git ; la prochaine release repart de là.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:39 +02:00
Kazeia Team 9c6f4e8838 build: reproductibilité des jniLibs — manifest sha256 + script + garde release
Les 23 .so de app/src/main/jniLibs/ (474 Mo, 5 chaînes de build : kazeia-engine,
ExecuTorch, QNN SDK 2.42, Genie, TTS pipeline) sont des artefacts gitignorés
posés à la main — « ça ne buildait que sur cette machine ». On ne les versionne
pas (l'historique gonflerait de ~500 Mo par update engine) ; on versionne leur
DÉFINITION :

- scripts/jnilibs.MANIFEST.sha256 : état attendu (sha256 + provenances).
- scripts/jnilibs.sh : verify (CI/Gradle) | sync-engine (rafraîchit le
  sous-ensemble kazeia-engine depuis /opt/Kazeia-engine/dist) | update-manifest
  (re-fige, à committer) | export/import (tarball pour autre machine).
- Garde Gradle : preReleaseBuild dépend de verifyJniLibs → un build RELEASE avec
  des libs manquantes/modifiées/non déclarées ÉCHOUE. Debug reste libre.

Validé : release passe avec libs conformes ; échoue sur lib altérée (MODIFIÉ
libomp.so) ; verify OK après restauration. Ménage au passage : 3 .bak morts
(~293 Mo) sortis de jniLibs vers _jnilibs_backup_baks/.

Nouvelle machine : git clone + ./scripts/jnilibs.sh import <tarball> (export
déposé sur box.kazeia.com privé) → build garanti conforme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:31:00 +02:00
Kazeia Team e5b48e4e0f test: tests unitaires JVM — CrisisGuard (sécurité vitale) + VectorIndex + Chunker
Premier harnais de tests du projet. testImplementation junit:4.13.2, src/test/.

CrisisGuardTest — NON-RÉGRESSION DE SÉCURITÉ : 26 formulations d'idéation qui
DOIVENT déclencher (toutes les familles de motifs, casse, variantes STT collées
validées device), 10 messages de détresse ordinaire qui ne doivent PAS déclencher
(tristesse, insomnie, colère, deuil d'autrui, « envie de dormir »…), 2 limites
ASSUMÉES documentées (sensibilité d'abord : « en finir avec ces insomnies » et
mention indirecte du suicide déclenchent), invariants de la réponse (112,
honnêteté « programme », anti-isolement). Toute retouche des PATTERNS doit garder
ces tests verts ; le clinicien ÉTEND les cas, ne supprime pas de positif.

VectorIndexTest : classement cosinus, top-k, seuil, index vide/dim incohérente.
ChunkerTest : tag source, découpe+overlap sous la limite ubatch, texte vide.

./gradlew :app:testDebugUnitTest → 11 tests, 0 échec, <5 s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:44:09 +02:00
Kazeia Team d834ea4955 docs: RELEASE_SIGNING à jour — keystore versionné dans le dépôt privé comme sauvegarde
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:38:16 +02:00
Kazeia Team d936b0d55d build: versionner le keystore release dans le dépôt privé (sauvegarde)
Décision : le dépôt git.kazeia.com étant privé (Gitea auto-hébergé, équipe
restreinte), le keystore + credentials y sont versionnés comme sauvegarde —
une panne de la machine de build ne peut plus coûter la clé.

Conséquence assumée : tout accès au dépôt = capacité de signer des mises à
jour Kazeia ; la clé reste dans l'historique git définitivement. Si le dépôt
devait un jour être ouvert/partagé plus largement, faire une rotation de clé
AVANT (cf docs/RELEASE_SIGNING.md §7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:37:34 +02:00
Kazeia Team 93f09c87d3 build: signature release (keystore partagé patient+admin) + doc de gestion
Keystore release créé : /opt/Kazeia/keystore/kazeia-release.jks (RSA 4096,
alias kazeia, valide 2056), credentials dans keystore/credentials.properties
(chmod 600, hors git — la liste blanche du .gitignore racine l'exclut d'office).

Les deux modules lisent credentials.properties : présent → assembleRelease signe
en release ; absent (autre machine/CI) → repli debug + warning, le build ne casse
pas. MÊME clé pour com.kazeia et com.kazeia.admin → permettra la
signature-permission sur le ContentProvider (plan admin).

Validé : assembleRelease des deux apps OK, apksigner confirme le certificat
CN=Kazeia (SHA-256 4b408d9d…) sur les deux APK.

docs/RELEASE_SIGNING.md : gestion complète — câblage, procédure de release via
catalogue (bump versionCode obligatoire), SAUVEGARDE du keystore (le point qui
ne pardonne pas), scénarios de panne (perte clé/mdp/machine/compromission),
migration one-shot des tablettes debug→release, dev quotidien reste en debug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:32:29 +02:00
Kazeia Team fd9e04a0cb 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>
2026-06-11 07:53:31 +02:00
Kazeia Team 4508b8fe2e feat(safety): garde-fous cliniques + filet de crise déterministe
Fusionne les garde-fous du prompt de Damien dans la voix chaleureuse de Kazeia,
et ajoute un filet de sécurité crise indépendant du LLM.

Bornes "soft" (niveau prompt, DEFAULT_SPEAKER_PROMPT + DEFAULT_SYSTEM_PROMPT +
copie admin, synchronisés) : pas de diagnostic / pas de pathologie nommée ;
médicament/dose/traitement -> décision médicale, renvoi médecin/psychiatre ;
refus des jeux de rôle/simulations ; réponse cadrée sur le stockage local.
Tutoiement + ton chaleureux préservés (≠ style clinique/vouvoiement de Damien).

Borne "hard" (CrisisGuard, DÉTERMINISTE) : on ne laisse jamais un 4B sous
contrainte de brièveté improviser sur une idéation suicidaire. Détection par
motifs FR (tunables clinicien) -> court-circuite Thinker+Speaker -> réponse fixe,
validée, honnête (Kazeia = programme), oriente vers l'humain + 112 (urgence EU,
valable FR et Luxembourg). Placé en tête de processLlmResponse.

Validé device : "envie d'en finir / me faire du mal / vaudrait mieux d'en finir"
-> [CRISIS] -> réponse de sécurité synthétisée (TTS frames=192) + jouée.
"arrêter mes médicaments" -> redirection médecin (prompt, pas crise). "triste et
fatigué" -> réponse normale, AUCUN faux positif crise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:32:38 +02:00
Kazeia Team acaf0cf2cf feat(rag): écran admin d'ingestion du corpus (provider /rag + réindexation live)
Gestion du corpus RAG depuis l'app admin, sans adb.

Patient :
- RagDb v2 : table rag_docs (documents BRUTS, source de vérité) séparée des
  embeddings rag_chunks ; onUpgrade ajoute la table.
- Rag : ingest enregistre le doc brut ; reingestMissing() (ré)indexe les docs
  sans embeddings ; listDocs/deleteDoc.
- ContentProvider /rag : query liste (source+char+chunk_count), /rag/<src> texte
  pour édition, insert (upsert doc + invalide chunks + broadcast), delete /rag/<src>.
  Broadcast RELOAD_RAG EXPLICITE (setPackage) — l'implicite n'est pas délivré au
  receiver NOT_EXPORTED sur Android récent.
- KazeiaService : receiver RAG_RELOAD -> reingestMissing live ; au démarrage,
  embarque les docs ajoutés hors-ligne.

Admin : KazeiaRagClient + RagRepository + RagScreen (Compose : liste, badge
"à indexer", ajout/édition/suppression). Remplace le StubScreen du slot Rag.

Validé device E2E : admin insert 'colere' -> RELOAD -> embeddé live (chunk_count 1,
sans restart) -> tour patient "fou de rage" récupère [Source: colere] (top 0.83)
-> réponse ancrée. App admin se lance sans crash.

Au passage : .gitignore exclut kazeia-android/*/build/ et dé-suit app-admin/build/
(1017 artefacts qui étaient trackés par erreur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:57:32 +02:00
Kazeia Team 62a9803fdd feat(rag): intégration RAG dans le flux patient (corpus + ingestion + injection live)
Active le RAG de bout en bout dans la vraie conversation :
- Rag.ingestDir(dir) : ingère tous les .txt/.md d'un dossier (source = nom fichier).
- KazeiaService : auto-ingestion du corpus au 1er démarrage si base vide
  (.../kazeia/rag_corpus/), + intent `rag_ingest` (clear+reingest, brique admin future).
- Injection live : seuil relevé à 0.82 (calé e5 : base ~0.80 / pertinent ~0.88), k=3,
  pour ne PAS injecter de hors-sujet.
- Corpus seed FR de psychoéducation (6 fichiers) dans docs/rag_corpus_seed/.

Validé device (ragEnabled ON, Speaker lib qwen3.5-4b) : 6 chunks ingérés, tour
patient « je n'arrive pas à dormir… » -> retrieve 1 hit (top 0.84, le chunk sommeil ;
les 5 autres filtrés par le seuil) -> bloc CONTEXTE injecté (577 c) -> réponse ancrée.
Aucun crash, coexistence RAM OK. ragEnabled reste OFF par défaut (opt-in clinicien).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:12:21 +02:00
Kazeia Team 95a7a25335 feat(rag): valider EngineEmbedder e5 in-app (E2E réel) + préfixe passage
Le dev kazeia-engine a livré le bridge CPU-only avec embedText (commit engine
655d65a) + un GGUF multilingual-e5-small fonctionnel (converter patché pour le
tokenizer XLM-R/unigram). Bridge swappé dans jniLibs (ABI cohérente : libllama/
libggml déjà = build CPU-only du 2 juin).

- Rag.ingest : les passages portent désormais le préfixe "passage: " (e5), en
  symétrie du "query: " de retrieve (EngineEmbedder.embedPassage).
- Intent `rag_real_test` : valide le VRAI EngineEmbedder (e5) in-app.

Validé device (CPU-only, untrusted_app) : embedder ready=true dim=384, AUCUN
crash SELinux/fastrpc (la .so ne tire plus hexagon), retrieval FR correct —
requête sommeil → doc insomnie en tête (top=0.88). embedText fonctionne in-app
end-to-end. Le .so (gitignored) est un artefact build, hors git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:02:44 +02:00
Kazeia Team 3eab29beb2 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>
2026-06-09 22:09:57 +02:00
Kazeia Team 6be3e47148 docs: spec embeddings RAG pour kazeia-engine + whitelist docs/
Ajoute docs/RAG_EMBEDDINGS_ENGINE_SPEC.md : instructions à transmettre au dev
de kazeia-engine pour exposer un entrypoint texte→vecteur poolé (loadEmbedder/
embedText/freeEmbedder) réutilisant la machinerie d'embedding déjà présente dans
le fork libllama. Calée sur dist/jni/kazeia_engine_jni.cpp réel.

Whitelist .gitignore élargie à /docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:54:32 +02:00
Kazeia Team 4498e5ac7a chore: dégraissage du dépôt — suivi restreint à l'app mobile (liste blanche)
Le working tree contenait ~21 k fichiers non suivis (modèles, backups, SDK,
venvs, arbres expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz de
Damien…) qui polluaient `git status`. Remplace la blacklist fuyante par une
LISTE BLANCHE : on n'ignore plus au cas par cas, on ignore tout à la racine
sauf l'app mobile (kazeia-android), scripts/, executorch-*, et les docs *.md/
*.txt. Rien n'est supprimé du disque — juste sorti du suivi.

Résultat : non-suivis 21389 → 0. Ajoute au passage des fichiers légitimes qui
traînaient non suivis (FROZEN.md = contrat de stack figée, scripts d'export TTS,
rapport). Le moteur unifié reste un dépôt séparé (/opt/Kazeia-engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:21:00 +02:00
Kazeia Team 6f45a75197 chore: snapshot migration moteur lib (préservation avant dégraissage)
Capture l'état courant de kazeia-android (migration vers le moteur lib unifié :
suppression des anciens AudioCaptureManager/VadEngine/SileroVad, WhisperJni/
WhisperLiteRt/WhisperNpu/AndroidStt, KazeiaLlmJni/LlamaCppLlmEngine,
AndroidTts/ChatterboxTts ; édits app/JNI/gradle associés). L'app construit et
tourne sur device dans cet état. Commit de préservation pour ne rien perdre
avant de restreindre le périmètre de suivi du dépôt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:19:24 +02:00
186 changed files with 22438 additions and 2535 deletions

83
.gitignore vendored
View File

@ -1,8 +1,26 @@
# ============================================
# Kazeia .gitignore — code only, no binaries
# ============================================
# ============================================================
# Kazeia .gitignore — LISTE BLANCHE
# On ne suit QUE l'app mobile + le code/les docs essentiels.
# Tout le reste à la racine (modèles, backups, SDK, venvs, arbres
# expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz, etc.)
# est ignoré — présent sur le disque, jamais supprimé, juste hors suivi.
# ============================================================
# === Large binary files ===
# 1) Ignorer tout ce qui est à la racine du dépôt...
/*
# 2) ...sauf ce qu'on veut explicitement suivre :
!/.gitignore
!/kazeia-android/
!/docs/
!/keystore/
!/scripts/
!/executorch-custom/
!/executorch-patches/
!/*.md
!/*.txt
# 3) Binaires & sorties de build (même à l'intérieur des dossiers suivis) :
*.so
*.so.*
*.so.bak
@ -15,9 +33,10 @@
*.npy
*.wav
*.apk
# === Build outputs ===
*.pyc
__pycache__/
kazeia-android/app/build/
kazeia-android/*/build/
kazeia-android/build/
kazeia-android/.gradle/
kazeia-android/local.properties
@ -25,61 +44,11 @@ kazeia-android/extracted/
kazeia-android/app/.cxx/
kazeia-android/unityLibrary/
# === Python environments ===
et_venv/
qnn_venv/
__pycache__/
*.pyc
# === SDKs and toolchains (external) ===
android-ndk-r27d/
qnn_sdk_242/
executorch/
# === Models and data (too large) ===
models_qnn/
tablet_backup*/
backup_*/
voix/
# === IDE ===
# 4) IDE / OS / temporaires
.idea/
*.iml
# === Compiled binaries ===
cp_et_test_client
llama.cpp/build*/
# === OS files ===
.DS_Store
Thumbs.db
# === Temporary ===
*.tmp
*.log
*.kate-swp
# === External repos (submodules or separate) ===
Vulkan-Headers/
llama.cpp/
whisper.cpp/
kazeia-unity/
models_hf/
# === Old/misc at root ===
android-sdk/
avatar_disabled_backup/
beta_kazeia/
build_et_jar/
root_oneplus/
cmdtools.zip
forward.dlc
.claude/
# === Root-level scripts (moved to scripts/) ===
export_decoder_onnx.py
export_talker_onnx.py
extract_tts_embeddings.py
extract_vq_individual.py
generate_tokens_for_tablet.py
generate_tts_wav.py

234
FROZEN.md Normal file
View File

@ -0,0 +1,234 @@
# Kazeia — Stack production figée
**Date du gel : 2026-05-02**
Ce document définit la stack **autorisée** de Kazeia. Tout composant non listé
ici doit être considéré comme **non autorisé** et ne doit pas être réintroduit
sans validation explicite + mise à jour de ce contrat.
---
## Emplacement des modèles (MAJ 2026-05-18 — chantier installeur Phase 0)
Les chemins `/data/local/tmp/kazeia/models/` et `/data/local/tmp/kazeia-et/`
cités ci-dessous restent **valides sur les tablettes de dev** (modèles poussés
par `adb push`). Ils sont désormais **résolus au runtime par `KazeiaPaths`** :
- si la racine `/data/local/tmp` existe et est non vide → elle prime (dev) ;
- sinon → stockage externe app `getExternalFilesDir("kazeia")/{models,llm}`,
rempli par l'installeur (modèles téléchargés depuis le Nextcloud).
Aucun composant n'est ajouté ni retiré — seul l'emplacement de chargement
devient dynamique. `KazeiaApplication.MODELS_DIR` / `.LLM_DIR` sont les points
d'accès uniques. Cf `project_kazeia_installer_updates`.
---
## STT — Speech-to-Text
### Composant figé
`com.kazeia.stt.WhisperHybridEngine` (542 lignes) — chemin **NPU only**
- Encoder + Decoder Qualcomm AI Hub HfWhisper KV-cache (Whisper-Small 12L/12H)
- Modèles tablette : `/data/local/tmp/kazeia/models/whisper-small-sm8750/`
(fingerprints MD5 dans `/opt/Kazeia/kazeia-stt-perf-validated/models/README.md`)
- Backend : ONNX Runtime QNN HTP V79 (Snapdragon 8 Elite)
- Performance validée : RTF 0.5 (audio 1.6 s → 825 ms)
- Override `<|translate|>``<|transcribe|>` actif (force transcription FR)
- Auto-détection layers/heads via `enc.outputInfo["k_cache_cross_0"]`
### Mel extractor figé
`com.kazeia.stt.MelExtractor` + `kazeia-android/app/src/main/jni/mel_extractor.cpp`
- HuggingFace-compatible WhisperFeatureExtractor en C++ pur
- 80 mels × 3000 frames, FFT 400, hop 160
### Wrapper v2 figé
`com.kazeia.v2.SttStage` — thin wrapper sur `WhisperHybridEngine`
### ❌ Interdit
- Pas de fallback CPU (`WhisperSttEngine`, `WhisperJni`, `whisper.cpp`) — supprimés
- Pas de fallback Android stock (`AndroidSttEngine`) — supprimé
- Pas d'autres backends (`WhisperLiteRtEngine`, `WhisperNpuSttEngine`) — supprimés
- Pas de Qwen3-ASR (1.7B GGUF cassé, 0.6B qualité FR insuffisante)
- Pas de `libwhisper.so` dans jniLibs — supprimé
---
## VAD — Voice Activity Detection
### Composant figé
**Détection RMS-énergie inline** :
- v1 : `KazeiaService.startContinuousListening()` — frame 1600 samples (100 ms),
threshold RMS=150, ≥ 300 ms speech, ≥ 800 ms silence end
- v2 : `com.kazeia.v2.VadStage` — réimplémentation propre du même algorithme
### ❌ Interdit
- Pas de Silero VAD (`SileroVadEngine.kt` + `silero_vad.onnx` — supprimés)
- Pas de `AudioCaptureManager.kt` — supprimé
- Pas de `core.VadEngine` interface — supprimée
- Cf `feedback_kazeia_vad_rms.md` : Silero plafonne à 5e-4 sur ce mic
---
## LLM — Large Language Model
### Composants figés v1 (legacy `KazeiaService`)
`com.kazeia.llm.ExecuTorchLlmEngine` (in-process)
- Modèle : `hybrid_llama_qnn.pte` symlink → `hybrid_llama_qnn_4b.pte` sur tablette
- Tokenizer : `tokenizer.json`
- Backend : ExecuTorch + QNN HTP NPU (in-process via PyTorch ExecuTorch JNI)
- Path : `/data/local/tmp/kazeia-et/`
### Composants figés v2 (`KazeiaServiceV2`)
`com.kazeia.v2.LlmCascadeStage` (mode mono-Speaker — temporaire RAM-budget)
- Spawn 1× `qnn_llama_runner --daemon_mode` via `ExecutorchDaemon`
- Modèle : `hybrid_llama_qnn_4b.pte` + `tokenizer.json`
- System prompt : SYS_SPEAKER (psychologue bienveillant, 1-2 phrases FR concises)
- Le code Thinker/cascade est conservé en commentaire pour restauration future
### ❌ Interdit
- Pas de `LlamaCppLlmEngine` (purgé 2026-04-29)
- Pas de `KazeiaLlmJni` (purgé 2026-04-29)
- Pas de Genie SDK (bug GQA Qwen3)
- Pas de cascade Thinker+Speaker SIMULTANÉE en cohabitation TTS (RAM OOM, cf
cleanup 2026-05-02)
- Pas de `LLM_BACKEND` constante / `_currentLlmModel` UI selector
### Restauration cascade complète : conditionné
À envisager **uniquement après** :
- Task #240 (libllama.so contre chraac-llama récent) → -30 % RAM/perf Talker+CP
- OU partage `.ptd` entre 2× 4B → -2.4 GB RAM
- OU phased loading opportuniste (cf `feedback_kazeia_vad_rms.md` non, l'analyse
RAM dans la conversation 2026-05-02)
- En attendant, mono-Speaker reste figé
---
## TTS — Text-to-Speech
### Composant figé
`com.kazeia.tts.Qwen3TtsEngine` — voie **ggml-cpu Talker + ggml-cpu CP + ggml C++ Decoder**
- Talker : `TtsTalkerCpuJni` linké contre `libllama.so` (in jniLibs)
- Modèle préféré : `talker_f32.gguf` (1.78 GB, fp32, élimine drift fp16)
- Fallback : `talker_f16.gguf`
- CP : `TtsCpCpuJni` linké contre `libllama.so`
- Modèle préféré : `cp_f32.gguf` (316 MB)
- Fallback : `cp_f16.gguf`
- Decoder : `libtts_decoder_ggml.so` static-linked contre **chraac-llama** ggml
(build : `kazeia-tts-decoder-ggml/build-android-static-chraac/`)
- GGUF : `qwen3tts_decoder.gguf` (340 MB)
- **RTF 0.96 mesuré 2026-05-02** (cf `kazeia-tts-perf-validated/`)
- Tokenizer texte : `Qwen3BpeTokenizer` (BPE byte-level Qwen2/Qwen3)
- Voice cloning : Damien (prefix 9 × 1024 + suffix 2 × 1024)
### API figée
- `synthesizeAndPlay(text, language)` — pour invocations one-shot
- `startStreamingSession()` / `enqueueSentence(text)` / `endStreamingSession()`
pour streaming sentence-par-sentence (utilisé dans v2.TtsStage)
### Wrapper v2 figé
`com.kazeia.v2.TtsStage` — wrapper streaming-session sur `Qwen3TtsEngine`
### ❌ Interdit dans le chemin de chargement
- Pas de chemin `cp_kv_v2 ONNX` (ligne 630-658 de Qwen3TtsEngine, gated sur
`!useCpuPath` 2026-05-02)
- Pas de `cp_et_runner` TCP (sub-process root, gated 2026-05-02)
- Pas de `talker_kv_cpu` ONNX fp32 (gated sur `!useCpuPath`)
- Pas de Hexagon talker / CP (`hexStartRunner`, `useHexagonTalker`) — pas dans
flux production
- Pas de `phrase_embeds.bin` cache (bypassed 2026-05-02 — provoquait dégénération)
### ❌ Interdit moteurs alternatifs
- Pas de `AndroidTtsEngine` — supprimé
- Pas de `ChatterboxTtsEngine` — supprimé
---
## Audio playback
### Composant figé
`com.kazeia.audio.AudioPlaybackManager` (54 lignes)
L'AudioTrack interne au streaming session du `Qwen3TtsEngine` (avec keepAlive
watchdog ColorOS) reste également figé.
---
## Build / Backends
### ggml — sources autorisées
| Composant | Source ggml autorisée | Build dir |
|---|---|---|
| **TTS Decoder** | `chraac-llama` (branch `dev-refactoring`, commit `897501a`) | `build-android-static-chraac` |
| TTS Talker (lib) | jniLibs `libggml-cpu.so` (`llama-upstream`, à migrer Task #240) | `kazeia-android/.../jniLibs/arm64-v8a` |
| TTS CP (lib) | idem | idem |
| STT | n/a (ONNX Runtime, pas ggml) | n/a |
**Critique** : ne JAMAIS pointer le décodeur TTS vers `whisper.cpp/ggml` ou
`llama-upstream/ggml` — sinon RTF passe à 1.4-1.5 (mesuré, cf
`kazeia-tts-perf-validated/docs/MEASUREMENTS.md`).
### Compile flags figés
```cmake
# TTS decoder (libtts_decoder_ggml.so)
-O3 -march=armv8.6-a+dotprod+fp16+i8mm+bf16 -fopenmp -static-openmp
GGML_CPU_ARM_ARCH=armv8.6-a+dotprod+fp16+i8mm+bf16
GGML_OPENMP=ON
BUILD_SHARED_LIBS=OFF
```
---
## Fichiers de référence persistants
- `/opt/Kazeia/kazeia-stt-perf-validated/` — doc + MD5 modèles STT/VAD
- `/opt/Kazeia/kazeia-tts-perf-validated/` — doc + libs `.a` + bench CLI
reproductible TTS (RTF<1)
- `/opt/Kazeia/chraac-llama/` — fork ggml indispensable pour le décodeur TTS
- `/opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static-chraac/` — build
statique pointé par l'app
---
## Comment dégeler / modifier ce contrat
**Procédure obligatoire** avant de réintroduire un composant supprimé OU de
remplacer un composant figé :
1. Mesurer la perf du composant cible **vs** la voie figée (sur la même phrase
de test, même tablette, même gouverneur DVFS)
2. Démontrer que la nouvelle voie est strictement meilleure (perf, RAM ou
maintenabilité) avec des chiffres reproductibles
3. Mettre à jour ce `FROZEN.md` avec le nouveau composant + les chiffres
4. Mettre à jour `MEMORY.md` index + memory file pertinent
5. Documenter le commit/build SHA exact dans `kazeia-{stt,tts}-perf-validated/`
6. Conserver l'ancien composant comme `*.archive.kt` UN trimestre avant
suppression définitive (filet de sécurité rollback)
Sans ce processus, **ne pas modifier la stack figée**.
---
## Métriques de référence (2026-05-02)
| Domaine | Métrique | Valeur figée | Comment |
|---|---|---:|---|
| STT | Load total | 750-1035 ms | NPU encoder + decoder + mel filters |
| STT | RTF (audio 1.6 s) | **0.51** | mel + encoder + decoder NPU |
| VAD | Trigger end-of-speech | 800 ms après dernier audio > seuil | hardcoded |
| LLM | Mono-Speaker forward | ~22 tok/s | qnn_llama_runner subprocess |
| TTS Decoder | RTF (audio 4.48 s) | **0.96** | ggml C++ chraac-llama, standalone |
| TTS Pipeline complet | RTF | ~2.87 | bottleneck CP (Task #240 pending) |

View File

@ -0,0 +1,508 @@
# Rapport Kazeia — État, architecture, performances
**Date :** 2026-05-04
**Contexte :** Kazeia est une application Android de soutien thérapeutique
conversationnel (psychologue virtuel) tournant **100 % on-device** sur OnePlus
Pad 3, sans connectivité réseau requise et sans root utilisateur.
---
## 1. Synthèse exécutive
### Ce qui fonctionne en production
- Pipeline complet **STT → LLM → TTS** opérationnel, multi-tour, avec cascade
Thinker+Speaker.
- Latence end-to-end utilisable en conversation : **3-5 s** entre la fin du
PTT et le premier audio de réponse.
- Stable mémoire après débloat ColorOS + désinstallation du watchdog OEM Athena.
- Application 100 % autonome sur la tablette : aucun service réseau requis,
modèles et runtime tous embarqués.
- Stack figée et documentée (`/opt/Kazeia/FROZEN.md`).
- Backup tablette complet et automatisable dans `/opt/Kazeia/kazeia-tablet-backup/`.
### Limites actuelles connues
- Le **Thinker Guard-0.6B** produit des bullets de qualité analytique limitée
(modèle conçu pour classification de safety, détourné vers analyse clinique).
Le Speaker compense en pratique mais l'apport qualitatif de la cascade est
moins net qu'espéré.
- Le rendu UI de l'orbe (cycle couleurs pendant Thinking) demande encore
validation visuelle utilisateur.
- Conversation history non persisté entre sessions (à dessein actuellement).
- Pas de fine-tuning thérapeutique français spécifique sur les modèles —
on utilise Qwen3-4B générique pré-entraîné.
---
## 2. Plateforme matérielle
| Composant | Spec |
|---|---|
| Tablette | OnePlus Pad 3 (OPD2415EEA) |
| RAM | 16 GB LPDDR5X |
| Stockage | 256 GB UFS |
| SoC | Snapdragon 8 Elite |
| CPU | Oryon (8 cœurs ARMv9) |
| GPU | Adreno 830 |
| **NPU/DSP** | **Hexagon V79 (HMX/HVX)** |
| OS | Android 16, ColorOS post-2026-03-18 OTA |
| Root | Aucun (user mode standard) |
---
## 3. Stack logicielle
```
┌────────────────────────────────────────────────────────────────┐
│ Kazeia Android App (Kotlin) │
│ /data/app/.../com.kazeia │
└───────────────────┬─────────────────────────┬───────────────────┘
│ │
┌───────────▼──────────┐ ┌───────────▼──────────┐
│ KazeiaService │ │ ChatActivity (UI) │
│ (state machine) │ │ AudioVisualizerView│
│ • PTT │ │ (orb HSV cycle) │
│ • Cascade │ └──────────────────────┘
│ • Pipeline state │
└─┬────────┬────────┬──┘
│ │ │
┌──────▼─┐ ┌───▼────┐ ┌─▼──────┐
│ STT │ │ LLM │ │ TTS │
│ Engine │ │ Engine │ │ Engine │
└────────┘ └────────┘ └────────┘
```
### 3.1 STT — Whisper-Small NPU (QAIRT)
- **Modèle** : `HfWhisper-Small` (12 layers, 12 heads)
- **Format** : QAIRT context binary (Qualcomm SDK natif)
- **Fichiers** :
- `HfWhisperEncoder_qairt_context.bin` (210 MB)
- `HfWhisperDecoder_qairt_context.bin` (361 MB)
- **Backend** : Hexagon V79 via QAIRT runtime (`libQnnHtpV79Skel.so`)
- **VAD** : RMS énergie inline (seuil 80, frames 100 ms)
- **Mode** : continuous listening avec streaming Whisper pendant PTT
- **Performance mesurée** : RTF 0.4 (transcription "Bonjour" ~640 ms total :
mel 260 ms + encoder 115 ms + decoder 170 ms)
### 3.2 LLM — Cascade Option E v2 (in-process)
**Architecture cascade** : 2 `LlmModule` ExecuTorch dans le **même process app**,
partageant le `QnnBackendBundle` via le singleton `QnnBackendUnifiedRegistry`
(donc 1 seul handle fastrpc DSP, pas de collision).
```
Process com.kazeia
├── Speaker Qwen3-4B (.pte 3.3 GB, ~4.4 GB ION)
│ └── system prompt: SYS_KAZEIA + "Indices internes (NE PAS RÉPÉTER) : ..."
└── Thinker Qwen3Guard-Gen-0.6B (.pte 700 MB, lazy first PTT)
└── system prompt: SYS_THINKER (4 bullets émotion/besoin/approche/axe)
```
| Composant | Modèle | Format | Taille | Quantization |
|---|---|---|---|---|
| Speaker | Qwen/Qwen3-4B | ExecuTorch `.pte` QNN HTP V79 | 3.3 GB | Q4 W4A16 |
| Thinker | Qwen/Qwen3Guard-Gen-0.6B | ExecuTorch `.pte` QNN HTP V79 | 700 MB | Q4 W4A16 |
**Cascade sequence par tour utilisateur :**
1. STT → texte patient (transcription Whisper)
2. **Thinker pass** : `Guard-0.6B(SYS_THINKER + msg)` → 4 bullets cliniques
3. Bullets tronqués à 400 chars (sécurité prefill budget)
4. **Speaker pass** : `Qwen3-4B(SYS_KAZEIA + bullets en system, msg en user)` → réponse 1-2 phrases
5. Streaming TTS phrase par phrase au fur et à mesure du décodage
### 3.3 TTS — Qwen3-TTS ggml-cpu
- **Modèle source** : Qwen3-TTS-0.6B-Base (clonage vocal)
- **Pipeline 3 étapes**, toutes en CPU pur (NEON ARMv9, pas de DSP) :
| Étape | Modèle | Fichier | Taille | Backend |
|---|---|---|---|---|
| Talker | transformer audio → tokens semantic | `talker_f16.gguf` | 851 MB | ggml-cpu |
| Code Predictor (CP) | sem → 16 codebooks RVQ | `cp_q8_0.gguf` | 84 MB + 240 MB embeds | ggml-cpu |
| Decoder | RVQ → mel → audio 24 kHz | `qwen3tts_decoder.gguf` | 325 MB | ggml-cpu (libtts_decoder_ggml) |
- **Voice cloning** : prefix/suffix embeddings pré-extraits (10 voix : damien,
elodie, jerome, richard, sid, zelda, didier, amir...)
- **Streaming sentence-level** : audio joue dès la 1ère phrase pendant que LLM
décode encore les suivantes (Opt TTS-B)
- **RTF mesuré** : 0.96 sur tablette (chraac-llama fork)
### 3.4 Glue applicative
| Composant | Rôle |
|---|---|
| `KazeiaService` (Kotlin) | State machine PTT + cascade + pipeline |
| `ExecuTorchLlmEngine` | Wrapper Java→JNI sur `LlmModule` ExecuTorch |
| `Qwen3TtsEngine` | Orchestration JNI Talker+CP+Decoder + voice clone |
| `WhisperHybridEngine` | JNI QAIRT + mel extractor NEON |
| `AudioVisualizerView` | Orbe Canvas, HSV cycle pendant Thinking |
| `SentenceStreamer` | Découpage phrases pour streaming TTS |
| `PipelineMetrics` | Instrumentation timings end-to-end |
---
## 4. Performances mesurées
### 4.1 Latences par étape (mesures réelles dans logcat)
| Étape | Temps typique | Note |
|---|---|---|
| **STT Whisper-Small** | 460-680 ms | RTF 0.4 sur audio 1.7s |
| ├ Mel extraction | 190-270 ms | NEON sur CPU |
| ├ Encoder NPU | 115 ms | QAIRT V79 |
| └ Decoder NPU | 75-300 ms | dépend du nb tokens |
| **Thinker (Guard-0.6B)** | 446 ms | 22 tokens à 49 tok/s, TTFT 130 ms |
| ├ Lazy load (1ère fois) | 1 088 ms | -844 MB MemAvailable |
| └ Inference | 446 ms | 4 bullets, prompt ~180 tok |
| **Speaker (Qwen3-4B)** | 1 000-2 200 ms | 14-46 tokens à 14-21 tok/s, TTFT 190 ms |
| ├ resetContext | <1 ms | clear KV cache |
| ├ Prefill (~150 tok) | 200 ms | hybrid mode AR=128 |
| └ Decode | 800-2000 ms | 13.9-21 tok/s selon longueur |
| **TTS Talker step** | ~25 ms | ggml-cpu NEON |
| **TTS CP step** | ~43 ms | Q8_0, optimisé |
| **TTS Decoder full** | RTF 0.96 | ggml-cpu, premier audio <1 s |
### 4.2 Latence end-to-end PTT → 1ᵉʳ audio
```
PTT release ─┬───── 100 ms finalize segment
╔═══ 600 ms ═══════ STT Whisper ─┐
║ │
╠═══ 450 ms ═══════ Thinker Guard-0.6B │ utilisateur
║ │ entend silence
╠═══ 500 ms ═══════ Speaker prefill + 1ère phrase │
║ │
╠═══ 800 ms ═══════ TTS 1ère phrase synthèse ─┘
▼ 1ᵉʳ audio joué (~ 2.5 s après PTT release)
```
**Total PTT release → 1ᵉʳ audio : ~ 2.5 s** (pour message court).
**Total PTT release → réponse complète audio : ~ 4-6 s**.
### 4.3 Throughput LLM mesuré
| Modèle | Hardware | Decode tok/s | TTFT |
|---|---|---|---|
| Qwen3-4B Speaker | Hexagon V79 | 14-21 | 190 ms |
| Guard-0.6B Thinker | Hexagon V79 | **49** | 130 ms |
| Whisper-Small | Hexagon V79 | n/a (encoder/decoder fusion) | 115+75 ms |
---
## 5. Profil mémoire
### 5.1 Tablette en idle (Speaker chargé, Thinker pas encore)
| Métrique | Valeur |
|---|---|
| MemAvailable | 5.10 GB |
| Cached (file cache) | 4.51 GB |
| Process Kazeia RSS | 2.06 GB |
| Process Kazeia PSS total | 2.14 GB |
### 5.2 Décomposition PSS Kazeia (idle)
| Section | PSS | % |
|---|---|---|
| Native Heap (modèles, ION, native libs) | 1.65 GB | 77 % |
| Java/Dalvik Heap | 362 MB | 17 % |
| Graphics (EGL/GL) | 64 MB | 3 % |
| Code (.so + .apk + .dex mmap) | 42 MB | 2 % |
| Stack/divers | 26 MB | 1 % |
| **TOTAL PSS** | **2.14 GB** | |
### 5.3 Pendant cascade et TTS
| | Idle | Cascade Thinker | TTS pic |
|---|---|---|---|
| RSS Kazeia | 2.06 GB | ~2.9 GB | ~3.5 GB |
| ION DSP cumulé | ~3.3 GB | ~5.0 GB | ~5.5 GB |
| MemAvailable | 5.10 GB | 4.0 GB | 3.0 GB |
Le pic TTS reste loin du seuil critique (~1 GB MemAvailable où Linux LMK
commencerait à killer). **Stable**.
### 5.4 Tablet baseline (sans Kazeia)
- **Stock fraîche** : MemAvailable ~3.5 GB (900+ processus OEM ColorOS)
- **Après débloat + reboot** : MemAvailable ~11.1 GB → **+7.7 GB de marge**
---
## 6. Trajectoire — Ce qui a été fait
### Phase A : Pipeline initial (avril 2026)
- Migration v1 du PoC Python/Flask vers app Android Kotlin native
- Speaker via ExecuTorch QNN delegate (Qwen3-4B `.pte` hybrid mode)
- STT Whisper-Small NPU via QAIRT
- TTS Qwen3-TTS originalement en ExecuTorch `.pte`
### Phase B : Migration TTS vers ggml-cpu (avril-mai 2026)
- Talker exporté en GGUF f16 (vs `.pte` ExecuTorch)
- CP exporté en GGUF Q8_0 (84 MB vs 316 MB f32 vs 158 MB f16)
- Decoder porté en C++ via libtts_decoder_ggml.so
- Validation RTF < 1 sur tablette (chraac-llama fork)
### Phase C : Tablette stabilisation (mai 2026)
- Mesure baseline ColorOS : ~3.5 GB MemAvailable seulement
- **Débloat** sans root : 84 packages désinstallés via `pm uninstall --user 0`
- **Athena watchdog** : désinstallation du package (kill ION quota 2 GB sur app)
- Suppression de symlinks ⚠ SELinux ColorOS sans Magisk les bloque
- Backup complet 25 GB dans `/opt/Kazeia/kazeia-tablet-backup/`
- Factory reset + restore réussi
### Phase D : Cascade Thinker+Speaker (mai 2026)
Tentatives successives :
| Tentative | Approche | Résultat |
|---|---|---|
| 1 | Subprocess `qnn_llama_runner` daemon | ✗ Collision DSP fastrpc (2 sessions QNN HTP) |
| 2 | Option E v1 : 2 LlmModule (Speaker 4B + Guard 4B) | ✗ OOM Linux LMK (8.8 GB ION cumulé) |
| 3 | Option B : 2-pass sur même Speaker | ✓ Stable, qualité Thinker faible (Qwen3-4B générique) |
| 4 | **Option E v2** : Speaker 4B + Guard-0.6B | **✓ Production actuelle** |
Pour Option E v2 : export Qwen3Guard-Gen-0.6B HF → ExecuTorch `.pte` QNN HTP V79
en local via `executorch.examples.qualcomm.oss_scripts.llama` (registration
ajoutée dans `__init__.py` + `decoder_constants.py`).
### Phase E : Robustesse runtime (mai 2026)
- Fix VAD seuil RMS (150 → 80) après calibration mic post-reset
- Fix PTT release : finalisation forcée du segment (sans ça, le LLM n'était
jamais déclenché si bruit ambiant > seuil)
- Fix `seq_len` ExecuTorch : passer 512 (max compilé .pte) au lieu de
`maxNewTokens` (assertion `cur_pos+prompt < seq_len`)
- Fix `cur_pos_` accumulation : appel `LlmModule.resetContext()` à chaque
generate (sinon crash après quelques tours)
- Fix `prefill()` overflow : truncation bullets à 400 chars (Guard-0.6B peut
rambler et faire dépasser le budget prefill du Speaker)
- Fix Speaker régurgitant les bullets : injection bullets dans **system prompt**
(avec instruction « NE PAS RÉPÉTER ») au lieu du user prompt
- Fix orbe visuelle : `Paint.shader = null` avant pose couleur dans
`drawThinking` (sinon le shader posé par `drawIdle` masquait la couleur)
### Phase F : Backup APK + scripts (en continu)
- `/opt/Kazeia/kazeia-tablet-backup/` (25 GB total)
- APK actuelle (110 MB, libs natives custom bundlées)
- Modèles TTS+STT (12 GB)
- LLM `.pte` (12 GB inc. Guard-0.6B nouveau)
- Voix de référence (130 MB)
- `restore.sh` automatisé
- `/opt/Kazeia/kazeia-debloat/` : `safelist.txt` + `debloat.sh` + `rebloat.sh`
- `/opt/Kazeia/export_guard_06b.sh` : pipeline d'export Guard-0.6B reproducible
---
## 7. Architecture cascade détaillée (Option E v2)
```
┌──────────────────────────────────────────────────────────────────┐
│ KazeiaService │
│ │
│ PTT release ───► startContinuousListening() drains buffer │
│ ► processSpeechInput / streaming Whisper │
│ ► text = "Comment ça va ?" │
│ ► processLlmResponse(text) │
│ │
│ ┌─ ensureThinkerEngine() ──┐ (lazy 1ère fois ~1.1 s) │
│ │ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ thinkerEngine │ │
│ │ │ ExecuTorchLlmEngine │ │
│ │ │ Guard-0.6B │ │
│ │ │ SYS_THINKER │ │
│ │ │ T=0.0, max 128 tok │ │
│ │ └──────────┬──────────┘ │
│ │ ▼ │
│ │ bullets (400 chars max) │
│ │ │ │
│ │ ┌─────────────────────────┴──────┐ │
│ │ │ speakerSystemPrompt = │ │
│ │ │ SYS_KAZEIA + │ │
│ │ │ "\n\nIndices d'analyse... │ │
│ │ │ (NE PAS CITER) :\n" + │ │
│ │ │ bullets │ │
│ │ └────────────┬────────────────────┘ │
│ │ ▼ │
│ │ ┌──────────────────────────┐ │
│ │ │ llm = ExecuTorchLlmEngine │ │
│ │ │ Speaker Qwen3-4B │ │
│ │ │ generateWithSystem() │ │
│ │ │ system=above, user=msg │ │
│ │ │ T=0.7, max 450 tok │ │
│ │ └──────────┬───────────────┘ │
│ │ ▼ token-by-token streaming │
│ │ ┌──────────────────────────┐ │
│ │ │ SentenceStreamer │ │
│ │ │ split sur . ! ? │ │
│ │ └──────────┬───────────────┘ │
│ │ ▼ phrase à phrase │
│ │ ┌──────────────────────────┐ │
│ │ │ Qwen3TtsEngine │ │
│ │ │ enqueueSentence() │ │
│ │ └──────────┬───────────────┘ │
│ │ ▼ │
│ └─────► AudioPlayback (24 kHz mono, AudioTrack) │
│ │
└──────────────────────────────────────────────────────────────────┘
┌─────────────────────────┐
│ DSP Hexagon V79 │
│ • QAIRT (Whisper) │
│ • ExecuTorch QNN HTP │
│ (Speaker + Thinker │
│ partagent bundle) │
└─────────────────────────┘
┌─────────────────────────┐
│ CPU NEON ARMv9 │
│ • TTS Talker (ggml) │
│ • TTS CP (ggml) │
│ • TTS Decoder (ggml) │
└─────────────────────────┘
```
---
## 8. Prompts système (production)
### Speaker
```
Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français :
UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple.
Pas de remplissage, pas d'explication, pas de conseil non sollicité.
/no_think
[ + injection en cascade : ]
Indices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement
à orienter le ton de ta réponse) :
- emotion : ...
- besoin : ...
- approche : ...
- axe : ...
```
### Thinker
```
Tu es un psychologue clinicien experimente qui ecoute attentivement ton
patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter
ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
- emotion : <emotion principale ressentie, 5 mots max>
- besoin : <besoin sous-jacent du patient, 5 mots max>
- approche : <posture therapeutique adaptee, 5 mots max>
- axe : <point cle a valider ou explorer, 5 mots max>
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais.
/no_think
```
---
## 9. Risques connus & mitigations
| Risque | Impact | Mitigation actuelle | Action future |
|---|---|---|---|
| Athena framework (system_server) tue Kazeia au-dessus de seuil RAM | Crash app | Débloat OEM réduit pression mémoire | Investiguer si killable plus profond |
| LMK Linux kill si MemAvailable < 1 GB | Crash app | Marge 3 GB en pic actuel | Lazy-load STT (-600 MB) |
| Speaker régurgite bullets cascade | Phrases bizarres lues par TTS | Bullets en system prompt + interdiction | Fine-tune/instruction tuning |
| Thinker Guard-0.6B mal calibré pour analyse FR | Bullets imprécis | Speaker compense par sa robustesse | Évaluer Qwen3-1.7B en remplacement |
| Whisper hallucinations sur silence | "que tu entends ce que je dis ici." apparaît au repos | Ignorer streaming results vides | Filtre RMS/longueur audio min |
| Orbe ne change pas (avant fix) | UX dégradée | `Paint.shader = null` dans drawThinking | OK |
| Symlinks `.pte` invisibles app | Mode perroquet | Fichiers réguliers (cp pas ln) | OK, mémoire feedback enregistrée |
| OOM 2× ExecuTorch HTP 4B | Crash | Thinker = Guard-0.6B (5.1 GB ION total OK) | Plus petit Thinker possible |
---
## 10. Optimisations identifiées (memory profiling 2026-05-04)
| Optim | Effort | Gain idle | Gain pic | Risque | Recommandation |
|---|---|---|---|---|---|
| Strip jniLibs Hexagon non-V79 | 30 min | 50 MB code | 50 MB | Bas | **Tier 1** |
| Lazy-load Whisper STT | 1 h | 600 MB | 0 | Moyen (+0.5s 1ᵉʳ PTT) | **Tier 1** |
| Trim Java heap (logs StateFlow) | 1 h | 100-200 MB | 100 MB | Bas | **Tier 1** |
| Release Thinker entre tours | 2 h | 0 | -700 MB | Moyen (+1.5s 2ᵉ PTT) | Tier 2 |
| Talker GGUF Q8_0 (vs f16) | 1 j | 400 MB | 400 MB | Haut (qualité audio) | Tier 2 |
| Decoder Q4 sélectif | 2-3 j | 200 MB | 200 MB | Haut | Tier 3 |
| Re-export Speaker plus petit | 5-10 j | 500-1 000 MB | similar | Très haut | Tier 3 |
---
## 11. Roadmap
### Court terme (1-2 semaines)
- [ ] Appliquer Tier 1 mémoire (strip libs + lazy STT + trim heap)
- [ ] Évaluation qualitative cascade FR sur corpus thérapeutique
20+ prompts de référence
- [ ] Comparer Guard-0.6B vs Qwen3-1.7B comme Thinker (export et bench)
- [ ] Documenter procédure réinstallation tablette neuve (incl. débloat)
### Moyen terme (1-2 mois)
- [ ] Rewrite Kazeia Phase 1-5 (architecture propre Kotlin/JNI)
- [ ] Voice clone .bin generator on-device (POC)
- [ ] Persistance conversation history (Room database)
- [ ] UI polish (mode conversation libre vs PTT)
- [ ] Voice commands étendus
### Long terme (3+ mois)
- [ ] Fine-tuning thérapeutique français spécifique (LoRA)
- [ ] Migration runtime briques (POC dans `/opt/htp-runtime/`) — alternative
à ExecuTorch/QNN sur le long terme pour autonomie
- [ ] Évaluation clinique avec professionnels santé mentale
---
## 12. Références — Fichiers clés
### Code source app
- `/opt/Kazeia/kazeia-android/` — projet Gradle 8.12 / NDK r27d / API 36
- `app/src/main/java/com/kazeia/service/KazeiaService.kt` — orchestrateur
- `app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt` — wrapper LLM
- `app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt` — TTS
- `app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt` — STT
- `app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt` — orbe
- `app/src/main/jni/` — bridges JNI custom (libtts_*, libmel_extractor)
### Scripts et outils
- `/opt/Kazeia/export_guard_06b.sh` — export Qwen3Guard-Gen-0.6B → `.pte`
- `/opt/Kazeia/kazeia-debloat/` — débloat tablette
- `/opt/Kazeia/kazeia-tablet-backup/` — backup + restore
- `/opt/Kazeia/executorch/examples/qualcomm/oss_scripts/llama/` — pipeline export QNN HTP
### Modèles tablette
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_4b.pte` — Speaker
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_guard06b.pte` — Thinker (nouveau)
- `/data/local/tmp/kazeia-et/tokenizer.json` / `tokenizer_guard06b.json`
- `/data/local/tmp/kazeia/models/` — Talker, CP, Decoder GGUF, Whisper QAIRT
- `/data/local/tmp/kazeia/voix/` — voix de référence
### Documents de référence
- `/opt/Kazeia/FROZEN.md` — stack figée
- `/opt/Kazeia/kazeia-tts-perf-validated/` — TTS RTF<1 validé
- `/opt/Kazeia/kazeia-stt-perf-validated/` — STT RTF 0.4 validé
- `/opt/Kazeia/kazeia-debloat/README.md` — procédure tablette
### Mémoire persistante (sessions Claude Code)
- `/home/alf/.claude/projects/-opt-Kazeia/memory/MEMORY.md` — index
- 50+ entrées documentant chaque décision technique majeure
---
## 13. Conclusion
Kazeia tourne **end-to-end stable** sur OnePlus Pad 3 sans connexion internet,
sans root utilisateur, avec des latences acceptables pour une conversation
thérapeutique (3-5 s entre tours). L'architecture cascade Speaker+Thinker
in-process via ExecuTorch QNN HTP est validée et reproductible.
Les principaux **blocages historiques** ont été résolus : DSP contention,
OOM, SELinux symlinks, watchdog OEM, accumulation KV cache. La stack est
documentée et figée, avec backup + procédure de restore complets.
Les **axes d'amélioration restants** sont incrémentaux (mémoire, qualité
Thinker, UX) plutôt que structurels. Le pipeline est prêt pour **évaluation
clinique** sur corpus de référence en français.
---
*Rapport généré 2026-05-04. Auteur : Richard Loyer + assistance IA.*

View File

@ -0,0 +1,178 @@
# Spec — Ajouter le support embeddings (RAG) à kazeia-engine
> **Destinataire :** développeur de `kazeia-engine` (`/opt/Kazeia-engine`).
> **But :** exposer un entrypoint « texte → vecteur de phrase poolé » pour le RAG de Kazeia mobile.
> **Fichier cible :** `dist/jni/kazeia_engine_jni.cpp` (+ bindings Kotlin côté app).
> **Coût estimé :** ~4060 lignes C++, aucune nouvelle dépendance.
## Contexte
Le RAG de Kazeia mobile doit transformer un texte en **vecteur de phrase poolé**. La machinerie
existe déjà dans le fork (`llama_pooling_type`, champ `pooling_type` dans les context params,
`llama_get_embeddings_seq`), mais **aucun entrypoint JNI texte→vecteur** n'est exposé. Il faut
l'ajouter **sans toucher au chemin Speaker/TTS existant**.
## Principe (important)
- L'embedder est un **modèle dédié** (GGUF type **e5 / bge**, arch BERT), chargé comme **handle
séparé** — surtout **pas** le LLM Speaker (un décodeur causal est un mauvais embedder).
- **CPU pur, pas de HTP/HMX** (un embed coûte ~1050 ms ; le NPU est inutile et déstabilisant ici).
- **Ne pas réutiliser** `prefillEmbeds`/`decodeEmbed`/`llama_get_embeddings_ith` : c'est l'I/O float
du Talker TTS (hidden du *dernier token*), pas un embedding de phrase poolé.
---
## Tâche 1 — Nouvelle struct + `loadEmbedder`
À ajouter dans `kazeia_engine_jni.cpp` (section nouvelle, après le bloc LLM) :
```cpp
// ============================================================================
// EMBEDDINGS (RAG) : modèle dédié BERT-like (e5/bge), CPU, pooling de séquence.
// Indépendant du KEngine Speaker. Aucun HTP.
// ============================================================================
struct KEmbedder {
llama_model* m;
llama_context* c;
const llama_vocab* v;
int n_embd;
};
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge)
extern "C" JNIEXPORT jlong JNICALL
Java_com_kazeia_llm_EngineJni_loadEmbedder(JNIEnv* e, jobject, jstring path, jint nThreads, jint pooling) {
const char* pc = e->GetStringUTFChars(path, 0);
std::string p(pc); e->ReleaseStringUTFChars(path, pc);
llama_backend_init(); // idempotent
auto mp = llama_model_default_params(); mp.n_gpu_layers = 0; // CPU only
llama_model* m = llama_model_load_from_file(p.c_str(), mp);
if (!m) return 0;
auto cp = llama_context_default_params();
cp.n_threads = (nThreads > 0) ? nThreads : 4;
cp.embeddings = true; // <-- clé
cp.pooling_type = (pooling == 1) ? LLAMA_POOLING_TYPE_MEAN
: (pooling == 2) ? LLAMA_POOLING_TYPE_CLS
: LLAMA_POOLING_TYPE_UNSPECIFIED; // défaut = métadonnées du modèle
// Pooling MEAN/CLS exige que TOUTE la séquence tienne dans un seul ubatch :
cp.n_ctx = 512; cp.n_batch = 512; cp.n_ubatch = 512; // chunks <= 512 tokens
// (ne PAS réutiliser make_ctx : pas de flash_attn ni de KV f16 ici)
llama_context* c = llama_init_from_model(m, cp);
if (!c) { llama_model_free(m); return 0; }
auto* k = new KEmbedder{ m, c, llama_model_get_vocab(m), llama_model_n_embd(m) };
fprintf(stderr, "kazeia-engine: EMBEDDER chargé (%s, n_embd=%d, pooling=%d)\n", p.c_str(), k->n_embd, pooling);
return (jlong) k;
}
```
## Tâche 2 — `embedText` (texte → vecteur L2-normalisé)
```cpp
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_kazeia_llm_EngineJni_embedText(JNIEnv* e, jobject, jlong h, jstring text) {
auto* k = (KEmbedder*) h;
const char* tc = e->GetStringUTFChars(text, 0);
std::string s(tc); e->ReleaseStringUTFChars(text, tc);
// 1) tokenize (add_special=true -> CLS/BOS selon le modèle)
int n = -llama_tokenize(k->v, s.c_str(), s.size(), nullptr, 0, true, true);
if (n <= 0) return nullptr;
if (n > 512) n = 512; // garde-fou ubatch
std::vector<llama_token> toks(n);
llama_tokenize(k->v, s.c_str(), s.size(), toks.data(), n, true, true);
// 2) batch : tous les tokens, seq 0, sortie activée (requis pour le pooling)
llama_memory_clear(llama_get_memory(k->c), true);
llama_batch b = llama_batch_init(n, 0, 1);
for (int i = 0; i < n; ++i) {
b.token[i] = toks[i]; b.pos[i] = i;
b.n_seq_id[i] = 1; b.seq_id[i][0] = 0; b.logits[i] = 1;
}
b.n_tokens = n;
if (llama_decode(k->c, b) != 0) { llama_batch_free(b); return nullptr; }
// 3) embedding poolé de la séquence 0
const float* emb = llama_get_embeddings_seq(k->c, 0);
llama_batch_free(b);
if (!emb) return nullptr;
// 4) normalisation L2 (cosinus = produit scalaire côté Android)
std::vector<float> out(k->n_embd);
double norm = 0.0; for (int i=0;i<k->n_embd;++i) norm += (double)emb[i]*emb[i];
norm = norm > 0 ? 1.0/std::sqrt(norm) : 0.0;
for (int i=0;i<k->n_embd;++i) out[i] = (float)(emb[i]*norm);
jfloatArray arr = e->NewFloatArray(k->n_embd);
e->SetFloatArrayRegion(arr, 0, k->n_embd, out.data());
return arr;
}
extern "C" JNIEXPORT void JNICALL
Java_com_kazeia_llm_EngineJni_freeEmbedder(JNIEnv*, jobject, jlong h) {
auto* k = (KEmbedder*) h;
if (k->c) llama_free(k->c);
if (k->m) llama_model_free(k->m);
delete k;
}
```
> ⚠️ **À cross-checker contre `tools/llama-embedding` (ou `examples/embedding`) de TON fork.**
> Selon la version, un modèle encoder-only (BERT) peut nécessiter `llama_encode()` au lieu de
> `llama_decode()`, et le drapeau `logits` du batch peut différer. **Reproduis exactement ce que
> fait ton outil d'embedding de référence** — c'est la source de vérité.
## Tâche 3 — Bindings Kotlin
Dans `EngineJni` (`kazeia-android/.../llm/EngineLlmEngine.kt`) :
```kotlin
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long
external fun embedText(handle: Long, text: String): FloatArray?
external fun freeEmbedder(handle: Long)
```
---
## Points de vigilance (impératifs)
1. **Modèle = e5/bge dédié, FR-capable** (ex. `multilingual-e5-small`, 384-dim). Le **même** modèle
à l'ingestion et à la requête, sinon vecteurs incompatibles. **Versionne-le** (le n° de version
du modèle d'embedding fait partie du contrat de distribution).
2. **Vérifier d'abord que le fork charge l'arch BERT** (`bert` / `nomic-bert`) : le fork a été modifié
pour Qwen/TTS — **teste le GGUF d'embedding avec `llama-embedding` AVANT d'intégrer**. Si l'arch a
été retirée du fork, c'est LE point de blocage à traiter en priorité (≈ 5 min à vérifier).
3. **`n_ubatch ≥ nombre de tokens`** : le pooling MEAN/CLS impose la séquence entière dans un seul
ubatch. D'où `n_ctx = n_batch = n_ubatch = 512` et le cap à 512 tokens (chunks plus courts).
4. **`pooling = -1` (défaut modèle) recommandé** ; sinon e5→MEAN(1), bge→CLS(2).
5. **Préfixes e5 (`query:` / `passage:`) = côté appelant**, pas dans le moteur.
6. **CPU only** (`n_gpu_layers = 0`), pas de HTP/HMX.
7. **Déterminisme** : pas d'échantillonnage, l'embed est déterministe — indispensable pour
l'invariant « même modèle ingestion = requête ».
## Test d'acceptation (sur PC d'abord)
- `loadEmbedder` retourne un handle ≠ 0 sur le GGUF choisi ; `embedText` renvoie un `float[]` de
taille `n_embd` (384 pour e5-small).
- **Déterminisme** : même texte → vecteur **bit-identique** sur 2 appels.
- **Norme ≈ 1.0** (L2).
- **Cohérence sémantique** : `cos("j'ai du mal à dormir", "insomnie")` >
`cos("j'ai du mal à dormir", "recette de tarte")`.
- **Parité référence** : le vecteur ≈ celui de `llama-embedding` sur le même texte (à epsilon près).
## Build
Rebuild `libkazeia_engine.so` via le script habituel (`dist/build_kazeia_*.sh` / CMake).
**Aucune nouvelle dépendance** (tout est dans libllama). Livrer la `.so` + un GGUF d'embedding e5/bge.
---
## Ce qui reste côté app mobile (hors de cette spec, pour info)
Une fois `embedText` livré, l'intégration RAG dans `kazeia-android` est :
schéma SQLite (`chunk_text`, `source`, `embedding BLOB`) dans la base SQLCipher existante →
ingestion côté app admin (chunk sémantique + `embedText`) → au runtime, charger la matrice en RAM
→ brute-force cosinus (produit scalaire NEON) top-k=35 + **seuil de similarité** → injection d'un
bloc « CONTEXTE » budgété en tokens dans le system prompt. **Pas de vector-DB, pas d'ANN, pas
d'ONNX** (cf. analyse d'architecture). Le RAG ne porte jamais la gestion de crise.

139
docs/RELEASE_SIGNING.md Normal file
View File

@ -0,0 +1,139 @@
# Signature release Kazeia — gestion du keystore
> **Résumé en 3 lignes** : les deux APK (patient + admin) sont signées en release avec
> **une seule clé** : `/opt/Kazeia/keystore/kazeia-release.jks`. Si cette clé est perdue,
> **aucune mise à jour in-place n'est plus possible** (désinstallation forcée = perte des
> données patient). → **Sauvegarder le dossier `keystore/` hors machine, maintenant.**
---
## 1. Ce qui existe
| Fichier | Rôle |
|---|---|
| `/opt/Kazeia/keystore/kazeia-release.jks` | Keystore (RSA 4096, alias `kazeia`, valide jusqu'en **2056**) |
| `/opt/Kazeia/keystore/credentials.properties` | Chemin + mots de passe, lu par Gradle (chmod 600) |
| `docs/RELEASE_SIGNING.md` | Ce document |
Empreinte du certificat (référence pour vérifier qu'on signe avec la bonne clé) :
```
SHA256: 4B:40:8D:9D:40:09:FD:68:F7:FD:5B:E0:BD:64:2B:D3:EE:B8:F8:BD:7A:84:D3:9C:84:90:B8:63:21:AF:E4:90
```
**Le dossier `keystore/` est VERSIONNÉ dans le dépôt git** (décision 2026-06-11 : le dépôt
`git.kazeia.com` est privé, auto-hébergé, équipe restreinte → le git sert de sauvegarde de
la clé). Conséquences à garder en tête :
- **accès au dépôt = capacité de signer des mises à jour Kazeia** ;
- la clé reste dans l'historique git pour toujours ;
- **si le dépôt doit un jour être ouvert/partagé plus largement → rotation de clé AVANT** (§7).
## 2. Comment c'est câblé
`app/build.gradle.kts` et `app-admin/build.gradle.kts` lisent
`/opt/Kazeia/keystore/credentials.properties` :
- **Présent**`assembleRelease` signe avec la clé release.
- **Absent** (autre machine, CI sans secrets) → le build **ne casse pas** : release signé
en **debug** avec un warning `⚠ keystore release absent`. Ces APK de repli ne doivent
JAMAIS être distribuées.
**Une seule clé pour les deux apps** (patient `com.kazeia` + admin `com.kazeia.admin`) :
même identité → permettra de protéger le ContentProvider par une
`signature`-permission (prévu au plan admin, pas encore fait).
## 3. Construire et distribuer une release
```bash
cd /opt/Kazeia/kazeia-android
# 1) Bumper versionCode (OBLIGATOIRE sinon l'OTA ne se déclenche pas)
# app/build.gradle.kts : versionCode = N+1
# (app-admin pareil si distribuée)
# 2) Build
./gradlew :app:assembleRelease # → app/build/outputs/apk/release/app-release.apk
# 3) Vérifier la signature (DOIT afficher l'empreinte SHA256 ci-dessus)
/opt/Kazeia/android-sdk/build-tools/*/apksigner verify --print-certs \
app/build/outputs/apk/release/app-release.apk | grep SHA-256
# 4) Publier via le catalogue Nextcloud
cp app/build/outputs/apk/release/app-release.apk /opt/Kazeia/kazeia-dist/apk/kazeia.apk
# Éditer kazeia-dist/catalog.spec.json : app.version_code = N+1, bump catalog_version
python3 /opt/Kazeia/kazeia-dist-tools/make_catalog.py --dist-root /opt/Kazeia/kazeia-dist --upload
```
Les tablettes voient `catalog.app.version_code > BuildConfig.VERSION_CODE` → téléchargent,
vérifient le SHA-256, et proposent l'installation (dialogue système).
## 4. SAUVEGARDE — le point qui ne pardonne pas
La clé EST l'identité de l'app. Android n'accepte une mise à jour que si elle est signée
par la **même clé** que la version installée.
**Sauvegarde principale : le dépôt git privé** (`keystore/` est versionné, cf §1). Une panne
de la machine de build ne coûte donc plus la clé — tant que `git.kazeia.com` est vivant.
**Recommandé en plus** (le git et la machine de build peuvent partager un même sinistre —
serveur et poste au même endroit) : une copie hors-ligne occasionnelle :
```bash
tar czf kazeia-keystore-$(date +%Y%m%d).tar.gz -C /opt/Kazeia keystore/ # → USB / coffre
```
**Sur une nouvelle machine de build** : `git clone` apporte le code, le keystore ET la
définition des jniLibs (manifest). Mais les **binaires jniLibs** (474 Mo, gitignorés) doivent
être récupérés à part :
```bash
# 1) code + keystore + manifest
git clone https://git.kazeia.com/Kazeia/kazeia.git /opt/Kazeia && cd /opt/Kazeia
chmod 600 keystore/credentials.properties
# 2) jniLibs (tarball privé sur Nextcloud, auth requise)
curl -u <user>:<pass> -o /tmp/jnilibs.tar.gz \
"https://box.kazeia.com/remote.php/dav/files/kazeia/soft/build-artifacts/kazeia-jnilibs-20260611.tar.gz"
bash kazeia-android/scripts/jnilibs.sh import /tmp/jnilibs.tar.gz # extrait + verify
# 3) build
cd kazeia-android && ./gradlew :app:assembleRelease
```
Le tarball jniLibs vit dans `soft/build-artifacts/` (dossier Nextcloud auth-protégé, NON
référencé par le catalogue → jamais servi à l'app). À régénérer après tout changement de
libs : `jnilibs.sh export` → re-uploader. La version (date dans le nom) doit correspondre au
`jnilibs.MANIFEST.sha256` du commit.
## 5. Scénarios de panne
| Scénario | Conséquence | Procédure |
|---|---|---|
| **Keystore perdu (aucune sauvegarde)** | Plus AUCUNE mise à jour in-place possible | Générer une nouvelle clé, désinstaller/réinstaller sur chaque tablette → **perte des données patient** (SQLCipher lié à l'install). Exporter l'historique via l'admin AVANT si possible. À éviter à tout prix → §4. |
| **Mot de passe perdu (jks présent)** | Identique à la perte du keystore | Aucun recouvrement possible sur un .jks. Le mot de passe est dans `credentials.properties` — sauvegardé AVEC le .jks. |
| **Machine de build morte (sauvegarde OK)** | Aucun impact | Restaurer `keystore/` sur la nouvelle machine (§4). |
| **Clé compromise (fuite)** | Un tiers peut signer des mises à jour | Distribution étant privée (Nextcloud authentifié), changer aussi les identifiants WebDAV ; planifier une migration de clé (réinstallation contrôlée). |
## 6. Migration des tablettes existantes (one-shot)
Les installs actuelles sont **debug-signées** → la première APK release sera refusée en
mise à jour (mismatch). **Une fois par tablette** :
```bash
# 1) (si données à garder) export de l'historique via l'app admin
# 2) Désinstaller les builds debug
adb uninstall com.kazeia ; adb uninstall com.kazeia.admin
# 3) Installer les release
adb install app/build/outputs/apk/release/app-release.apk
adb install app-admin/build/outputs/apk/release/app-admin-release.apk
# 4) Re-provisionner (les modèles sur stockage externe/`/data/local/tmp` SURVIVENT à la
# désinstallation ; profils/conversations/config in-app sont PERDUS → re-créer le profil)
```
Après cette migration, toutes les mises à jour passent par l'OTA catalogue, sans perte.
**Le dev quotidien reste en debug** (`assembleDebug` + `adb install -r`) : debug et
release sont signés différemment, on ne peut pas écraser l'un par l'autre. La tablette
de dev reste en debug ; les tablettes patient passent en release.
## 7. Rotation / clés multiples (futur)
- Pas d'expiration avant 2056 ; pas de rotation préventive nécessaire pour une
distribution privée.
- Si un jour publication sur un store : Google Play App Signing prendra cette clé comme
« upload key » et gérera la clé de signature côté store (le risque de perte disparaît).

View File

@ -0,0 +1,22 @@
# Corpus seed RAG (psychoéducation FR)
Corpus de DÉPART pour le RAG Kazeia — contenu standard et sûr, 18 fiches (~1 chunk
chacune, Chunker maxChars=1200) : sommeil, respiration, ancrage 5-4-3-2-1, activation
comportementale, rumination, solitude, crise d'angoisse, pensées automatiques,
émotions, estime de soi, auto-compassion, colère, deuil, stress, relaxation
musculaire progressive, pleine conscience, anxiété sociale, demander de l'aide.
Lignes éditoriales : une fiche = un thème + une technique concrète ; vouvoiement
neutre (matériau de référence injecté dans le prompt, pas la voix de Kazeia) ;
JAMAIS de médication, de diagnostic ni de gestion de crise suicidaire (la crise est
gérée en dur par CrisisGuard, hors LLM). Les fiches orientent vers un professionnel
quand c'est pertinent.
**À curer/valider par le clinicien (Damien) avant activation patient.**
En prod il est distribué via le catalogue Nextcloud et déposé sous
`.../kazeia/rag_corpus/` ; l'app l'ingère au 1er démarrage si la base RAG est vide
(ou via l'intent `rag_ingest`). Ce dossier `docs/rag_corpus_seed/` est la source
canonique (versionnée) ; `kazeia-dist/rag_corpus/` n'en est que la copie publiée.
Modèle d'embedding : `multilingual-e5-small` (384-dim, préfixes query:/passage:).

View File

@ -0,0 +1 @@
Technique d'ancrage 5-4-3-2-1. Lorsqu'une émotion devient envahissante ou en cas d'attaque de panique, ramener son attention au présent aide à reprendre pied. Nommez cinq choses que vous voyez autour de vous, quatre choses que vous pouvez toucher, trois sons que vous entendez, deux odeurs que vous percevez, et une chose que vous pouvez goûter. Cet exercice d'ancrage sensoriel interrompt le cycle des pensées anxieuses en reconnectant le corps à l'environnement immédiat.

View File

@ -0,0 +1 @@
Anxiété sociale et évitement. La peur du regard ou du jugement des autres pousse à éviter les situations sociales : refuser une invitation, se taire en groupe, fuir les regards. L'évitement soulage sur le moment mais renforce la peur à long terme — il confirme au cerveau que la situation était dangereuse. Le chemin inverse est l'exposition progressive : dresser une liste de situations classées de la moins à la plus difficile, et s'exposer régulièrement à la première marche jusqu'à ce qu'elle devienne gérable, avant de passer à la suivante. Rester dans la situation assez longtemps pour sentir l'anxiété redescendre d'elle-même. Après coup, noter ce qui s'est réellement passé plutôt que ce qu'on craignait : l'écart entre les deux est l'apprentissage. Les autres remarquent beaucoup moins nos signes de gêne qu'on ne l'imagine.

View File

@ -0,0 +1 @@
Auto-compassion. L'auto-compassion consiste à se traiter soi-même avec la même bienveillance qu'on offrirait à un ami en difficulté. Elle repose sur trois appuis : la gentillesse envers soi plutôt que le jugement ; reconnaître que souffrir, échouer, douter font partie de l'expérience humaine commune — on n'est pas seul ni anormal ; et observer sa douleur avec un peu de recul, sans s'y noyer ni la nier. Exercice : dans un moment difficile, poser une main sur le cœur et se dire « c'est un moment douloureux ; d'autres vivent cela aussi ; puis-je être doux avec moi-même ? ». L'auto-compassion n'est pas de la complaisance : les recherches montrent qu'elle augmente la motivation et la capacité à rebondir après un échec.

View File

@ -0,0 +1 @@
Gérer la colère. La colère est une émotion normale qui signale qu'une limite ou une valeur a été heurtée. Elle devient un problème quand elle explose ou qu'elle est ravalée systématiquement. Au moment de la montée : repérer les signes physiques (chaleur, mâchoires serrées, voix qui monte) et s'accorder un temps de pause — quitter la pièce quelques minutes, respirer lentement, marcher. Décider de revenir sur le sujet à froid n'est pas fuir : c'est se donner les moyens d'être entendu. À froid, exprimer le besoin derrière la colère avec des messages en « je » : « je me suis senti ignoré quand… j'aurais besoin que… » plutôt que des reproches en « tu ». Si la colère déborde régulièrement ou fait peur à l'entourage, en parler à un professionnel aide à en reprendre le contrôle.

View File

@ -0,0 +1 @@
Crise d'angoisse (attaque de panique). Une crise d'angoisse est une montée brutale de peur intense accompagnée de sensations physiques fortes : cœur qui s'emballe, souffle court, vertiges, tremblements, impression d'étouffer ou de perdre le contrôle. Ces sensations sont très impressionnantes mais ne sont pas dangereuses : une crise atteint son pic en quelques minutes puis redescend toujours, même sans rien faire. Lutter contre la crise ou fuir la situation tend à renforcer la peur des prochaines crises. Ce qui aide : reconnaître « c'est une crise d'angoisse, elle va passer », ralentir l'expiration, s'ancrer dans le présent par les cinq sens, et laisser la vague monter puis redescendre sans la combattre. Si les crises se répètent, en parler à un professionnel de santé permet de les traiter efficacement.

View File

@ -0,0 +1 @@
Demander de l'aide professionnelle. Consulter un médecin, un psychologue ou un psychothérapeute n'est pas un signe de faiblesse : c'est une démarche active de soin, comme consulter pour une douleur physique persistante. Quelques repères qui indiquent qu'un accompagnement aiderait : une souffrance qui dure depuis plusieurs semaines, un retentissement sur le sommeil, l'appétit, le travail ou les relations, un isolement qui s'installe, ou le sentiment de tourner en rond malgré ses efforts. Le médecin traitant est souvent la porte d'entrée la plus simple : il connaît le parcours et peut orienter. Préparer la consultation en notant ses difficultés concrètes aide à ne rien oublier. Un suivi psychologique demande parfois d'essayer plus d'un praticien avant de trouver la bonne personne : c'est normal, et cela vaut l'effort.

View File

@ -0,0 +1 @@
Deuil et perte. Le deuil ne suit pas un parcours linéaire avec des étapes fixes : il avance par vagues. Tristesse, colère, culpabilité, soulagement, moments d'apaisement puis rechutes — toutes ces réactions sont normales, parfois le même jour. Il n'y a pas de durée « correcte » ni de bonne manière de faire son deuil. Ce qui aide : s'autoriser à ressentir sans se juger, parler du défunt et des souvenirs, maintenir un minimum de routine (repas, sommeil, sorties), accepter le soutien des proches même brièvement. Les dates anniversaires ravivent souvent la douleur : c'est attendu, pas une rechute. Si, après plusieurs mois, la douleur reste aussi envahissante qu'au début et empêche de vivre, un accompagnement spécialisé du deuil peut apporter un vrai soulagement.

View File

@ -0,0 +1 @@
Nommer ses émotions. Mettre des mots précis sur ce que l'on ressent — tristesse, colère, honte, peur, déception, frustration — réduit en soi l'intensité de l'émotion : le cerveau traite mieux ce qu'il peut nommer. Une émotion est un signal, pas un ennemi : la tristesse signale une perte, la colère une limite franchie, la peur un danger perçu. Chercher à supprimer une émotion la renforce souvent ; l'accueillir comme une vague qui monte puis redescend lui permet de passer. Exercice simple : trois fois par jour, s'arrêter et compléter « là, maintenant, je ressens… parce que… ». Avec la pratique, on distingue mieux les nuances, et on choisit mieux comment y répondre plutôt que de réagir à chaud.

View File

@ -0,0 +1 @@
Estime de soi et autocritique. Une voix intérieure dure (« je suis nul », « je n'y arriverai jamais ») abîme l'estime de soi et coupe l'élan. Cette voix se présente comme lucide, mais elle est partiale : elle retient les échecs et efface les réussites. Pour rééquilibrer : noter chaque jour une chose faite correctement, même petite, et une qualité dont on a fait preuve. Se juger sur des actions précises (« j'ai raté ce dossier ») plutôt que sur sa personne entière (« je suis un raté »). Remplacer l'exigence de perfection par un objectif de progrès. L'estime de soi se reconstruit par l'accumulation de petites preuves concrètes, pas par la volonté de penser positif.

View File

@ -0,0 +1 @@
Activation comportementale et humeur basse. Quand le moral est bas, l'envie de faire des choses diminue, ce qui réduit encore les occasions de ressentir du plaisir : un cercle vicieux s'installe. L'activation comportementale consiste à programmer de petites activités agréables ou valorisantes, même sans motivation au départ. Commencez petit : une courte marche, un appel à un proche, une tâche simple menée à son terme. L'action précède souvent la motivation, et non l'inverse. Noter ce qui a procuré un peu de satisfaction aide à repérer ce qui fait du bien.

View File

@ -0,0 +1 @@
Pensées automatiques et recul. Face à une situation, des pensées surgissent d'elles-mêmes, rapides et souvent négatives : « je vais échouer », « personne ne m'apprécie », « c'est ma faute ». Ces pensées automatiques semblent vraies sur le moment, mais ce sont des interprétations, pas des faits. Quelques pièges fréquents : tout voir en noir ou blanc, généraliser à partir d'un seul événement (« toujours », « jamais »), lire dans les pensées des autres, prédire le pire. Pour prendre du recul : noter la pensée telle quelle, puis se demander « quels faits l'appuient ? quels faits la contredisent ? que dirais-je à un ami qui penserait cela ? ». Formuler ensuite une pensée plus juste et nuancée, même imparfaite, suffit souvent à faire baisser l'émotion d'un cran.

View File

@ -0,0 +1 @@
Pleine conscience au quotidien. La pleine conscience consiste à porter intentionnellement son attention sur le moment présent, sans jugement. Elle s'entraîne par de courts exercices : observer sa respiration pendant deux minutes, et chaque fois que l'esprit part — c'est normal et attendu — le ramener doucement au souffle, sans se reprocher la distraction. Ramener l'attention est l'exercice, pas un échec. On peut aussi pratiquer en marchant (sentir les appuis des pieds), en mangeant (goûts, textures), en se lavant les mains (l'eau, la température). Avec la régularité, on apprend à observer ses pensées et émotions comme des événements passagers plutôt que de se laisser embarquer par eux. Mieux vaut deux minutes chaque jour qu'une longue séance occasionnelle.

View File

@ -0,0 +1 @@
Relaxation musculaire progressive. L'anxiété et le stress s'accompagnent de tensions musculaires souvent inaperçues : épaules remontées, mâchoires serrées, ventre noué. La relaxation musculaire progressive consiste à contracter volontairement un groupe musculaire pendant cinq secondes, puis à le relâcher d'un coup en portant attention à la détente pendant une dizaine de secondes. Parcourir le corps dans l'ordre : mains et avant-bras, bras, épaules, visage, nuque, ventre, jambes, pieds. Le contraste contraction-relâchement apprend au corps à reconnaître puis à relâcher la tension. Dix à quinze minutes, assis ou allongé, idéalement chaque jour au début. Pratiquée le soir, elle facilite aussi l'endormissement.

View File

@ -0,0 +1 @@
Respiration et apaisement de l'anxiété. La respiration lente et profonde active le système nerveux parasympathique et réduit la tension physique. Un exercice simple : inspirez doucement par le nez pendant quatre secondes, puis expirez lentement par la bouche pendant six secondes. L'expiration plus longue que l'inspiration est ce qui calme le rythme cardiaque. Répétez ce cycle pendant quelques minutes, en posant une main sur le ventre pour sentir le souffle. Cet exercice peut se pratiquer discrètement, à tout moment d'une montée d'angoisse.

View File

@ -0,0 +1 @@
Rumination mentale. Ruminer, c'est tourner en boucle sur des pensées négatives sans parvenir à une solution. Cela amplifie la détresse sans rien résoudre. Pour s'en dégager, il peut aider de fixer un court moment dédié aux soucis dans la journée, puis de reporter les pensées qui surgissent en dehors de ce créneau. Distinguer ce qui est sous votre contrôle de ce qui ne l'est pas oriente l'énergie vers l'action possible. Prendre du recul en observant la pensée comme un événement mental passager, plutôt que comme une vérité, réduit son emprise.

View File

@ -0,0 +1 @@
Sentiment de solitude. Se sentir seul est une expérience douloureuse et fréquente, qui ne reflète pas une valeur personnelle. Le lien social, même bref et modeste, nourrit le bien-être : un message, un sourire échangé, une courte conversation comptent. Reprendre contact progressivement, à son rythme, vaut mieux que d'attendre de se sentir prêt. Les activités partagées autour d'un intérêt commun créent du lien sans la pression de la conversation directe. Tenir un journal de trois moments positifs par jour aide à rééquilibrer l'attention souvent captée par le manque.

View File

@ -0,0 +1 @@
Hygiène du sommeil. Pour favoriser l'endormissement, il est utile de se coucher et se lever à des horaires réguliers, même le week-end. Évitez les écrans lumineux dans l'heure qui précède le coucher, car la lumière bleue retarde la sécrétion de mélatonine. La chambre doit rester fraîche, sombre et silencieuse. La caféine et l'alcool perturbent le sommeil profond : limitez-les en fin de journée. Si le sommeil ne vient pas après vingt minutes, mieux vaut se lever et faire une activité calme plutôt que de rester au lit à ruminer.

View File

@ -0,0 +1 @@
Faire face au stress. Le stress est la réponse du corps à une demande perçue comme dépassant nos ressources. Face à une situation stressante, deux voies se complètent. Quand on peut agir sur le problème : le découper en petites étapes concrètes, traiter la première, et seulement elle. Quand on ne peut pas agir (attente, incertitude, décision d'autrui) : prendre soin de l'émotion — respiration lente, activité physique, parler à quelqu'un, se changer les idées sans culpabiliser. Demander « est-ce sous mon contrôle ? » oriente vers la bonne voie. Le stress chronique s'entretient par le manque de sommeil, l'excès de caféine et l'isolement : protéger ces trois piliers est déjà une stratégie anti-stress. Des pauses brèves mais régulières préviennent mieux l'épuisement qu'un long repos tardif.

View File

@ -0,0 +1,104 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
// MÊME keystore release que l'app patient (permet à terme la permission de
// signature sur le ContentProvider). Voir docs/RELEASE_SIGNING.md.
val releaseKeystoreProps = Properties().apply {
val f = file("/opt/Kazeia/keystore/credentials.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
val hasReleaseKeystore = releaseKeystoreProps.getProperty("storeFile")
?.let { file(it).exists() } == true
android {
namespace = "com.kazeia.admin"
compileSdk = 36
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(releaseKeystoreProps.getProperty("storeFile"))
storePassword = releaseKeystoreProps.getProperty("storePassword")
keyAlias = releaseKeystoreProps.getProperty("keyAlias")
keyPassword = releaseKeystoreProps.getProperty("keyPassword")
}
}
}
defaultConfig {
applicationId = "com.kazeia.admin"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "0.1.0-mvp"
ndk {
abiFilters += "arm64-v8a"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (hasReleaseKeystore) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn("⚠ keystore release absent (/opt/Kazeia/keystore/) — release signé en DEBUG. Voir docs/RELEASE_SIGNING.md")
signingConfig = signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
// Core
implementation("androidx.core:core-ktx:1.15.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// JSON (manifest serialization)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// DataStore (PIN + UI settings)
implementation("androidx.datastore:datastore-preferences:1.1.1")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@ -0,0 +1 @@
# Default rules minify disabled in MVP, keep file for future

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<queries>
<package android:name="com.kazeia" />
</queries>
<application
android:label="Kazeia Admin"
android:icon="@mipmap/ic_launcher_admin"
android:roundIcon="@mipmap/ic_launcher_admin"
android:theme="@style/Theme.KazeiaAdmin"
android:supportsRtl="true"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,135 @@
package com.kazeia.admin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.Row
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.kazeia.admin.ui.components.StubScreen
import com.kazeia.admin.ui.history.HistoryScreen
import com.kazeia.admin.ui.login.LoginScreen
import com.kazeia.admin.ui.navigation.AdminDestination
import com.kazeia.admin.ui.profiles.ProfilesScreen
import com.kazeia.admin.ui.prompts.PromptsScreen
import com.kazeia.admin.ui.system.SystemScreen
import com.kazeia.admin.ui.telemetry.TelemetryScreen
import com.kazeia.admin.ui.theme.KazeiaAdminTheme
import com.kazeia.admin.ui.voices.RecordVoiceScreen
import com.kazeia.admin.ui.voices.VoicesScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KazeiaAdminTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AdminApp()
}
}
}
}
}
@Composable
fun AdminApp() {
var authenticated by remember { mutableStateOf(false) }
if (!authenticated) {
LoginScreen(onAuthenticated = { authenticated = true })
} else {
MainShell()
}
}
@Composable
private fun MainShell() {
val nav = rememberNavController()
val current by nav.currentBackStackEntryAsState()
val currentRoute = current?.destination?.route
Row(modifier = Modifier.fillMaxSize()) {
// Show the rail only on top-level destinations (hide it during record flow)
val showRail = AdminDestination.entries.any { it.route == currentRoute }
if (showRail) {
AdminNavigationRail(currentRoute = currentRoute, nav = nav)
}
Box(modifier = Modifier.fillMaxSize()) {
NavHost(navController = nav, startDestination = AdminDestination.Voices.route) {
composable(AdminDestination.Voices.route) {
VoicesScreen(onStartRecording = { nav.navigate("voices/new") })
}
composable("voices/new") {
RecordVoiceScreen(
onCancel = { nav.popBackStack() },
onSaved = { nav.popBackStack() }
)
}
composable(AdminDestination.Profiles.route) {
ProfilesScreen()
}
composable(AdminDestination.Prompts.route) {
PromptsScreen()
}
composable(AdminDestination.Rag.route) {
com.kazeia.admin.ui.rag.RagScreen()
}
composable(AdminDestination.History.route) {
HistoryScreen()
}
composable(AdminDestination.Telemetry.route) {
TelemetryScreen()
}
composable(AdminDestination.System.route) {
SystemScreen()
}
}
}
}
}
@Composable
private fun AdminNavigationRail(currentRoute: String?, nav: NavHostController) {
NavigationRail(
containerColor = MaterialTheme.colorScheme.surface
) {
AdminDestination.entries.forEach { dest ->
NavigationRailItem(
selected = currentRoute == dest.route,
onClick = {
if (currentRoute != dest.route) {
nav.navigate(dest.route) {
launchSingleTop = true
popUpTo(AdminDestination.Voices.route) { saveState = true }
restoreState = true
}
}
},
icon = { Icon(dest.icon, contentDescription = null) },
label = { Text(stringResource(dest.labelRes)) }
)
}
}
}

View File

@ -0,0 +1,37 @@
package com.kazeia.admin.data.mock
import com.kazeia.admin.data.model.Profile
import com.kazeia.admin.data.model.Voice
import com.kazeia.admin.data.model.VoiceState
/**
* Données mock pour iterer sur l'UI sans dépendance au Kazeia patient.
* Sera remplacé par un VoiceRepository réel quand la control surface
* (ContentProvider + fichiers) sera en place (Phase 1).
*/
object MockData {
val voices: List<Voice> = listOf(
Voice("damien", "Damien", VoiceState.READY, isBuiltIn = true,
durationSeconds = 12.1f, speakerNorm = 10.42f),
Voice("elodie", "Elodie", VoiceState.READY, isBuiltIn = true,
durationSeconds = 11.4f, speakerNorm = 10.18f),
Voice("zelda", "Zelda", VoiceState.READY, isBuiltIn = true,
durationSeconds = 13.0f, speakerNorm = 9.95f),
Voice("otis", "Otis", VoiceState.READY, isBuiltIn = true,
durationSeconds = 12.7f, speakerNorm = 10.31f),
Voice("amir_2026-05-14", "Amir", VoiceState.ERROR,
durationSeconds = 11.2f, speakerNorm = 4.2f,
errorMessage = "Norme speaker hors plage (4.2 < 8). Audio probablement bruité."),
Voice("bertrand_2026-05-13", "Bertrand", VoiceState.RECORDED,
durationSeconds = 10.5f,
errorMessage = null),
Voice("claire_2026-05-12", "Claire", VoiceState.PROCESSING,
durationSeconds = 12.0f)
)
val profiles: List<Profile> = listOf(
Profile("patient_001", "Patient 1", voiceId = "damien", isActive = true),
Profile("patient_002", "Patient 2", voiceId = "elodie"),
Profile("patient_003", "Patient 3", voiceId = "zelda")
)
}

View File

@ -0,0 +1,15 @@
package com.kazeia.admin.data.model
/**
* Profil patient : identité + paramètres personnalisés.
* Stocké dans Room DB côté Kazeia patient, lu via ContentProvider.
*/
data class Profile(
val id: String,
val displayName: String,
val voiceId: String? = null,
val systemPromptOverride: String? = null,
val ragProfileId: String? = null,
val isActive: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)

View File

@ -0,0 +1,68 @@
package com.kazeia.admin.data.model
/**
* Snapshot complet de l'état Kazeia : état process + KPI pipeline
* récents + séries temporelles mémoire + événements + crashes.
*
* Pour l'instant alimenté par MockTelemetry. À terme alimenté par
* le ContentProvider de Kazeia patient (Phase 1).
*/
data class TelemetrySnapshot(
val kazeiaState: KazeiaProcessState,
val pipeline: PipelineKpi,
val memorySeries: MemorySeries,
val recentTurns: List<TurnEvent>,
val recentCrashes: List<CrashEvent>,
val updatedAt: Long = System.currentTimeMillis()
)
data class KazeiaProcessState(
val running: Boolean,
val pid: Int?,
val uptimeSeconds: Long,
val cpuPercent: Float,
val pssMb: Int,
val ionMb: Int
)
/**
* KPI agrégés sur les N derniers tours conversation
* (typiquement N=10). Chaque série est dans l'ordre chronologique :
* série[N-1] = tour le plus récent.
*/
data class PipelineKpi(
val ttftMs: List<Int>, // Time-to-first-token LLM, ms
val llmTokensPerSec: List<Float>,
val ttsRtf: List<Float>, // < 1 = temps réel OK
val sttRtf: List<Float>,
val totalCycleMs: List<Int> // STT + LLM + TTS + playback
) {
fun avgTtft(): Int = if (ttftMs.isEmpty()) 0 else ttftMs.average().toInt()
fun avgTps(): Float = if (llmTokensPerSec.isEmpty()) 0f else llmTokensPerSec.average().toFloat()
fun avgTtsRtf(): Float = if (ttsRtf.isEmpty()) 0f else ttsRtf.average().toFloat()
fun avgSttRtf(): Float = if (sttRtf.isEmpty()) 0f else sttRtf.average().toFloat()
fun avgTotalSec(): Float = if (totalCycleMs.isEmpty()) 0f else totalCycleMs.average().toFloat() / 1000f
}
/** Séries temporelles mémoire : 60 derniers échantillons (1 par seconde) */
data class MemorySeries(
val pssMb: List<Int>,
val ionMb: List<Int>,
val systemAvailMb: List<Int>
)
data class TurnEvent(
val timestamp: Long,
val turnNumber: Int,
val llmTokensPerSec: Float,
val tokensGenerated: Int,
val totalMs: Int,
val ok: Boolean
)
data class CrashEvent(
val timestamp: Long,
val signal: String, // ex: "SIGABRT", "SIGSEGV"
val component: String, // ex: "LLM (prefill)", "TTS (talker)"
val message: String?
)

View File

@ -0,0 +1,28 @@
package com.kazeia.admin.data.model
/**
* Une voix Kazeia : référence audio + (optionnellement) embeddings extraits côté PC.
*
* État machine :
* recorded WAV présent, embeddings absents non utilisable TTS
* processing PC tool extrait actuellement
* ready embeddings présents, voix opérationnelle
* error extraction échouée (norm hors plage, audio bruité)
*
* Cf project_kazeia_voice_workflow.md pour le workflow complet.
*/
enum class VoiceState {
RECORDED, PROCESSING, READY, ERROR
}
data class Voice(
val id: String,
val name: String,
val state: VoiceState,
val isBuiltIn: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val durationSeconds: Float? = null,
val speakerNorm: Float? = null,
val errorMessage: String? = null,
val deployedOnDevices: List<String> = emptyList()
)

View File

@ -0,0 +1,69 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.source.KazeiaConfigClient
/**
* Thin repository qui expose la config Kazeia + la liste des modèles
* disponibles depuis le ContentProvider. Les écrans Prompts et Système
* y délèguent leur I/O.
*/
class ConfigRepository(context: Context) {
private val client = KazeiaConfigClient(context.contentResolver)
fun config(): KazeiaConfigClient.Config? = client.queryConfig()
fun models(): List<KazeiaConfigClient.ModelInfo> = client.queryModels()
fun updateCascade(enabled: Boolean): Boolean =
client.updateConfig(cascadeEnabled = enabled)
fun updateTts(enabled: Boolean): Boolean =
client.updateConfig(ttsEnabled = enabled)
fun updateSpeakerModel(modelId: String): Boolean =
client.updateConfig(speakerModelId = modelId)
fun updateThinkerModel(modelId: String): Boolean =
client.updateConfig(thinkerModelId = modelId)
fun updateSpeakerPrompt(prompt: String): Boolean =
client.updateConfig(speakerSystemPrompt = prompt)
fun updateThinkerPrompt(prompt: String): Boolean =
client.updateConfig(thinkerSystemPrompt = prompt)
fun resetSpeakerPromptToDefault(): Boolean =
client.updateConfig(speakerSystemPrompt = DEFAULT_SPEAKER_PROMPT)
fun resetThinkerPromptToDefault(): Boolean =
client.updateConfig(thinkerSystemPrompt = DEFAULT_THINKER_PROMPT)
companion object {
// Doivent rester strictement synchronisés avec ConfigStore côté Kazeia.
// Si on les divergent, le "réinitialiser" produira un prompt différent
// du fallback Kazeia patient.
const val DEFAULT_SPEAKER_PROMPT =
"Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle " +
"simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. " +
"Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : " +
"ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de " +
"médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à " +
"en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou " +
"simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes " +
"échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
const val DEFAULT_THINKER_PROMPT =
"Tu es un psychologue clinicien experimente qui ecoute attentivement ton " +
"patient. Avant de repondre, tu prepares une analyse silencieuse pour " +
"orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict " +
"suivant :\n" +
"- emotion : <emotion principale ressentie, 5 mots max>\n" +
"- besoin : <besoin sous-jacent du patient, 5 mots max>\n" +
"- approche : <posture therapeutique adaptee, 5 mots max>\n" +
"- axe : <point cle a valider ou explorer, 5 mots max>\n" +
"Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"
}
}

View File

@ -0,0 +1,100 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.source.KazeiaConversationsClient
import com.kazeia.admin.data.source.KazeiaProfilesClient
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Repository de l'historique conversation. Combine les sessions du
* ContentProvider Kazeia avec les noms de profils, et gère l'export JSON
* vers le stockage externe de l'admin (récupérable par câble depuis le PC,
* même workflow que les voix).
*/
class ConversationsRepository(private val context: Context) {
private val client = KazeiaConversationsClient(context.contentResolver)
private val profilesClient = KazeiaProfilesClient(context.contentResolver)
data class Session(
val profileId: String,
val profileName: String,
val sessionId: String,
val startedAt: Long,
val endedAt: Long,
val turnCount: Int
)
/** Sessions enrichies du nom de profil, optionnellement filtrées. */
fun sessions(profileId: String? = null): List<Session> {
val names = profilesClient.queryAll().associate { it.id to it.displayName }
return client.sessions(profileId).map { s ->
Session(
profileId = s.profileId,
profileName = names[s.profileId] ?: s.profileId,
sessionId = s.sessionId,
startedAt = s.startedAt,
endedAt = s.endedAt,
turnCount = s.turnCount
)
}
}
/** Profils ayant au moins une session — pour le filtre. */
fun profilesWithHistory(): List<Pair<String, String>> {
val names = profilesClient.queryAll().associate { it.id to it.displayName }
return client.sessions(null)
.map { it.profileId }
.distinct()
.map { id -> id to (names[id] ?: id) }
}
fun turns(sessionId: String): List<KazeiaConversationsClient.TurnRow> =
client.turns(sessionId)
fun deleteSession(sessionId: String): Boolean = client.deleteSession(sessionId)
fun deleteProfileHistory(profileId: String): Boolean =
client.deleteProfileHistory(profileId)
/**
* Exporte une session en JSON dans `Android/data/com.kazeia.admin/files/
* historique/`. Retourne le fichier créé, ou null en cas d'échec.
*/
fun exportSession(session: Session): File? = runCatching {
val turns = client.turns(session.sessionId)
val json = JSONObject().apply {
put("profile_id", session.profileId)
put("profile_name", session.profileName)
put("session_id", session.sessionId)
put("started_at", session.startedAt)
put("ended_at", session.endedAt)
put("turn_count", session.turnCount)
put("exported_at", System.currentTimeMillis())
put("turns", JSONArray().apply {
turns.forEach { t ->
put(JSONObject().apply {
put("timestamp", t.timestamp)
put("role", t.role)
put("text", t.text)
t.ttftMs?.let { put("ttft_ms", it) }
t.totalMs?.let { put("total_ms", it) }
t.voiceUsed?.let { put("voice_used", it) }
t.modelUsed?.let { put("model_used", it) }
})
}
})
}
val dir = File(context.getExternalFilesDir(null), "historique").apply { mkdirs() }
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(session.startedAt))
val safeName = session.profileName.replace(Regex("[^A-Za-z0-9]"), "_")
val file = File(dir, "${safeName}_$stamp.json")
file.writeText(json.toString(2))
file
}.getOrNull()
}

View File

@ -0,0 +1,45 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.source.KazeiaProfilesClient
/**
* Thin repo qui sépare l'UI du ContentResolver. La création / modification
* d'un profil passe par `update()` (le ContentProvider crée si l'id n'existe
* pas encore pattern upsert).
*/
class ProfilesRepository(context: Context) {
private val client = KazeiaProfilesClient(context.contentResolver)
fun list(): List<KazeiaProfilesClient.ProfileRow> = client.queryAll()
fun create(
id: String,
displayName: String,
voiceId: String? = null,
pin: String? = null,
isDefault: Boolean = false,
notes: String? = null
): Boolean = client.upsert(
id = id, displayName = displayName, voiceId = voiceId,
pin = pin, notes = notes, isDefault = isDefault
)
fun rename(id: String, newName: String): Boolean =
client.upsert(id = id, displayName = newName)
fun setVoice(id: String, voiceId: String?): Boolean =
client.upsert(id = id, voiceId = voiceId)
fun setPin(id: String, pin: String?): Boolean =
client.upsert(id = id, pin = pin ?: "") // "" efface
fun setDefault(id: String): Boolean = client.upsert(id = id, isDefault = true)
fun delete(id: String): Boolean = client.delete(id)
fun activeProfileId(): String? = client.activeProfileId()
fun setActive(id: String?): Boolean = client.setActive(id)
}

View File

@ -0,0 +1,30 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.source.KazeiaConfigClient
import com.kazeia.admin.data.source.KazeiaRagClient
/**
* Passerelle unique de l'écran RAG admin : documents (corpus), config RAG
* (activation / seuil / top-k), statut du moteur, et test de récupération.
*/
class RagRepository(context: Context) {
private val rag = KazeiaRagClient(context.contentResolver)
private val config = KazeiaConfigClient(context.contentResolver)
// -- Documents --
fun docs(): List<KazeiaRagClient.RagDoc>? = rag.queryDocs()
fun docText(source: String): String? = rag.docText(source)
fun save(source: String, text: String): Boolean = rag.upsertDoc(source, text)
fun delete(source: String): Boolean = rag.deleteDoc(source)
// -- Statut + test --
fun status(): KazeiaRagClient.RagStatus? = rag.status()
fun test(query: String): List<KazeiaRagClient.ScoredHit> = rag.test(query)
// -- Config RAG --
fun config(): KazeiaConfigClient.Config? = config.queryConfig()
fun setEnabled(enabled: Boolean): Boolean = config.updateConfig(ragEnabled = enabled)
fun setThreshold(t: Float): Boolean = config.updateConfig(ragThreshold = t)
fun setTopK(k: Int): Boolean = config.updateConfig(ragTopK = k)
}

View File

@ -0,0 +1,127 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.model.CrashEvent
import com.kazeia.admin.data.model.KazeiaProcessState
import com.kazeia.admin.data.model.MemorySeries
import com.kazeia.admin.data.model.PipelineKpi
import com.kazeia.admin.data.model.TelemetrySnapshot
import com.kazeia.admin.data.model.TurnEvent
import com.kazeia.admin.data.source.KazeiaTelemetryClient
import com.kazeia.admin.data.source.SystemTelemetrySource
/**
* Repository qui agrège les deux sources de telemetry :
* - SystemTelemetrySource : `/proc/meminfo`, `/proc/stat` (toujours dispo)
* - KazeiaTelemetryClient : ContentProvider de Kazeia patient (si tourne)
*
* Maintient une fenêtre glissante des 60 derniers échantillons mémoire
* pour produire les sparklines. Pour les KPI pipeline, les valeurs viennent
* directement des derniers tours collectés côté Kazeia.
*/
class TelemetryRepository(context: Context) {
private val system = SystemTelemetrySource()
private val kazeia = KazeiaTelemetryClient(context.contentResolver)
private val pssWindow = ArrayDeque<Int>(60)
private val ionWindow = ArrayDeque<Int>(60)
private val availWindow = ArrayDeque<Int>(60)
private val maxWindow = 60
/**
* Indique si Kazeia est joignable (ContentProvider répond).
* Évite l'affichage erroné quand le process n'est pas lancé.
*/
fun kazeiaAvailable(): Boolean = kazeia.queryState() != null
fun snapshot(): TelemetrySnapshot {
val mem = system.readMemory()
val cpuPct = system.readCpuPercent()
val kState = kazeia.queryState()
val kTurns = kazeia.queryTurns()
val kCrashes = kazeia.queryCrashes()
// Push memory samples to sliding windows. PSS=0 if Kazeia not running.
push(pssWindow, kState?.pssMb ?: 0)
push(ionWindow, mem.ionTotalUsedMb)
push(availWindow, mem.memAvailableMb)
val pipeline = if (kTurns.isNotEmpty()) {
val recent = kTurns.take(10).reversed()
PipelineKpi(
ttftMs = recent.mapNotNull { it.llmTtftMs?.toInt() },
llmTokensPerSec = recent.mapNotNull { it.tokensPerSecond() },
ttsRtf = recent.mapNotNull { rtfTtsFor(it) },
sttRtf = recent.mapNotNull { rtfSttFor(it) },
totalCycleMs = recent.map { it.totalMs.toInt() }
)
} else {
// Pas encore de tour observé — listes vides, l'UI affiche "—"
PipelineKpi(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())
}
val turns: List<TurnEvent> = kTurns.mapIndexedNotNull { idx, t ->
val tps = t.tokensPerSecond() ?: return@mapIndexedNotNull null
TurnEvent(
timestamp = t.timestamp,
turnNumber = kTurns.size - idx,
llmTokensPerSec = tps,
tokensGenerated = t.llmTokens ?: 0,
totalMs = t.totalMs.toInt(),
ok = t.status == "ok"
)
}
val crashes: List<CrashEvent> = kCrashes.map {
CrashEvent(
timestamp = it.timestamp,
signal = "",
component = it.component,
message = it.message
)
}
return TelemetrySnapshot(
kazeiaState = KazeiaProcessState(
running = kState != null,
pid = kState?.pid,
uptimeSeconds = kState?.uptimeSeconds ?: 0,
cpuPercent = cpuPct,
pssMb = kState?.pssMb ?: 0,
ionMb = mem.ionTotalUsedMb
),
pipeline = pipeline,
memorySeries = MemorySeries(
pssMb = pssWindow.toList(),
ionMb = ionWindow.toList(),
systemAvailMb = availWindow.toList()
),
recentTurns = turns,
recentCrashes = crashes
)
}
private fun push(window: ArrayDeque<Int>, v: Int) {
window.addLast(v)
while (window.size > maxWindow) window.removeFirst()
}
// TTS / STT RTF : pas exposés directement par Kazeia, on les estime
// depuis les timings disponibles. À terme exposer dans le provider.
private fun rtfTtsFor(t: KazeiaTelemetryClient.KazeiaTurn): Float? {
val ttfa = t.timeToFirstAudioMs ?: return null
val playback = t.audioPlaybackMs ?: return null
// RTF approx : temps de synth utile / durée audio rendue
if (playback <= 0) return null
val synthMs = (ttfa - (t.llmGenMs ?: 0)).coerceAtLeast(0)
return synthMs.toFloat() / playback.toFloat()
}
private fun rtfSttFor(t: KazeiaTelemetryClient.KazeiaTurn): Float? {
// Pour l'instant on n'a pas la durée d'audio d'entrée séparément :
// sttMs / audio_in_ms. On expose null tant que le provider n'envoie
// pas cette info.
return null
}
}

View File

@ -0,0 +1,86 @@
package com.kazeia.admin.data.repository
import android.content.Context
import com.kazeia.admin.data.model.Voice
import com.kazeia.admin.data.model.VoiceState
import com.kazeia.admin.data.source.KazeiaVoicesClient
import com.kazeia.admin.voice.VoiceStorage
/**
* Repository des voix consommé par les écrans admin. Fusionne deux sources :
* 1. **Voix Kazeia patient** (via ContentProvider) celles que le LLM TTS
* sait utiliser (.bin embeddings présents). State : ready / recorded /
* error selon ce que le provider renvoie.
* 2. **Voix enregistrées localement** par cet admin via le micro tablette
* (`VoiceStorage.listLocal()`). State : recorded en attente PC.
*
* Les ids étant horodatés côté local, il n'y a pas de collision possible
* avec les voix Kazeia. Si une voix locale a été ingérée par Kazeia, elle
* apparaîtra dans les deux sources le temps que le PC tool nettoie le
* dossier admin on déduplique par id en gardant la version Kazeia (ready
* prioritaire sur recorded).
*/
class VoicesRepository(context: Context) {
private val client = KazeiaVoicesClient(context.contentResolver)
private val localStorage = VoiceStorage(context)
fun list(): List<Voice> {
val kazeia = client.queryAll().map { row ->
Voice(
id = row.id,
name = row.name,
state = mapState(row.state),
isBuiltIn = false,
createdAt = row.createdAt,
durationSeconds = row.wavDurationSeconds.takeIf { it > 0f },
speakerNorm = null,
errorMessage = errorMessageFor(row),
deployedOnDevices = emptyList()
)
}
val kazeiaIds = kazeia.map { it.id }.toSet()
val local = localStorage.listLocal()
.filter { it.id !in kazeiaIds }
.map { rec ->
Voice(
id = rec.id,
name = rec.displayName,
state = VoiceState.RECORDED,
isBuiltIn = false,
createdAt = rec.createdAt,
durationSeconds = rec.durationSeconds.takeIf { it > 0f },
speakerNorm = null,
errorMessage = "Enregistrée sur la tablette (admin). " +
"Brancher au PC de gestion pour extraire les embeddings et activer la voix.",
deployedOnDevices = emptyList()
)
}
// Voix locales d'abord (plus récentes), puis voix Kazeia.
return local.sortedByDescending { it.createdAt } +
kazeia.sortedBy { it.name }
}
/** Suppression : essaie d'abord le delete local (voix admin), sinon
* délègue au ContentProvider Kazeia. */
fun delete(id: String): Boolean {
val localHit = localStorage.listLocal().firstOrNull { it.id == id }
if (localHit != null) {
return localStorage.delete(localHit)
}
return client.delete(id) > 0
}
private fun mapState(raw: String): VoiceState = when (raw) {
"ready" -> VoiceState.READY
"recorded" -> VoiceState.RECORDED
"processing" -> VoiceState.PROCESSING
else -> VoiceState.ERROR
}
private fun errorMessageFor(row: KazeiaVoicesClient.VoiceRow): String? = when (row.state) {
"recorded" -> "Audio enregistré, embeddings absents — brancher au PC pour finaliser."
"error" -> "Fichiers embeddings manquants et pas de WAV source. Voix non utilisable."
else -> null
}
}

View File

@ -0,0 +1,117 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
/**
* Client du ContentProvider Kazeia pour les URI `/config` et `/models`.
* Lit l'état courant + envoie un update qui déclenche RELOAD_CONFIG côté
* Kazeia patient.
*/
class KazeiaConfigClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val CONFIG_URI: Uri = Uri.parse("content://$AUTHORITY/config")
val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models")
}
data class Config(
val cascadeEnabled: Boolean,
val ttsEnabled: Boolean,
val speakerModelId: String,
val speakerSystemPrompt: String,
val speakerTemperature: Float,
val thinkerModelId: String,
val thinkerSystemPrompt: String,
val thinkerTemperature: Float,
val ragEnabled: Boolean = false,
val ragThreshold: Float = 0.82f,
val ragTopK: Int = 3
)
data class ModelInfo(
val id: String,
val displayName: String,
val ptePath: String,
val tokenizerPath: String,
val role: String, // SPEAKER | THINKER | BOTH
val sizeMb: Int,
val maxSeqLen: Int,
val fileExists: Boolean,
val notes: String
)
fun queryConfig(): Config? = runCatching {
resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
Config(
cascadeEnabled = c.getInt(c.getColumnIndexOrThrow("cascade_enabled")) == 1,
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")),
speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")),
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")),
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()
fun queryModels(): List<ModelInfo> = runCatching {
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<ModelInfo>()
while (c.moveToNext()) {
out += ModelInfo(
id = c.getString(c.getColumnIndexOrThrow("id")),
displayName = c.getString(c.getColumnIndexOrThrow("display_name")),
ptePath = c.getString(c.getColumnIndexOrThrow("pte_path")),
tokenizerPath = c.getString(c.getColumnIndexOrThrow("tokenizer_path")),
role = c.getString(c.getColumnIndexOrThrow("role")),
sizeMb = c.getInt(c.getColumnIndexOrThrow("size_mb")),
maxSeqLen = c.getInt(c.getColumnIndexOrThrow("max_seq_len")),
fileExists = c.getInt(c.getColumnIndexOrThrow("file_exists")) == 1,
notes = c.getString(c.getColumnIndexOrThrow("notes"))
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
/** Push une nouvelle config (champs non-nuls). Renvoie true si le
* provider a confirmé l'update (1 row). */
fun updateConfig(
cascadeEnabled: Boolean? = null,
ttsEnabled: Boolean? = null,
speakerModelId: String? = null,
speakerSystemPrompt: String? = null,
speakerTemperature: Float? = null,
thinkerModelId: String? = null,
thinkerSystemPrompt: String? = null,
thinkerTemperature: Float? = null,
ragEnabled: Boolean? = null,
ragThreshold: Float? = null,
ragTopK: Int? = null
): Boolean {
val v = ContentValues()
cascadeEnabled?.let { v.put("cascade_enabled", it) }
ttsEnabled?.let { v.put("tts_enabled", it) }
speakerModelId?.let { v.put("speaker_model_id", it) }
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
speakerTemperature?.let { v.put("speaker_temperature", it) }
thinkerModelId?.let { v.put("thinker_model_id", it) }
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
thinkerTemperature?.let { v.put("thinker_temperature", it) }
ragEnabled?.let { v.put("rag_enabled", it) }
ragThreshold?.let { v.put("rag_threshold", it) }
ragTopK?.let { v.put("rag_top_k", it) }
if (v.size() == 0) return true
return runCatching {
resolver.update(CONFIG_URI, v, null, null) > 0
}.getOrDefault(false)
}
}

View File

@ -0,0 +1,104 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.net.Uri
/**
* Lecture seule (+ delete) de l'historique conversation Kazeia via le
* ContentProvider `com.kazeia.provider`. La base SQLCipher reste côté
* patient ; on ne reçoit ici que des lignes déchiffrées à la demande.
*/
class KazeiaConversationsClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val ALL_SESSIONS_URI: Uri = Uri.parse("content://$AUTHORITY/conversations")
fun sessionsForProfileUri(profileId: String): Uri =
Uri.parse("content://$AUTHORITY/conversations/profile/$profileId")
fun turnsForSessionUri(sessionId: String): Uri =
Uri.parse("content://$AUTHORITY/conversations/session/$sessionId")
}
data class SessionRow(
val profileId: String,
val sessionId: String,
val startedAt: Long,
val endedAt: Long,
val turnCount: Int
)
data class TurnRow(
val id: Long,
val profileId: String,
val sessionId: String,
val timestamp: Long,
val role: String, // "PATIENT" | "KAZEIA"
val text: String,
val ttftMs: Long?,
val totalMs: Long?,
val voiceUsed: String?,
val modelUsed: String?
)
/** Sessions de tous les profils, ou d'un profil donné. Triées récent → ancien. */
fun sessions(profileId: String? = null): List<SessionRow> = runCatching {
val uri = profileId?.let { sessionsForProfileUri(it) } ?: ALL_SESSIONS_URI
resolver.query(uri, null, null, null, null)?.use { c ->
val out = mutableListOf<SessionRow>()
while (c.moveToNext()) {
out += SessionRow(
profileId = c.getString(c.getColumnIndexOrThrow("profile_id")),
sessionId = c.getString(c.getColumnIndexOrThrow("session_id")),
startedAt = c.getLong(c.getColumnIndexOrThrow("started_at")),
endedAt = c.getLong(c.getColumnIndexOrThrow("ended_at")),
turnCount = c.getInt(c.getColumnIndexOrThrow("turn_count"))
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
/** Tours d'une session, ordre chronologique. */
fun turns(sessionId: String): List<TurnRow> = runCatching {
resolver.query(turnsForSessionUri(sessionId), null, null, null, null)?.use { c ->
val out = mutableListOf<TurnRow>()
while (c.moveToNext()) {
out += TurnRow(
id = c.getLong(c.getColumnIndexOrThrow("id")),
profileId = c.getString(c.getColumnIndexOrThrow("profile_id")),
sessionId = c.getString(c.getColumnIndexOrThrow("session_id")),
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
role = c.getString(c.getColumnIndexOrThrow("role")),
text = c.getString(c.getColumnIndexOrThrow("text")),
ttftMs = c.colLongOrNull("ttft_ms"),
totalMs = c.colLongOrNull("total_ms"),
voiceUsed = c.colStringOrNull("voice_used"),
modelUsed = c.colStringOrNull("model_used")
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
/** Supprime une session entière. */
fun deleteSession(sessionId: String): Boolean = runCatching {
resolver.delete(turnsForSessionUri(sessionId), null, null) > 0
}.getOrDefault(false)
/** Supprime tout l'historique d'un profil (les tours, pas le profil). */
fun deleteProfileHistory(profileId: String): Boolean = runCatching {
resolver.delete(sessionsForProfileUri(profileId), null, null) > 0
}.getOrDefault(false)
}
private fun android.database.Cursor.colStringOrNull(col: String): String? {
val i = getColumnIndex(col)
if (i < 0 || isNull(i)) return null
return getString(i)
}
private fun android.database.Cursor.colLongOrNull(col: String): Long? {
val i = getColumnIndex(col)
if (i < 0 || isNull(i)) return null
return getLong(i)
}

View File

@ -0,0 +1,54 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
/**
* Lecture / écriture de la config WebDAV de distribution via le
* ContentProvider de l'app patient. Le mot de passe n'est jamais relu
* seul [DistConfigRow.hasPassword] indique s'il est défini.
*/
class KazeiaDistConfigClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val URI: Uri = Uri.parse("content://$AUTHORITY/dist_config")
}
data class DistConfigRow(
val base: String,
val user: String,
val hasPassword: Boolean,
/** Vrai si une config a été enregistrée (≠ défaut d'usine de l'APK). */
val isCustomized: Boolean
)
fun query(): DistConfigRow? = runCatching {
resolver.query(URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) null
else DistConfigRow(
base = c.getString(c.getColumnIndexOrThrow("webdav_base")),
user = c.getString(c.getColumnIndexOrThrow("webdav_user")),
hasPassword = c.getInt(c.getColumnIndexOrThrow("has_password")) == 1,
isCustomized = c.getInt(c.getColumnIndexOrThrow("is_customized")) == 1
)
}
}.getOrNull()
/** Enregistre la config. `pass` null/vide → mot de passe inchangé. */
fun update(base: String, user: String, pass: String?): Boolean {
val v = ContentValues().apply {
put("webdav_base", base)
put("webdav_user", user)
if (!pass.isNullOrEmpty()) put("webdav_pass", pass)
}
return runCatching { resolver.update(URI, v, null, null) > 0 }.getOrDefault(false)
}
/** Réinitialise au défaut d'usine (config compilée dans l'APK). */
fun reset(): Boolean {
val v = ContentValues().apply { put("reset", true) }
return runCatching { resolver.update(URI, v, null, null) > 0 }.getOrDefault(false)
}
}

View File

@ -0,0 +1,98 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
class KazeiaProfilesClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val PROFILES_URI: Uri = Uri.parse("content://$AUTHORITY/profiles")
val ACTIVE_URI: Uri = Uri.parse("content://$AUTHORITY/profiles/active")
fun itemUri(id: String): Uri = Uri.parse("content://$AUTHORITY/profiles/$id")
}
data class ProfileRow(
val id: String,
val displayName: String,
val avatarColor: Int,
val voiceId: String?,
val systemPromptOverride: String?,
val hasPin: Boolean,
val createdAt: Long,
val lastUsedAt: Long,
val notes: String?,
val isDefault: Boolean,
val turnCount: Int
)
fun queryAll(): List<ProfileRow> = runCatching {
resolver.query(PROFILES_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<ProfileRow>()
while (c.moveToNext()) {
out += ProfileRow(
id = c.getString(c.getColumnIndexOrThrow("id")),
displayName = c.getString(c.getColumnIndexOrThrow("display_name")),
avatarColor = c.getInt(c.getColumnIndexOrThrow("avatar_color")),
voiceId = c.colStringOrNull("voice_id"),
systemPromptOverride = c.colStringOrNull("system_prompt_override"),
hasPin = c.getInt(c.getColumnIndexOrThrow("has_pin")) == 1,
createdAt = c.getLong(c.getColumnIndexOrThrow("created_at")),
lastUsedAt = c.getLong(c.getColumnIndexOrThrow("last_used_at")),
notes = c.colStringOrNull("notes"),
isDefault = c.getInt(c.getColumnIndexOrThrow("is_default")) == 1,
turnCount = c.getInt(c.getColumnIndexOrThrow("turn_count"))
)
}
out.sortedByDescending { it.lastUsedAt }
} ?: emptyList()
}.getOrDefault(emptyList())
fun activeProfileId(): String? = runCatching {
resolver.query(ACTIVE_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) null
else c.colStringOrNull("active_profile_id")
}
}.getOrNull()
fun upsert(
id: String? = null,
displayName: String? = null,
voiceId: String? = null,
systemPromptOverride: String? = null,
pin: String? = null, // "" pour effacer ; null = pas de changement
notes: String? = null,
isDefault: Boolean? = null,
avatarColor: Int? = null
): Boolean {
val v = ContentValues()
id?.let { v.put("id", it) }
displayName?.let { v.put("display_name", it) }
voiceId?.let { v.put("voice_id", it) }
systemPromptOverride?.let { v.put("system_prompt_override", it) }
pin?.let { v.put("pin", it) }
notes?.let { v.put("notes", it) }
isDefault?.let { v.put("is_default", it) }
avatarColor?.let { v.put("avatar_color", it) }
val uri = id?.let { itemUri(it) } ?: PROFILES_URI
return runCatching { resolver.update(uri, v, null, null) > 0 }.getOrDefault(false)
}
fun delete(id: String): Boolean = runCatching {
resolver.delete(itemUri(id), null, null) > 0
}.getOrDefault(false)
fun setActive(id: String?): Boolean {
val v = ContentValues().apply {
put("active_profile_id", id ?: "")
}
return runCatching { resolver.update(ACTIVE_URI, v, null, null) > 0 }.getOrDefault(false)
}
}
private fun android.database.Cursor.colStringOrNull(col: String): String? {
val i = getColumnIndex(col)
if (i < 0 || isNull(i)) return null
return getString(i)
}

View File

@ -0,0 +1,94 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
/**
* Client du ContentProvider Kazeia pour l'URI `/rag` (corpus RAG).
* L'admin gère les documents BRUTS ; Kazeia patient les ()embedde sur le
* broadcast RELOAD_RAG déclenché par insert/delete.
*/
class KazeiaRagClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val RAG_URI: Uri = Uri.parse("content://$AUTHORITY/rag")
val RAG_STATUS_URI: Uri = Uri.parse("content://$AUTHORITY/rag_status")
val RAG_QUERY_URI: Uri = Uri.parse("content://$AUTHORITY/rag_query")
}
data class RagStatus(
val enabled: Boolean, val ready: Boolean, val model: String, val dim: Int,
val docCount: Int, val chunkCount: Int, val pending: Int
)
data class ScoredHit(val source: String, val score: Float, val text: String)
fun status(): RagStatus? = runCatching {
resolver.query(RAG_STATUS_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
RagStatus(
enabled = c.getInt(c.getColumnIndexOrThrow("enabled")) == 1,
ready = c.getInt(c.getColumnIndexOrThrow("ready")) == 1,
model = c.getString(c.getColumnIndexOrThrow("model")) ?: "",
dim = c.getInt(c.getColumnIndexOrThrow("dim")),
docCount = c.getInt(c.getColumnIndexOrThrow("doc_count")),
chunkCount = c.getInt(c.getColumnIndexOrThrow("chunk_count")),
pending = c.getInt(c.getColumnIndexOrThrow("pending"))
)
}
}.getOrNull()
/** Test de récupération : candidats classés avec scores (seuil 0 côté patient). */
fun test(query: String): List<ScoredHit> = runCatching {
resolver.query(RAG_QUERY_URI, null, null, arrayOf(query), null)?.use { c ->
val out = mutableListOf<ScoredHit>()
while (c.moveToNext()) out += ScoredHit(
source = c.getString(c.getColumnIndexOrThrow("source")),
score = c.getFloat(c.getColumnIndexOrThrow("score")),
text = c.getString(c.getColumnIndexOrThrow("text"))
)
out
} ?: emptyList()
}.getOrDefault(emptyList())
data class RagDoc(
val source: String,
val charCount: Int,
val chunkCount: Int, // 0 = pas encore indexé (RAG off ou embedder absent)
val updatedAt: Long
)
fun queryDocs(): List<RagDoc>? = runCatching {
resolver.query(RAG_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<RagDoc>()
while (c.moveToNext()) {
out += RagDoc(
source = c.getString(c.getColumnIndexOrThrow("source")),
charCount = c.getInt(c.getColumnIndexOrThrow("char_count")),
chunkCount = c.getInt(c.getColumnIndexOrThrow("chunk_count")),
updatedAt = c.getLong(c.getColumnIndexOrThrow("updated_at"))
)
}
out
}
}.getOrNull()
/** Texte brut d'un document (pour édition), ou null si absent/injoignable. */
fun docText(source: String): String? = runCatching {
resolver.query(Uri.withAppendedPath(RAG_URI, source), null, null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(c.getColumnIndexOrThrow("text")) else null
}
}.getOrNull()
/** Crée/met à jour un document (source = identifiant). Déclenche la réindexation. */
fun upsertDoc(source: String, text: String): Boolean = runCatching {
val v = ContentValues().apply { put("source", source); put("text", text) }
resolver.insert(RAG_URI, v) != null
}.getOrDefault(false)
fun deleteDoc(source: String): Boolean = runCatching {
resolver.delete(Uri.withAppendedPath(RAG_URI, source), null, null) > 0
}.getOrDefault(false)
}

View File

@ -0,0 +1,113 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.net.Uri
/**
* Client du ContentProvider exposé par Kazeia patient
* (`com.kazeia.provider`). Toute query échoue silencieusement si le
* provider n'est pas disponible (Kazeia non installé ou pas démarré)
* et retourne `null` pour que l'admin gère le fallback.
*/
class KazeiaTelemetryClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val STATE_URI: Uri = Uri.parse("content://$AUTHORITY/state")
val TURNS_URI: Uri = Uri.parse("content://$AUTHORITY/turns")
val CRASHES_URI: Uri = Uri.parse("content://$AUTHORITY/crashes")
}
data class KazeiaState(
val pid: Int,
val uptimeSeconds: Long,
val pssMb: Int,
val ionMb: Int,
val anonPagesMb: Int,
val memAvailableMb: Int
)
data class KazeiaTurn(
val timestamp: Long,
val inputKind: String,
val status: String,
val totalMs: Long,
val sttMs: Long?,
val llmTtftMs: Long?,
val llmGenMs: Long?,
val llmTokens: Int?,
val timeToFirstAudioMs: Long?,
val audioPlaybackMs: Long?
) {
fun tokensPerSecond(): Float? {
val ms = llmGenMs ?: return null
val tk = llmTokens ?: return null
if (ms <= 0 || tk <= 0) return null
return (tk * 1000f) / ms
}
}
data class KazeiaCrash(
val timestamp: Long,
val component: String,
val message: String?
)
fun queryState(): KazeiaState? = runCatching {
resolver.query(STATE_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
KazeiaState(
pid = c.getInt(c.getColumnIndexOrThrow("pid")),
uptimeSeconds = c.getLong(c.getColumnIndexOrThrow("uptime_seconds")),
pssMb = c.getInt(c.getColumnIndexOrThrow("pss_mb")),
ionMb = c.getInt(c.getColumnIndexOrThrow("ion_mb")),
anonPagesMb = c.getInt(c.getColumnIndexOrThrow("anon_pages_mb")),
memAvailableMb = c.getInt(c.getColumnIndexOrThrow("mem_available_mb"))
)
}
}.getOrNull()
fun queryTurns(): List<KazeiaTurn> = runCatching {
resolver.query(TURNS_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<KazeiaTurn>()
while (c.moveToNext()) {
out += KazeiaTurn(
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
inputKind = c.getString(c.getColumnIndexOrThrow("input_kind")),
status = c.getString(c.getColumnIndexOrThrow("status")),
totalMs = c.getLong(c.getColumnIndexOrThrow("total_ms")),
sttMs = c.nullableLong("stt_ms"),
llmTtftMs = c.nullableLong("llm_ttft_ms"),
llmGenMs = c.nullableLong("llm_gen_ms"),
llmTokens = c.nullableInt("llm_tokens"),
timeToFirstAudioMs = c.nullableLong("time_to_first_audio_ms"),
audioPlaybackMs = c.nullableLong("audio_playback_ms")
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
fun queryCrashes(): List<KazeiaCrash> = runCatching {
resolver.query(CRASHES_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<KazeiaCrash>()
while (c.moveToNext()) {
out += KazeiaCrash(
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
component = c.getString(c.getColumnIndexOrThrow("component")),
message = c.getString(c.getColumnIndexOrThrow("message"))
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
}
private fun android.database.Cursor.nullableLong(col: String): Long? {
val i = getColumnIndex(col); if (i < 0 || isNull(i)) return null
return getLong(i)
}
private fun android.database.Cursor.nullableInt(col: String): Int? {
val i = getColumnIndex(col); if (i < 0 || isNull(i)) return null
return getInt(i)
}

View File

@ -0,0 +1,60 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.net.Uri
/**
* Client du ContentProvider Kazeia pour la table `voices`.
* Lecture + suppression. L'extraction des embeddings (state recorded ready)
* est gérée côté PC tool ADB (workflow décrit dans
* project_kazeia_voice_workflow.md).
*/
class KazeiaVoicesClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
val VOICES_URI: Uri = Uri.parse("content://$AUTHORITY/voices")
fun voiceItemUri(id: String): Uri = Uri.parse("content://$AUTHORITY/voices/$id")
}
data class VoiceRow(
val id: String,
val name: String,
val state: String, // "ready" | "recorded" | "error"
val wavExists: Boolean,
val prefixExists: Boolean,
val suffixExists: Boolean,
val wavSizeBytes: Long,
val wavDurationSeconds: Float,
val wavPath: String,
val createdAt: Long
)
fun queryAll(): List<VoiceRow> = runCatching {
resolver.query(VOICES_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<VoiceRow>()
while (c.moveToNext()) {
out += VoiceRow(
id = c.getString(c.getColumnIndexOrThrow("id")),
name = c.getString(c.getColumnIndexOrThrow("name")),
state = c.getString(c.getColumnIndexOrThrow("state")),
wavExists = c.getInt(c.getColumnIndexOrThrow("wav_exists")) == 1,
prefixExists = c.getInt(c.getColumnIndexOrThrow("prefix_exists")) == 1,
suffixExists = c.getInt(c.getColumnIndexOrThrow("suffix_exists")) == 1,
wavSizeBytes = c.getLong(c.getColumnIndexOrThrow("wav_size_bytes")),
wavDurationSeconds = c.getFloat(c.getColumnIndexOrThrow("wav_duration_seconds")),
wavPath = c.getString(c.getColumnIndexOrThrow("wav_path")),
createdAt = c.getLong(c.getColumnIndexOrThrow("created_at"))
)
}
out
} ?: emptyList()
}.getOrDefault(emptyList())
/** Supprime une voix (wav + bin) côté Kazeia patient. Retourne le nombre
* de fichiers réellement effacés (0 si rien trouvé, ce qui est aussi
* retourné si Kazeia n'est pas démarré). */
fun delete(id: String): Int = runCatching {
resolver.delete(voiceItemUri(id), null, null)
}.getOrDefault(0)
}

View File

@ -0,0 +1,72 @@
package com.kazeia.admin.data.source
import java.io.File
/**
* Lecture directe des fichiers procfs du device (/proc/meminfo et /proc/stat)
* accessibles à toute app sans permission. Donne la mémoire système (incl.
* ION qui correspond 100% à Kazeia sur cette tablette puisque c'est le seul
* user du DSP HTP) et le CPU global. Aucune dépendance à Kazeia.
*/
class SystemTelemetrySource {
data class MemSnapshot(
val memTotalMb: Int,
val memAvailableMb: Int,
val ionTotalUsedMb: Int,
val anonPagesMb: Int,
val cachedMb: Int,
val swapFreeMb: Int
)
fun readMemory(): MemSnapshot {
val keys = mapOf<String, Long>(
"MemTotal" to 0, "MemAvailable" to 0, "IonTotalUsed" to 0,
"AnonPages" to 0, "Cached" to 0, "SwapFree" to 0
).toMutableMap()
try {
File("/proc/meminfo").bufferedReader().useLines { lines ->
for (line in lines) {
val key = keys.keys.firstOrNull { line.startsWith("$it:") } ?: continue
val kb = line.split(Regex("\\s+")).getOrNull(1)?.toLongOrNull() ?: 0L
keys[key] = kb
}
}
} catch (_: Exception) { /* fall through with zeros */ }
return MemSnapshot(
memTotalMb = (keys["MemTotal"]!! / 1024).toInt(),
memAvailableMb = (keys["MemAvailable"]!! / 1024).toInt(),
ionTotalUsedMb = (keys["IonTotalUsed"]!! / 1024).toInt(),
anonPagesMb = (keys["AnonPages"]!! / 1024).toInt(),
cachedMb = (keys["Cached"]!! / 1024).toInt(),
swapFreeMb = (keys["SwapFree"]!! / 1024).toInt()
)
}
/**
* CPU% calculé en diffant deux lectures de /proc/stat. Garde l'état
* précédent pour produire un %% relatif au laps de temps entre 2 appels.
*/
private var prevTotal = 0L
private var prevIdle = 0L
fun readCpuPercent(): Float = try {
val parts = File("/proc/stat").bufferedReader().readLine().trim().split(Regex("\\s+"))
if (parts.size < 8) 0f else {
val user = parts[1].toLong()
val nice = parts[2].toLong()
val system = parts[3].toLong()
val idle = parts[4].toLong()
val iowait = parts[5].toLong()
val irq = parts[6].toLong()
val softirq = parts[7].toLong()
val total = user + nice + system + idle + iowait + irq + softirq
val idleAll = idle + iowait
val dt = total - prevTotal
val di = idleAll - prevIdle
prevTotal = total
prevIdle = idleAll
if (dt > 0) ((dt - di).toFloat() / dt * 100f).coerceIn(0f, 100f) else 0f
}
} catch (_: Exception) { 0f }
}

View File

@ -0,0 +1,106 @@
package com.kazeia.admin.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Sparkline minimaliste : trace une polyline normalisée + optionnellement
* la ligne du dernier point en surbrillance. Pas d'axes ni de grid pour
* rester dense et lisible dans une rangée de KPI.
*/
@Composable
fun Sparkline(
values: List<Float>,
modifier: Modifier = Modifier,
height: Dp = 32.dp,
width: Dp = 120.dp,
lineColor: Color = MaterialTheme.colorScheme.primary,
showLastPointDot: Boolean = true
) {
if (values.isEmpty()) return
val min = values.min()
val max = values.max()
val range = (max - min).takeIf { it > 0f } ?: 1f
Canvas(modifier = modifier.width(width).height(height)) {
val w = size.width
val h = size.height
val stepX = if (values.size > 1) w / (values.size - 1).toFloat() else 0f
val path = Path()
values.forEachIndexed { i, v ->
val x = i * stepX
val y = h - ((v - min) / range) * h
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(
path = path,
color = lineColor,
style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)
)
if (showLastPointDot) {
val lastY = h - ((values.last() - min) / range) * h
val lastX = (values.size - 1) * stepX
drawCircle(lineColor, radius = 3.dp.toPx(), center = Offset(lastX, lastY))
}
}
}
/**
* Sparkline doublée : remplit l'aire sous la courbe avec une couleur translucide.
* Pour les séries mémoire le niveau absolu compte autant que la tendance.
*/
@Composable
fun FilledSparkline(
values: List<Int>,
modifier: Modifier = Modifier,
height: Dp = 36.dp,
width: Dp = 200.dp,
lineColor: Color = MaterialTheme.colorScheme.primary
) {
if (values.isEmpty()) return
val min = values.min()
val max = values.max()
val range = (max - min).takeIf { it > 0 } ?: 1
Canvas(modifier = modifier.width(width).height(height)) {
val w = size.width
val h = size.height
val stepX = if (values.size > 1) w / (values.size - 1).toFloat() else 0f
val linePath = Path()
val fillPath = Path()
values.forEachIndexed { i, v ->
val x = i * stepX
val y = h - ((v - min).toFloat() / range) * h
if (i == 0) {
linePath.moveTo(x, y)
fillPath.moveTo(x, h)
fillPath.lineTo(x, y)
} else {
linePath.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo((values.size - 1) * stepX, h)
fillPath.close()
drawPath(path = fillPath, color = lineColor.copy(alpha = 0.18f))
drawPath(
path = linePath,
color = lineColor,
style = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round)
)
}
}

View File

@ -0,0 +1,36 @@
package com.kazeia.admin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.data.model.VoiceState
import com.kazeia.admin.ui.theme.StateError
import com.kazeia.admin.ui.theme.StateProcessing
import com.kazeia.admin.ui.theme.StateReady
import com.kazeia.admin.ui.theme.StateRecorded
@Composable
fun StateBadge(state: VoiceState, modifier: Modifier = Modifier) {
val (color, label) = when (state) {
VoiceState.READY -> StateReady to "✓ Prête"
VoiceState.RECORDED -> StateRecorded to "⚠ À finaliser PC"
VoiceState.PROCESSING -> StateProcessing to "⟳ Extraction…"
VoiceState.ERROR -> StateError to "✗ Erreur"
}
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
modifier = modifier
.background(color, RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
)
}

View File

@ -0,0 +1,65 @@
package com.kazeia.admin.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HourglassEmpty
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
/**
* Écran "à venir" pour les rubriques pas encore implémentées (Profils,
* Prompts, RAG, Historique, Telemetry, System). Visuel unifié, message
* clair, navigation top-bar.
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun StubScreen(title: String) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Outlined.HourglassEmpty,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp)
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(R.string.stub_coming_soon),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@ -0,0 +1,357 @@
package com.kazeia.admin.ui.history
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
import com.kazeia.admin.data.repository.ConversationsRepository
import com.kazeia.admin.data.source.KazeiaConversationsClient
import com.kazeia.admin.ui.theme.StateError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val DAY_FMT = SimpleDateFormat("dd/MM/yyyy", Locale.FRENCH)
private val TIME_FMT = SimpleDateFormat("HH:mm", Locale.FRENCH)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen() {
val context = LocalContext.current
val repo = remember { ConversationsRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var sessions by remember { mutableStateOf<List<ConversationsRepository.Session>>(emptyList()) }
var profileFilter by remember { mutableStateOf<Pair<String, String>?>(null) } // (id, name)
var availableProfiles by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var filterExpanded by remember { mutableStateOf(false) }
var refreshTick by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf<ConversationsRepository.Session?>(null) }
LaunchedEffect(refreshTick, profileFilter) {
availableProfiles = withContext(Dispatchers.IO) { repo.profilesWithHistory() }
sessions = withContext(Dispatchers.IO) { repo.sessions(profileFilter?.first) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_history)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// Filtre profil
ExposedDropdownMenuBox(
expanded = filterExpanded,
onExpandedChange = { filterExpanded = !filterExpanded }
) {
OutlinedTextField(
value = profileFilter?.second ?: "Tous les profils",
onValueChange = {},
readOnly = true,
label = { Text("Profil") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterExpanded)
},
modifier = Modifier
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = filterExpanded,
onDismissRequest = { filterExpanded = false }
) {
DropdownMenuItem(
text = { Text("Tous les profils") },
onClick = { profileFilter = null; filterExpanded = false }
)
availableProfiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.second) },
onClick = { profileFilter = p; filterExpanded = false }
)
}
}
}
Spacer(Modifier.height(12.dp))
if (sessions.isEmpty()) {
EmptyState()
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(sessions, key = { it.sessionId }) { s ->
SessionCard(
session = s,
loadTurns = { repo.turns(s.sessionId) },
onExport = {
scope.launch {
val file = withContext(Dispatchers.IO) { repo.exportSession(s) }
snackbar.showSnackbar(
if (file != null) "Exporté : ${file.name}"
else "Échec de l'export.",
duration = SnackbarDuration.Long
)
}
},
onDelete = { pendingDelete = s }
)
}
item { Spacer(Modifier.height(32.dp)) }
}
}
}
}
pendingDelete?.let { s ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Supprimer cette session ?") },
text = {
Text("La session du ${DAY_FMT.format(Date(s.startedAt))} " +
"(${s.turnCount} tour(s), profil « ${s.profileName} ») " +
"sera effacée définitivement. Pensez à l'exporter avant si nécessaire.")
},
confirmButton = {
Button(
onClick = {
val ok = repo.deleteSession(s.sessionId)
pendingDelete = null
refreshTick++
scope.launch {
snackbar.showSnackbar(
if (ok) "Session supprimée." else "Échec suppression.",
duration = SnackbarDuration.Short
)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = StateError, contentColor = Color.White
)
) { Text("Supprimer") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("Annuler") }
}
)
}
}
@Composable
private fun SessionCard(
session: ConversationsRepository.Session,
loadTurns: () -> List<KazeiaConversationsClient.TurnRow>,
onExport: () -> Unit,
onDelete: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var turns by remember { mutableStateOf<List<KazeiaConversationsClient.TurnRow>>(emptyList()) }
LaunchedEffect(expanded) {
if (expanded && turns.isEmpty()) {
turns = withContext(Dispatchers.IO) { loadTurns() }
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(
session.profileName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.size(2.dp))
Text(
"${DAY_FMT.format(Date(session.startedAt))} · " +
"${TIME_FMT.format(Date(session.startedAt))}" +
"${TIME_FMT.format(Date(session.endedAt))} · " +
"${session.turnCount} tour(s)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = if (expanded) "Réduire" else "Développer"
)
}
if (expanded) {
Spacer(Modifier.height(12.dp))
if (turns.isEmpty()) {
Text(
"Chargement…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
turns.forEach { t -> TurnBubble(t); Spacer(Modifier.height(8.dp)) }
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = onExport) {
Icon(Icons.Outlined.FileDownload, null, Modifier.size(18.dp))
Spacer(Modifier.size(4.dp))
Text("Exporter JSON")
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, "Supprimer", tint = StateError)
}
}
}
}
}
}
@Composable
private fun TurnBubble(turn: KazeiaConversationsClient.TurnRow) {
val isKazeia = turn.role == "KAZEIA"
val bg = if (isKazeia) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
Column(
modifier = Modifier
.fillMaxWidth()
.background(bg, RoundedCornerShape(10.dp))
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (isKazeia) "Kazeia" else "Patient",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.weight(1f))
Text(
TIME_FMT.format(Date(turn.timestamp)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
Text(turn.text, style = MaterialTheme.typography.bodyLarge)
if (isKazeia && (turn.ttftMs != null || turn.modelUsed != null)) {
Spacer(Modifier.height(4.dp))
val meta = buildString {
turn.modelUsed?.let { append(it) }
turn.voiceUsed?.let {
if (isNotEmpty()) append(" · ")
append("voix $it")
}
turn.ttftMs?.let {
if (isNotEmpty()) append(" · ")
append("TTFT ${it}ms")
}
turn.totalMs?.let {
if (isNotEmpty()) append(" · ")
append("LLM ${it}ms")
}
}
Text(
meta,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun EmptyState() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Aucune conversation enregistrée",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
"Les échanges patient ↔ Kazeia apparaissent ici dès qu'un " +
"profil (non-invité) est utilisé.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@ -0,0 +1,177 @@
package com.kazeia.admin.ui.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kazeia.admin.R
// MVP placeholder: hardcoded PIN. Phase 1 will use Android Keystore-backed hash.
private const val DEFAULT_PIN = "123456"
private const val PIN_LENGTH = 6
private const val MAX_ATTEMPTS = 3
@Composable
fun LoginScreen(
onAuthenticated: () -> Unit
) {
var pin by remember { mutableStateOf("") }
var attempts by remember { mutableStateOf(0) }
var locked by remember { mutableStateOf(false) }
val attemptsLeft = MAX_ATTEMPTS - attempts
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.login_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
if (locked) stringResource(R.string.login_locked)
else stringResource(R.string.login_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(48.dp))
// Pin dots
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
repeat(PIN_LENGTH) { i ->
val filled = pin.length > i
Box(
Modifier
.size(20.dp)
.background(
color = if (filled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
)
}
}
if (attempts > 0 && !locked) {
Text(
stringResource(R.string.login_error_attempts, attemptsLeft),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(48.dp))
PinPad(
enabled = !locked,
onDigit = { digit ->
if (pin.length < PIN_LENGTH) {
pin = pin + digit
if (pin.length == PIN_LENGTH) {
if (pin == DEFAULT_PIN) {
onAuthenticated()
} else {
attempts++
pin = ""
if (attempts >= MAX_ATTEMPTS) locked = true
}
}
}
},
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) }
)
}
}
}
@Composable
private fun PinPad(
enabled: Boolean,
onDigit: (String) -> Unit,
onBackspace: () -> Unit
) {
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", "")
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
rows.forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { label ->
PinButton(
label = label,
enabled = enabled && label.isNotEmpty(),
onClick = {
if (label == "") onBackspace() else onDigit(label)
}
)
}
}
}
}
}
@Composable
private fun PinButton(label: String, enabled: Boolean, onClick: () -> Unit) {
Surface(
modifier = Modifier.size(84.dp),
shape = CircleShape,
color = if (enabled && label.isNotEmpty())
MaterialTheme.colorScheme.surfaceVariant
else Color.Transparent
) {
if (label.isNotEmpty()) {
TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxSize()) {
Text(label, fontSize = 30.sp, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface)
}
}
}
}

View File

@ -0,0 +1,34 @@
package com.kazeia.admin.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.People
import androidx.compose.material.icons.outlined.RecordVoiceOver
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Destinations principales de l'admin app.
* L'ordre ici détermine l'ordre dans le rail de navigation.
*/
enum class AdminDestination(
val route: String,
val labelRes: Int,
val icon: ImageVector
) {
Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver),
Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People),
Prompts( "prompts", com.kazeia.admin.R.string.nav_prompts, Icons.AutoMirrored.Outlined.Article),
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
companion object {
const val LoginRoute = "login"
}
}

View File

@ -0,0 +1,438 @@
package com.kazeia.admin.ui.profiles
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kazeia.admin.R
import com.kazeia.admin.data.repository.ProfilesRepository
import com.kazeia.admin.data.repository.VoicesRepository
import com.kazeia.admin.data.source.KazeiaProfilesClient
import com.kazeia.admin.ui.theme.StateError
import com.kazeia.admin.ui.theme.StateReady
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
private val DATE_FMT = SimpleDateFormat("dd/MM/yyyy", Locale.FRENCH)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen() {
val context = LocalContext.current
val repo = remember { ProfilesRepository(context) }
val voicesRepo = remember { VoicesRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var profiles by remember { mutableStateOf<List<KazeiaProfilesClient.ProfileRow>>(emptyList()) }
var refreshTick by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf<KazeiaProfilesClient.ProfileRow?>(null) }
var showCreate by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<KazeiaProfilesClient.ProfileRow?>(null) }
LaunchedEffect(refreshTick) {
profiles = repo.list()
}
val availableVoiceIds = remember(refreshTick) {
voicesRepo.list().filter { it.state == com.kazeia.admin.data.model.VoiceState.READY }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_profiles)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) },
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showCreate = true },
icon = { Icon(Icons.Outlined.PersonAdd, null) },
text = { Text("+ Nouveau profil") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
) { padding ->
if (profiles.isEmpty()) {
EmptyState(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(profiles, key = { it.id }) { p ->
ProfileCard(
row = p,
onEdit = { editing = p },
onDelete = { pendingDelete = p },
onSetActive = {
val ok = repo.setActive(p.id)
scope.launch {
snackbar.showSnackbar(
if (ok) "« ${p.displayName} » est maintenant le profil actif."
else "Échec : Kazeia injoignable.",
duration = SnackbarDuration.Short
)
}
refreshTick++
}
)
}
item { Spacer(Modifier.height(96.dp)) }
}
}
}
// Création
if (showCreate) {
ProfileEditDialog(
initial = null,
availableVoices = availableVoiceIds.map { it.id to it.name },
onDismiss = { showCreate = false },
onSave = { name, voiceId, pin, isDefault, notes ->
val id = "p_" + UUID.randomUUID().toString().take(8)
val ok = repo.create(id, name, voiceId, pin.ifBlank { null }, isDefault, notes.ifBlank { null })
showCreate = false
refreshTick++
scope.launch {
snackbar.showSnackbar(
if (ok) "Profil « $name » créé." else "Échec création.",
duration = SnackbarDuration.Short
)
}
}
)
}
// Édition
editing?.let { row ->
ProfileEditDialog(
initial = row,
availableVoices = availableVoiceIds.map { it.id to it.name },
onDismiss = { editing = null },
onSave = { name, voiceId, pin, isDefault, notes ->
if (name != row.displayName) repo.rename(row.id, name)
if (voiceId != row.voiceId) repo.setVoice(row.id, voiceId)
// pin : "" efface, blank = pas de changement
if (pin.isNotBlank() || (pin.isBlank() && row.hasPin)) {
// si l'user a tapé un nouveau PIN OU vide le champ
repo.setPin(row.id, pin.ifBlank { null })
}
if (isDefault && !row.isDefault) repo.setDefault(row.id)
editing = null
refreshTick++
scope.launch {
snackbar.showSnackbar("Modifications enregistrées.",
duration = SnackbarDuration.Short)
}
}
)
}
// Confirmation delete
pendingDelete?.let { row ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Supprimer le profil ?") },
text = {
Column {
Text("« ${row.displayName} » et **${row.turnCount} tour(s)** de conversation associés " +
"seront effacés définitivement de la tablette.")
Spacer(Modifier.height(8.dp))
Text("Pensez à exporter l'historique avant suppression si nécessaire.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
confirmButton = {
Button(
onClick = {
val ok = repo.delete(row.id)
pendingDelete = null
refreshTick++
scope.launch {
snackbar.showSnackbar(
if (ok) "Profil supprimé." else "Échec suppression.",
duration = SnackbarDuration.Short
)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = StateError,
contentColor = Color.White
)
) { Text("Supprimer") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("Annuler") }
}
)
}
}
@Composable
private fun ProfileCard(
row: KazeiaProfilesClient.ProfileRow,
onEdit: () -> Unit,
onDelete: () -> Unit,
onSetActive: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AvatarBubble(name = row.displayName, color = Color(row.avatarColor))
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(row.displayName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
if (row.isDefault) {
Spacer(Modifier.size(8.dp))
Badge("défaut", StateReady)
}
if (row.hasPin) {
Spacer(Modifier.size(6.dp))
Badge("🔒 PIN", MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.size(2.dp))
val sub = buildString {
row.voiceId?.let { append("voix : $it") } ?: append("voix par défaut")
append(" · ${row.turnCount} tour(s)")
append(" · créé ${DATE_FMT.format(Date(row.createdAt))}")
}
Text(sub,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onSetActive) { Text("Définir actif") }
TextButton(onClick = onEdit) { Text("Modifier") }
Spacer(Modifier.weight(1f))
TextButton(
onClick = onDelete,
colors = ButtonDefaults.textButtonColors(contentColor = StateError)
) { Text("Supprimer") }
}
}
}
}
@Composable
private fun AvatarBubble(name: String, color: Color) {
val letter = name.trim().firstOrNull()?.uppercase() ?: "?"
Box(
modifier = Modifier
.size(56.dp)
.background(color, CircleShape),
contentAlignment = Alignment.Center
) {
Text(letter, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun Badge(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = Color.White,
modifier = Modifier
.background(color, RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfileEditDialog(
initial: KazeiaProfilesClient.ProfileRow?,
availableVoices: List<Pair<String, String>>, // (id, displayName)
onDismiss: () -> Unit,
onSave: (name: String, voiceId: String?, pin: String, isDefault: Boolean, notes: String) -> Unit
) {
var name by remember { mutableStateOf(initial?.displayName ?: "") }
var voiceId by remember { mutableStateOf(initial?.voiceId) }
var pin by remember { mutableStateOf("") } // toujours blank, on ne montre pas le hash
var isDefault by remember { mutableStateOf(initial?.isDefault ?: false) }
var notes by remember { mutableStateOf(initial?.notes ?: "") }
var voiceExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (initial == null) "Nouveau profil" else "Modifier ${initial.displayName}") },
text = {
Column {
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text("Nom du patient *") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
ExposedDropdownMenuBox(
expanded = voiceExpanded,
onExpandedChange = { voiceExpanded = !voiceExpanded }
) {
OutlinedTextField(
value = voiceId?.let { id ->
availableVoices.firstOrNull { it.first == id }?.second ?: id
} ?: "(défaut Kazeia)",
onValueChange = {},
readOnly = true,
label = { Text("Voix Kazeia") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = voiceExpanded) },
modifier = Modifier
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = voiceExpanded,
onDismissRequest = { voiceExpanded = false }
) {
DropdownMenuItem(
text = { Text("(défaut Kazeia)") },
onClick = { voiceId = null; voiceExpanded = false }
)
availableVoices.forEach { (id, n) ->
DropdownMenuItem(
text = { Text(n) },
onClick = { voiceId = id; voiceExpanded = false }
)
}
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pin, onValueChange = { pin = it.filter { c -> c.isDigit() }.take(6) },
label = { Text("PIN patient (4-6 chiffres, laisser vide = pas de PIN)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (initial?.hasPin == true && pin.isBlank()) {
Text(
"Un PIN est déjà défini. Laissez vide pour le conserver, ou tapez « 0 » suivi de Effacer pour le retirer.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = notes, onValueChange = { notes = it },
label = { Text("Notes cliniques (optionnel)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
Text("Profil par défaut (actif au démarrage de Kazeia)")
}
}
},
confirmButton = {
Button(
onClick = { onSave(name.trim(), voiceId, pin, isDefault, notes) },
enabled = name.trim().isNotEmpty()
) { Text(if (initial == null) "Créer" else "Enregistrer") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Annuler") }
}
)
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Aucun profil patient",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
Text("Créez un profil avec le bouton ci-dessous. " +
"Chaque profil aura son historique de conversation chiffré et son PIN optionnel.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

View File

@ -0,0 +1,243 @@
package com.kazeia.admin.ui.prompts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
import com.kazeia.admin.data.repository.ConfigRepository
import com.kazeia.admin.data.source.KazeiaConfigClient
import com.kazeia.admin.ui.theme.StateError
import kotlinx.coroutines.launch
private enum class PromptTab(val title: String) {
Speaker("Speaker (Kazeia)"),
Thinker("Thinker (analyse)")
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun PromptsScreen() {
val context = LocalContext.current
val repo = remember { ConfigRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
var selectedTab by remember { mutableIntStateOf(0) }
// Local editing state per tab
var speakerDraft by remember { mutableStateOf("") }
var thinkerDraft by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
config = repo.config()
speakerDraft = config?.speakerSystemPrompt ?: ""
thinkerDraft = config?.thinkerSystemPrompt ?: ""
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_prompts)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
if (config == null) {
OfflineCard(modifier = Modifier.padding(padding))
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
PrimaryTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.background
) {
PromptTab.entries.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(tab.title) }
)
}
}
val activeTab = PromptTab.entries[selectedTab]
val draft = if (activeTab == PromptTab.Speaker) speakerDraft else thinkerDraft
val original = if (activeTab == PromptTab.Speaker)
config?.speakerSystemPrompt ?: ""
else
config?.thinkerSystemPrompt ?: ""
val dirty = draft != original
val isThinker = activeTab == PromptTab.Thinker
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isThinker && config?.cascadeEnabled == false) {
CascadeOffWarning()
}
ContextCard(activeTab)
OutlinedTextField(
value = draft,
onValueChange = {
if (activeTab == PromptTab.Speaker) speakerDraft = it
else thinkerDraft = it
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 240.dp),
label = { Text("Prompt système ${activeTab.title}") },
supportingText = { Text("${draft.length} caractères") }
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
val newPrompt = if (activeTab == PromptTab.Speaker)
ConfigRepository.DEFAULT_SPEAKER_PROMPT
else ConfigRepository.DEFAULT_THINKER_PROMPT
if (activeTab == PromptTab.Speaker) speakerDraft = newPrompt
else thinkerDraft = newPrompt
}) { Text("Restaurer le défaut") }
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
if (activeTab == PromptTab.Speaker)
speakerDraft = config?.speakerSystemPrompt ?: ""
else
thinkerDraft = config?.thinkerSystemPrompt ?: ""
},
enabled = dirty
) { Text("Annuler") }
Button(
onClick = {
val ok = if (activeTab == PromptTab.Speaker)
repo.updateSpeakerPrompt(speakerDraft)
else
repo.updateThinkerPrompt(thinkerDraft)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Prompt enregistré — prise en compte au prochain tour."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
},
enabled = dirty && draft.length >= 20
) { Text("Enregistrer") }
}
}
}
}
}
@Composable
private fun ContextCard(tab: PromptTab) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
val text = when (tab) {
PromptTab.Speaker -> "Le **Speaker** est le LLM qui parle au patient. Son prompt définit le ton, le format des réponses, et les contraintes thérapeutiques (longueur, validation émotionnelle, etc.). Modifié à chaud — pris en compte au tour suivant, sans recharger le modèle."
PromptTab.Thinker -> "Le **Thinker** est un LLM léger qui produit une analyse clinique structurée du message patient (émotion / besoin / approche / axe). Ses bullets sont injectées dans le system prompt du Speaker. Utilisé uniquement quand la cascade est activée dans l'écran Système."
}
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
private fun CascadeOffWarning() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = StateError.copy(alpha = 0.1f)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"⚠ La cascade est désactivée dans l'écran Système — le prompt Thinker n'est pas utilisé actuellement.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun OfflineCard(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize().padding(32.dp), contentAlignment = androidx.compose.ui.Alignment.Center) {
Card(
colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))
) {
Text(
"Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@ -0,0 +1,320 @@
package com.kazeia.admin.ui.rag
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
import com.kazeia.admin.data.repository.RagRepository
import com.kazeia.admin.data.source.KazeiaConfigClient
import com.kazeia.admin.data.source.KazeiaRagClient
import com.kazeia.admin.ui.theme.StateError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class Editing(val source: String, val text: String, val isNew: Boolean)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RagScreen() {
val context = LocalContext.current
val repo = remember { RagRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
var status by remember { mutableStateOf<KazeiaRagClient.RagStatus?>(null) }
var docs by remember { mutableStateOf<List<KazeiaRagClient.RagDoc>?>(null) }
var editing by remember { mutableStateOf<Editing?>(null) }
var offline by remember { mutableStateOf(false) }
var testQuery by remember { mutableStateOf("") }
var testResults by remember { mutableStateOf<List<KazeiaRagClient.ScoredHit>?>(null) }
suspend fun reload() = withContext(Dispatchers.IO) {
val c = repo.config()
offline = c == null
config = c
status = repo.status()
docs = repo.docs()
}
LaunchedEffect(Unit) { reload() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_rag)) },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
)
},
floatingActionButton = {
if (editing == null && !offline) {
FloatingActionButton(onClick = { editing = Editing("", "", true) }) {
Icon(Icons.Outlined.Add, contentDescription = "Ajouter un document")
}
}
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
val mod = Modifier.fillMaxSize().padding(padding)
when {
offline -> OfflineCard(mod)
editing != null -> DocEditor(
editing = editing!!, modifier = mod,
onChange = { editing = it },
onCancel = { editing = null },
onSave = { e ->
scope.launch {
val ok = withContext(Dispatchers.IO) { repo.save(e.source.trim(), e.text) }
if (ok) { editing = null; delay(400); reload() }
snackbar.showSnackbar(if (ok) "Document enregistré — réindexation en cours." else "Échec : Kazeia injoignable.", duration = SnackbarDuration.Short)
}
},
onDelete = { src ->
scope.launch {
val ok = withContext(Dispatchers.IO) { repo.delete(src) }
if (ok) { editing = null; delay(400); reload() }
snackbar.showSnackbar(if (ok) "Document supprimé." else "Échec suppression.", duration = SnackbarDuration.Short)
}
}
)
else -> Dashboard(
config = config, status = status, docs = docs ?: emptyList(),
testQuery = testQuery, testResults = testResults, modifier = mod,
onToggleEnabled = { on ->
scope.launch {
withContext(Dispatchers.IO) { repo.setEnabled(on) }
snackbar.showSnackbar(if (on) "RAG activé — chargement de l'embedder…" else "RAG désactivé.", duration = SnackbarDuration.Short)
delay(1500); reload()
}
},
onThreshold = { t -> scope.launch { withContext(Dispatchers.IO) { repo.setThreshold(t) }; reload() } },
onTopK = { k -> scope.launch { withContext(Dispatchers.IO) { repo.setTopK(k) }; reload() } },
onTestQueryChange = { testQuery = it },
onRunTest = {
scope.launch {
val r = withContext(Dispatchers.IO) { repo.test(testQuery.trim()) }
testResults = r
if (r.isEmpty()) snackbar.showSnackbar("Aucun résultat (RAG inactif ou corpus vide).", duration = SnackbarDuration.Short)
}
},
onOpenDoc = { src ->
scope.launch {
val t = withContext(Dispatchers.IO) { repo.docText(src) } ?: ""
editing = Editing(src, t, false)
}
}
)
}
}
}
@Composable
private fun Dashboard(
config: KazeiaConfigClient.Config?,
status: KazeiaRagClient.RagStatus?,
docs: List<KazeiaRagClient.RagDoc>,
testQuery: String,
testResults: List<KazeiaRagClient.ScoredHit>?,
modifier: Modifier,
onToggleEnabled: (Boolean) -> Unit,
onThreshold: (Float) -> Unit,
onTopK: (Int) -> Unit,
onTestQueryChange: (String) -> Unit,
onRunTest: () -> Unit,
onOpenDoc: (String) -> Unit
) {
val threshold = config?.ragThreshold ?: 0.82f
LazyColumn(
modifier = modifier,
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item { StatusCard(config, status, onToggleEnabled) }
item { SettingsCard(config, onThreshold, onTopK) }
item { TestCard(testQuery, testResults, threshold, onTestQueryChange, onRunTest) }
item {
Text("Documents du corpus (${docs.size})",
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp))
}
if (docs.isEmpty()) {
item { Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
items(docs, key = { it.source }) { d ->
Card(modifier = Modifier.fillMaxWidth().clickable { onOpenDoc(d.source) }, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.padding(14.dp)) {
Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall)
val st = if (d.chunkCount > 0) "${d.chunkCount} fragment(s)" else "⚠ à indexer"
Text("${d.charCount} caractères · $st",
style = MaterialTheme.typography.bodySmall,
color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError)
}
}
}
}
}
}
@Composable
private fun StatusCard(config: KazeiaConfigClient.Config?, status: KazeiaRagClient.RagStatus?, onToggle: (Boolean) -> Unit) {
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("RAG (mémoire documentaire)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Switch(checked = config?.ragEnabled == true, onCheckedChange = onToggle)
}
val ready = status?.ready == true
val badge = when {
config?.ragEnabled != true -> "désactivé"
ready -> "actif · embedder prêt"
else -> "activé · embedder NON prêt (modèle absent ?)"
}
Text(badge, style = MaterialTheme.typography.bodyMedium,
color = if (config?.ragEnabled == true && !ready) StateError else MaterialTheme.colorScheme.onSurfaceVariant)
status?.let { s ->
Text("Modèle : ${s.model.ifEmpty { "—" }} (dim ${s.dim}) · ${s.docCount} doc · ${s.chunkCount} fragments" +
if (s.pending > 0) " · ${s.pending} à indexer" else "",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun SettingsCard(config: KazeiaConfigClient.Config?, onThreshold: (Float) -> Unit, onTopK: (Int) -> Unit) {
if (config == null) return
var localT by remember(config.ragThreshold) { mutableStateOf(config.ragThreshold) }
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Réglages de récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text("Seuil de similarité : ${"%.2f".format(localT)}", style = MaterialTheme.typography.bodyMedium)
Slider(value = localT, onValueChange = { localT = it }, valueRange = 0.5f..0.95f,
onValueChangeFinished = { onThreshold(localT) })
Text("Plus haut = plus strict (moins de contexte injecté, mais plus pertinent).",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Text("Nombre max de fragments : ${config.ragTopK}", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = { if (config.ragTopK > 1) onTopK(config.ragTopK - 1) }) { Text("", style = MaterialTheme.typography.titleLarge) }
IconButton(onClick = { if (config.ragTopK < 8) onTopK(config.ragTopK + 1) }) { Text("+", style = MaterialTheme.typography.titleLarge) }
}
}
}
}
@Composable
private fun TestCard(
query: String, results: List<KazeiaRagClient.ScoredHit>?, threshold: Float,
onChange: (String) -> Unit, onRun: () -> Unit
) {
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Tester la récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
OutlinedTextField(value = query, onValueChange = onChange, modifier = Modifier.fillMaxWidth(),
label = { Text("Message patient simulé") }, singleLine = false)
Button(onClick = onRun, enabled = query.trim().length >= 3) { Text("Tester") }
results?.let { res ->
if (res.isEmpty()) {
Text("Aucun candidat.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Text("Candidats (✓ = injecté au seuil ${"%.2f".format(threshold)}) :", style = MaterialTheme.typography.bodySmall)
res.forEach { h ->
val pass = h.score >= threshold
Column(Modifier.padding(vertical = 2.dp)) {
Text("${if (pass) "✓" else "✗"} ${h.source} · ${"%.3f".format(h.score)}",
style = MaterialTheme.typography.bodyMedium,
color = if (pass) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (pass) FontWeight.SemiBold else FontWeight.Normal)
Text(h.text.take(120), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
}
@Composable
private fun DocEditor(
editing: Editing, modifier: Modifier,
onChange: (Editing) -> Unit, onCancel: () -> Unit, onSave: (Editing) -> Unit, onDelete: (String) -> Unit
) {
Column(modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(value = editing.source, onValueChange = { onChange(editing.copy(source = it)) },
modifier = Modifier.fillMaxWidth(), label = { Text("Titre / source (identifiant unique)") },
enabled = editing.isNew, singleLine = true)
OutlinedTextField(value = editing.text, onValueChange = { onChange(editing.copy(text = it)) },
modifier = Modifier.fillMaxWidth().heightIn(min = 280.dp), label = { Text("Contenu du document") },
supportingText = { Text("${editing.text.length} caractères") })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (!editing.isNew) {
IconButton(onClick = { onDelete(editing.source) }) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = StateError)
}
}
Spacer(Modifier.weight(1f))
TextButton(onClick = onCancel) { Text("Annuler") }
Button(onClick = { onSave(editing) },
enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10) { Text("Enregistrer") }
}
}
}
@Composable
private fun OfflineCard(modifier: Modifier) {
Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) {
Card(colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))) {
Text("Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.",
Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
}
}

View File

@ -0,0 +1,560 @@
package com.kazeia.admin.ui.system
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
import com.kazeia.admin.data.repository.ConfigRepository
import com.kazeia.admin.data.source.KazeiaConfigClient
import com.kazeia.admin.data.source.KazeiaDistConfigClient
import com.kazeia.admin.ui.theme.StateError
import com.kazeia.admin.ui.theme.StateReady
import com.kazeia.admin.ui.theme.StateRecorded
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SystemScreen() {
val context = LocalContext.current
val repo = remember { ConfigRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
val distClient = remember { KazeiaDistConfigClient(context.contentResolver) }
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
var models by remember { mutableStateOf<List<KazeiaConfigClient.ModelInfo>>(emptyList()) }
var distRow by remember { mutableStateOf<KazeiaDistConfigClient.DistConfigRow?>(null) }
LaunchedEffect(Unit) {
config = repo.config()
models = repo.models()
distRow = distClient.query()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_system)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
val cfg = config
if (cfg == null) {
OfflineNotice(Modifier.padding(padding))
return@Scaffold
}
val speakerModels = models.filter {
it.role == "SPEAKER" || it.role == "BOTH"
}
val thinkerModels = models.filter {
it.role == "THINKER" || it.role == "BOTH"
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.height(8.dp))
CascadeCard(
enabled = cfg.cascadeEnabled,
onToggle = { newValue ->
val ok = repo.updateCascade(newValue)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Cascade ${if (newValue) "activée" else "désactivée"} — appliquée immédiatement."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
}
)
TtsCard(
enabled = cfg.ttsEnabled,
onToggle = { newValue ->
val ok = repo.updateTts(newValue)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Synthèse vocale ${if (newValue) "activée" else "désactivée"} — appliquée au prochain message."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
}
)
ModelSection(
title = "Speaker",
subtitle = "LLM qui répond au patient",
models = speakerModels,
currentId = cfg.speakerModelId,
onPick = { picked ->
val ok = repo.updateSpeakerModel(picked.id)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Speaker → ${picked.displayName}. Rechargement Kazeia (~5s)…"
else "Échec du switch Speaker.",
duration = SnackbarDuration.Long
)
}
}
)
val thinkerDimmed = !cfg.cascadeEnabled
ModelSection(
title = "Thinker",
subtitle = if (thinkerDimmed)
"LLM d'analyse — inactif tant que la cascade est désactivée"
else "LLM d'analyse — produit les bullets clinique",
models = thinkerModels,
currentId = cfg.thinkerModelId,
dimmed = thinkerDimmed,
onPick = { picked ->
val ok = repo.updateThinkerModel(picked.id)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Thinker → ${picked.displayName}."
else "Échec du switch Thinker.",
duration = SnackbarDuration.Short
)
}
}
)
RamWarningCard(cfg, speakerModels, thinkerModels)
distRow?.let { row ->
WebDavConfigCard(
row = row,
onSave = { base, user, pass ->
val ok = distClient.update(base, user, pass)
if (ok) distRow = distClient.query()
scope.launch {
snackbar.showSnackbar(
if (ok) "Serveur de mise à jour enregistré."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
},
onReset = {
val ok = distClient.reset()
if (ok) distRow = distClient.query()
scope.launch {
snackbar.showSnackbar(
if (ok) "Config réinitialisée au défaut d'usine."
else "Échec de la réinitialisation.",
duration = SnackbarDuration.Short
)
}
}
)
}
Spacer(Modifier.height(16.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WebDavConfigCard(
row: KazeiaDistConfigClient.DistConfigRow,
onSave: (base: String, user: String, pass: String?) -> Unit,
onReset: () -> Unit
) {
var base by remember(row) { mutableStateOf(row.base) }
var user by remember(row) { mutableStateOf(row.user) }
var pass by remember(row) { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
"Serveur de mise à jour (WebDAV)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(2.dp))
Text(
if (row.isCustomized) "Configuration personnalisée."
else "Configuration d'usine (compilée dans l'APK).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = base, onValueChange = { base = it },
label = { Text("URL WebDAV") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = user, onValueChange = { user = it },
label = { Text("Identifiant") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pass, onValueChange = { pass = it },
label = {
Text(
if (row.hasPassword) "Mot de passe (vide = inchangé)"
else "Mot de passe"
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { onSave(base.trim(), user.trim(), pass.ifBlank { null }) },
enabled = base.isNotBlank() && user.isNotBlank()
) { Text("Enregistrer") }
Spacer(Modifier.weight(1f))
if (row.isCustomized) {
TextButton(onClick = onReset) { Text("Défaut d'usine") }
}
}
}
}
}
@Composable
private fun CascadeCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"Cascade Thinker → Speaker",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
Text(
if (enabled)
"ACTIVÉE — le Thinker analyse chaque message avant que le Speaker réponde."
else
"DÉSACTIVÉE — mode mono-Speaker (réponse directe, RAM minimum).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(
checkedThumbColor = StateReady,
checkedTrackColor = StateReady.copy(alpha = 0.4f)
)
)
}
if (enabled) {
Spacer(Modifier.height(12.dp))
Text(
"💡 Le Thinker est lazy-loadé au premier message. Premier tour de la session ~+1.5 s.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun TtsCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"Synthèse vocale (TTS)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
Text(
if (enabled)
"ACTIVÉE — Kazeia répond à voix haute."
else
"DÉSACTIVÉE — la réponse s'affiche progressivement à l'écran, sans voix.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(
checkedThumbColor = StateReady,
checkedTrackColor = StateReady.copy(alpha = 0.4f)
)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModelSection(
title: String,
subtitle: String,
models: List<KazeiaConfigClient.ModelInfo>,
currentId: String,
dimmed: Boolean = false,
onPick: (KazeiaConfigClient.ModelInfo) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (dimmed)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(2.dp))
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
val available = models.filter { it.fileExists }
val current = available.firstOrNull { it.id == currentId }
?: models.firstOrNull { it.id == currentId }
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded && !dimmed,
onExpandedChange = { if (!dimmed) expanded = !expanded }
) {
OutlinedTextField(
value = current?.displayName ?: currentId,
onValueChange = {},
readOnly = true,
enabled = !dimmed,
label = { Text("Modèle actif") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
available.forEach { m ->
DropdownMenuItem(
text = {
Column {
Text(m.displayName, fontWeight = FontWeight.Medium)
Text(
"${m.sizeMb} MB · seq=${m.maxSeqLen}" +
if (m.notes.isNotEmpty()) " · ${m.notes}" else "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
expanded = false
onPick(m)
}
)
}
if (available.isEmpty()) {
DropdownMenuItem(
text = { Text("Aucun modèle disponible sur la tablette") },
onClick = { expanded = false },
enabled = false
)
}
}
}
if (current != null) {
Spacer(Modifier.height(8.dp))
Text(
"Chemin : ${current.ptePath}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun RamWarningCard(
cfg: KazeiaConfigClient.Config,
speakerModels: List<KazeiaConfigClient.ModelInfo>,
thinkerModels: List<KazeiaConfigClient.ModelInfo>
) {
val sp = speakerModels.firstOrNull { it.id == cfg.speakerModelId }?.sizeMb ?: 0
val th = if (cfg.cascadeEnabled)
thinkerModels.firstOrNull { it.id == cfg.thinkerModelId }?.sizeMb ?: 0
else 0
val ttsAndStt = 1500 // TTS Talker + CP + Whisper, estimation
val total = sp + th + ttsAndStt
val critical = total > 9000
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (critical)
StateError.copy(alpha = 0.12f)
else StateRecorded.copy(alpha = 0.08f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Warning,
contentDescription = null,
tint = if (critical) StateError else StateRecorded
)
Spacer(Modifier.size(8.dp))
Text(
"Budget mémoire estimé",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
}
Spacer(Modifier.height(8.dp))
val breakdown = buildString {
appendLine("Speaker ${sp.toString().padStart(5)} MB")
if (cfg.cascadeEnabled) appendLine("Thinker ${th.toString().padStart(5)} MB")
else appendLine("Thinker — (cascade off)")
appendLine("TTS+STT ${ttsAndStt.toString().padStart(5)} MB (estim.)")
appendLine("───────")
append("Total ~${total.toString().padStart(5)} MB")
}
Text(
breakdown,
style = MaterialTheme.typography.bodyMedium
)
if (critical) {
Spacer(Modifier.height(8.dp))
Text(
"⚠ Configuration tendue — risque de Low-Memory Killer Android sur les pics TTS. " +
"Recommandé : Thinker = Guard 0.6B, ou désactiver la cascade.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun OfflineNotice(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Kazeia patient non joignable",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
"La configuration LLM ne peut pas être lue. Lance l'app Kazeia puis reviens ici.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@ -0,0 +1,375 @@
package com.kazeia.admin.ui.telemetry
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.kazeia.admin.R
import com.kazeia.admin.data.model.PipelineKpi
import com.kazeia.admin.data.model.TelemetrySnapshot
import com.kazeia.admin.data.model.TurnEvent
import com.kazeia.admin.data.repository.TelemetryRepository
import com.kazeia.admin.ui.components.FilledSparkline
import com.kazeia.admin.ui.components.Sparkline
import com.kazeia.admin.ui.theme.StateError
import com.kazeia.admin.ui.theme.StateProcessing
import com.kazeia.admin.ui.theme.StateReady
import com.kazeia.admin.ui.theme.StateRecorded
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val hms = SimpleDateFormat("HH:mm:ss", Locale.FRENCH)
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun TelemetryScreen() {
val context = LocalContext.current
val repo = remember { TelemetryRepository(context) }
var snapshot by remember { mutableStateOf(repo.snapshot()) }
var tick by remember { mutableIntStateOf(0) }
// Refresh à 2s : assez fluide pour voir les sparklines bouger, suffisamment
// espacé pour ne pas marteler le ContentProvider.
LaunchedEffect(tick) {
while (true) {
snapshot = repo.snapshot()
delay(2_000L)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.nav_telemetry)) },
actions = {
IconButton(onClick = { tick++; snapshot = repo.snapshot() }) {
Icon(Icons.Outlined.Refresh, contentDescription = "Rafraîchir")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!snapshot.kazeiaState.running) {
item { KazeiaOfflineBanner() }
}
item { KazeiaStateCard(snapshot) }
item { PipelineCard(snapshot.pipeline) }
item { MemoryCard(snapshot) }
item { EventsCard(snapshot.recentTurns) }
item { CrashesCard(snapshot) }
item { Spacer(Modifier.height(8.dp)) }
}
}
}
@Composable
private fun KazeiaOfflineBanner() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = StateError.copy(alpha = 0.12f)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"⚠ Kazeia patient non joignable. Mémoire système affichée, KPI pipeline indisponibles.",
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun KazeiaStateCard(snapshot: TelemetrySnapshot) {
val state = snapshot.kazeiaState
SectionCard("État Kazeia") {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(12.dp)
.background(
if (state.running) StateReady else StateError,
CircleShape
)
)
Spacer(Modifier.size(8.dp))
Text(
if (state.running) "Actif" else "Arrêté",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.size(16.dp))
state.pid?.let {
Text("PID $it", style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(16.dp))
}
Text(
"Uptime ${formatDuration(state.uptimeSeconds)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
MiniStat("CPU", "%.0f %%".format(state.cpuPercent))
MiniStat("PSS", "${state.pssMb} MB")
MiniStat("ION (DSP)", "${state.ionMb} MB")
MiniStat("Total app", "${state.pssMb + state.ionMb} MB")
}
}
}
@Composable
private fun PipelineCard(p: PipelineKpi) {
SectionCard("Pipeline — ${p.llmTokensPerSec.size} derniers tours") {
KpiRow("TTFT (LLM)", "${p.avgTtft()} ms", p.ttftMs.map { it.toFloat() })
KpiRow("Décode LLM", "%.1f tok/s".format(p.avgTps()), p.llmTokensPerSec)
KpiRow("TTS RTF", "%.2f".format(p.avgTtsRtf()), p.ttsRtf,
hint = "< 1.0 = temps réel")
KpiRow("STT RTF", "%.2f".format(p.avgSttRtf()), p.sttRtf)
KpiRow("Cycle total", "%.1f s".format(p.avgTotalSec()),
p.totalCycleMs.map { it.toFloat() })
}
}
@Composable
private fun KpiRow(
label: String,
value: String,
series: List<Float>,
hint: String? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.padding(end = 12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface)
hint?.let {
Text(it, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(Modifier.weight(1f))
Sparkline(values = series)
Spacer(Modifier.size(12.dp))
Text(
value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp)
)
}
}
@Composable
private fun MemoryCard(snapshot: TelemetrySnapshot) {
val m = snapshot.memorySeries
SectionCard("Mémoire — 60 dernières secondes") {
MemoryRow("PSS Kazeia", "${m.pssMb.last()} MB", m.pssMb, MaterialTheme.colorScheme.primary)
MemoryRow("ION (DSP)", "${m.ionMb.last()} MB", m.ionMb, StateProcessing)
MemoryRow("Mémoire libre", "${m.systemAvailMb.last()} MB", m.systemAvailMb, StateReady)
}
}
@Composable
private fun MemoryRow(label: String, value: String, series: List<Int>, color: Color) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(label, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 12.dp))
Spacer(Modifier.weight(1f))
FilledSparkline(values = series, lineColor = color)
Spacer(Modifier.size(12.dp))
Text(
value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = color
)
}
}
@Composable
private fun EventsCard(turns: List<TurnEvent>) {
SectionCard("Derniers tours conversation") {
turns.takeLast(10).reversed().forEach { turn ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
hms.format(Date(turn.timestamp)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.size(12.dp))
Text(
if (turn.ok) "" else "",
color = if (turn.ok) StateReady else StateError,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.size(8.dp))
Text(
"Tour ${turn.turnNumber}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 16.dp)
)
Spacer(Modifier.weight(1f))
Text(
"%.1f tok/s · %d tok · %.1f s".format(
turn.llmTokensPerSec,
turn.tokensGenerated,
turn.totalMs / 1000f
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun CrashesCard(snapshot: TelemetrySnapshot) {
SectionCard("Crashes récents (24h)") {
if (snapshot.recentCrashes.isEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.CheckCircle, null, tint = StateReady,
modifier = Modifier.size(24.dp))
Spacer(Modifier.size(8.dp))
Text(
"Aucun crash — production stable depuis le rebuild libexecutorch (2026-05-14)",
style = MaterialTheme.typography.bodyMedium
)
}
} else {
snapshot.recentCrashes.forEach { c ->
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
hms.format(Date(c.timestamp)),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.size(8.dp))
Text(
"${c.signal}${c.component}",
color = StateError,
fontWeight = FontWeight.SemiBold
)
}
c.message?.let {
Text(it, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp))
}
}
}
}
}
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@Composable
private fun MiniStat(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold)
}
}
private fun formatDuration(seconds: Long): String {
val h = seconds / 3600
val m = (seconds % 3600) / 60
val s = seconds % 60
return when {
h > 0 -> "%dh %02dm %02ds".format(h, m, s)
m > 0 -> "%dm %02ds".format(m, s)
else -> "${s}s"
}
}

View File

@ -0,0 +1,25 @@
package com.kazeia.admin.ui.theme
import androidx.compose.ui.graphics.Color
// Palette therapeutic — purple/lavender matching Kazeia main app accent,
// neutral dark for clinical sobriety, accent green/orange/red for state badges.
val Lavender = Color(0xFFBCA4E8)
val LavenderDeep = Color(0xFF7A6BAA)
val LavenderSoft = Color(0xFFE3D9F5)
val SurfaceDark = Color(0xFF1C1B2B)
val SurfaceDarker = Color(0xFF121121)
val SurfaceLight = Color(0xFFF7F5FA)
val OnSurfaceDark = Color(0xFFEAE6F5)
val OnSurfaceLight = Color(0xFF1C1B2B)
val MutedDark = Color(0xFF2A2940)
val MutedLight = Color(0xFFE8E5F0)
// State colors
val StateReady = Color(0xFF4CAF8C) // soft green
val StateRecorded = Color(0xFFE0A856) // warm orange
val StateProcessing = Color(0xFF5BA8E0) // calm blue
val StateError = Color(0xFFD56B6B) // muted red

View File

@ -0,0 +1,70 @@
package com.kazeia.admin.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColors = darkColorScheme(
primary = Lavender,
onPrimary = SurfaceDarker,
primaryContainer = LavenderDeep,
onPrimaryContainer = LavenderSoft,
secondary = LavenderSoft,
onSecondary = SurfaceDarker,
background = SurfaceDarker,
onBackground = OnSurfaceDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
surfaceVariant = MutedDark,
onSurfaceVariant = OnSurfaceDark,
error = StateError,
onError = SurfaceDarker
)
private val LightColors = lightColorScheme(
primary = LavenderDeep,
onPrimary = SurfaceLight,
primaryContainer = LavenderSoft,
onPrimaryContainer = SurfaceDarker,
secondary = LavenderDeep,
onSecondary = SurfaceLight,
background = SurfaceLight,
onBackground = OnSurfaceLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
surfaceVariant = MutedLight,
onSurfaceVariant = OnSurfaceLight,
error = StateError,
onError = SurfaceLight
)
@Composable
fun KazeiaAdminTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColors else LightColors
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colors.background.toArgb()
WindowCompat.getInsetsController(window, view)
.isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colors,
typography = AdminTypography,
content = content
)
}

View File

@ -0,0 +1,60 @@
package com.kazeia.admin.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// System default typography slightly upscaled — tablet-friendly,
// clinical sobriety. No custom font in MVP.
val AdminTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light,
fontSize = 48.sp,
lineHeight = 56.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
lineHeight = 24.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 22.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 22.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
)
)

View File

@ -0,0 +1,383 @@
package com.kazeia.admin.ui.voices
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.Stop
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.kazeia.admin.R
import com.kazeia.admin.ui.theme.StateError
import com.kazeia.admin.ui.theme.StateReady
import com.kazeia.admin.voice.AudioRecorder
import com.kazeia.admin.voice.VoiceQualityValidator
import com.kazeia.admin.voice.VoiceStorage
import kotlinx.coroutines.launch
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun RecordVoiceScreen(
onCancel: () -> Unit,
onSaved: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val recorder = remember { AudioRecorder(context) }
val storage = remember { VoiceStorage(context) }
val snackbar = remember { SnackbarHostState() }
val state by recorder.state.collectAsState()
val rms by recorder.rmsLevel.collectAsState()
val rmsDb by recorder.rmsDbfs.collectAsState()
val peakDb by recorder.peakDbfs.collectAsState()
val durationMs by recorder.durationMs.collectAsState()
var voiceName by remember { mutableStateOf("") }
var permissionGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
val permLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted -> permissionGranted = granted }
DisposableEffect(Unit) { onDispose { recorder.reset() } }
val referenceText = stringResource(
R.string.rec_reference_text,
voiceName.trim().ifBlank { "[prénom]" }
)
// Quand l'enregistrement se termine, on lance la validation qualité
LaunchedEffect(state) {
if (state == AudioRecorder.State.FINISHED) {
qualityReport = VoiceQualityValidator.analyze(recorder.samples())
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Nouvelle voix") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// 1. Nom de la voix
OutlinedTextField(
value = voiceName,
onValueChange = { voiceName = it },
label = { Text(stringResource(R.string.rec_name_label)) },
placeholder = { Text(stringResource(R.string.rec_name_hint)) },
singleLine = true,
enabled = state != AudioRecorder.State.RECORDING,
modifier = Modifier.fillMaxWidth()
)
// 2. Phrase de référence (vaut consentement enregistré)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(20.dp)) {
Text(
stringResource(R.string.rec_instruction_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(12.dp))
Text(
referenceText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.rec_consent_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.weight(1f))
// 3. Visualisation enregistrement
RecordingMeter(
state = state,
rms = rms,
rmsDb = rmsDb,
peakDb = peakDb,
durationMs = durationMs,
quality = qualityReport
)
Spacer(Modifier.height(12.dp))
// 4. Bouton record / stop
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
when (state) {
AudioRecorder.State.IDLE, AudioRecorder.State.FINISHED -> {
if (!permissionGranted) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
} else {
qualityReport = null
recorder.reset()
val r = recorder.start(scope)
if (r.isFailure) {
scope.launch {
snackbar.showSnackbar(
"Erreur micro : ${r.exceptionOrNull()?.message}"
)
}
}
}
}
AudioRecorder.State.RECORDING -> recorder.stop()
}
},
enabled = voiceName.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = if (state == AudioRecorder.State.RECORDING)
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(50),
modifier = Modifier.height(64.dp)
) {
Icon(
if (state == AudioRecorder.State.RECORDING) Icons.Outlined.Stop
else Icons.Outlined.Mic,
contentDescription = null,
modifier = Modifier.size(28.dp)
)
Spacer(Modifier.size(12.dp))
Text(
when (state) {
AudioRecorder.State.RECORDING -> stringResource(R.string.rec_stop)
AudioRecorder.State.FINISHED -> "Refaire"
else -> stringResource(R.string.rec_start)
},
style = MaterialTheme.typography.titleMedium
)
}
}
// 5. Bouton save : visible quand audio capturé ET qualité OK
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true
if (state == AudioRecorder.State.FINISHED) {
Button(
onClick = {
val samples = recorder.samples()
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
val saved = runCatching {
storage.save(
displayName = voiceName.trim(),
samples = samples,
durationSeconds = durSec,
referenceText = referenceText
)
}
scope.launch {
if (saved.isSuccess) {
val v = saved.getOrNull()!!
snackbar.showSnackbar(
"Voix « ${v.displayName} » enregistrée. État : recorded — brancher au PC pour finaliser."
)
onSaved()
} else {
snackbar.showSnackbar(
"Échec de sauvegarde : ${saved.exceptionOrNull()?.message}"
)
}
}
},
enabled = saveEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.rec_save))
}
}
if (!permissionGranted) {
Text(
"⚠ Permission micro requise pour enregistrer une voix.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun RecordingMeter(
state: AudioRecorder.State,
rms: Float,
rmsDb: Float,
peakDb: Float,
durationMs: Long,
quality: VoiceQualityValidator.Report?
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(170.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
when (state) {
AudioRecorder.State.IDLE -> Text(
"Appuyez sur le bouton pour commencer",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
AudioRecorder.State.RECORDING -> {
Text(
"● Enregistrement — %.1f s".format(durationMs / 1000f),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(12.dp))
// Niveau micro normalisé pour barre — mais on amplifie
// visuellement par 3× car la voix proche-micro reste
// souvent dans la moitié basse de la dynamique 16-bit.
LinearProgressIndicator(
progress = { (rms * 3f).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = if (rmsDb >= -34f) StateReady
else MaterialTheme.colorScheme.error,
trackColor = MaterialTheme.colorScheme.surface
)
Spacer(Modifier.height(4.dp))
val rmsTxt = if (rmsDb > -119f) "%.0f dB".format(rmsDb) else "–∞"
val peakTxt = if (peakDb > -119f) "%.0f dB".format(peakDb) else "–∞"
Text(
"Niveau : $rmsTxt crête : $peakTxt (cible RMS : 30 à 15 dB)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
AudioRecorder.State.FINISHED -> {
val q = quality
if (q == null) {
Text("Analyse qualité…", style = MaterialTheme.typography.bodyLarge)
} else if (q.ok) {
Text(
"✓ Audio OK — %.1f s · RMS %.0f dB · SNR %.0f dB".format(
q.durationSeconds, q.rmsDbfs, q.snrEstimateDb
),
style = MaterialTheme.typography.bodyLarge,
color = StateReady,
fontWeight = FontWeight.Medium
)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"✗ Qualité insuffisante",
style = MaterialTheme.typography.titleMedium,
color = StateError,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
q.issues.forEach { issue ->
Text(
"$issue",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,295 @@
package com.kazeia.admin.ui.voices
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.R
import com.kazeia.admin.data.model.Voice
import com.kazeia.admin.data.model.VoiceState
import com.kazeia.admin.data.repository.VoicesRepository
import com.kazeia.admin.ui.components.StateBadge
import com.kazeia.admin.ui.theme.StateError
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun VoicesScreen(
onStartRecording: () -> Unit
) {
val context = LocalContext.current
val repo = remember { VoicesRepository(context) }
val scope = rememberCoroutineScope()
val snackbarHost = remember { SnackbarHostState() }
var voices by remember { mutableStateOf(repo.list()) }
var pendingDelete by remember { mutableStateOf<Voice?>(null) }
var refreshTick by remember { mutableIntStateOf(0) }
// Recharge la liste à chaque tick (manuel après delete, ou périodique).
LaunchedEffect(refreshTick) { voices = repo.list() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.voices_title)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
)
)
},
snackbarHost = { SnackbarHost(snackbarHost) },
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onStartRecording,
icon = { Icon(Icons.Outlined.Mic, null) },
text = { Text(stringResource(R.string.voices_add)) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
) { padding ->
if (voices.isEmpty()) {
EmptyVoicesState(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(voices, key = { it.id }) { voice ->
VoiceCard(
voice = voice,
onDeleteClick = { pendingDelete = voice }
)
}
item { Spacer(Modifier.height(96.dp)) }
}
}
}
// Confirm delete dialog
pendingDelete?.let { voice ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Supprimer la voix ?") },
text = {
Column {
Text(
"« ${voice.name} » sera retirée de la tablette : " +
"les embeddings et l'audio source seront effacés."
)
Spacer(Modifier.height(8.dp))
Text(
"Cette action est irréversible côté tablette. " +
"Si la voix est sauvegardée sur le PC de gestion, " +
"vous pourrez la redéployer.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Button(
onClick = {
val ok = repo.delete(voice.id)
pendingDelete = null
refreshTick++
scope.launch {
snackbarHost.showSnackbar(
message = if (ok) "Voix « ${voice.name} » supprimée."
else "Impossible de supprimer « ${voice.name} » : " +
"voix système installée hors de l'app. " +
"Retirez-la manuellement via : adb shell rm " +
"/data/local/tmp/kazeia/voix/voix/${voice.id}.wav",
duration = if (ok) SnackbarDuration.Short else SnackbarDuration.Long
)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = StateError,
contentColor = androidx.compose.ui.graphics.Color.White
)
) { Text("Supprimer") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
@Composable
private fun EmptyVoicesState(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Aucune voix détectée",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
"Vérifiez que Kazeia est lancé sur cette tablette, " +
"puis revenez ici. Vous pourrez enregistrer une nouvelle voix " +
"avec le bouton ci-dessous.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun VoiceCard(
voice: Voice,
onDeleteClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
voice.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.weight(1f))
StateBadge(voice.state)
}
Spacer(Modifier.height(8.dp))
// Sous-titre : durée + norme (si dispo)
val subtitle = buildString {
voice.durationSeconds?.let { append("%.1f s".format(it)) }
voice.speakerNorm?.let {
if (isNotEmpty()) append(" · ")
append("norme %.2f".format(it))
}
}
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Message d'erreur ou statut
voice.errorMessage?.let { msg ->
Spacer(Modifier.height(6.dp))
Text(
msg,
style = MaterialTheme.typography.bodyMedium,
color = if (voice.state == VoiceState.ERROR)
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when (voice.state) {
VoiceState.READY -> {
TextButton(onClick = {}) { Text(stringResource(R.string.voices_test)) }
Spacer(Modifier.weight(1f))
TextButton(
onClick = onDeleteClick,
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = StateError
)
) { Text(stringResource(R.string.voices_delete)) }
}
VoiceState.RECORDED -> {
Text(
"🔌 Brancher au PC de gestion pour finaliser",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = onDeleteClick,
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = StateError
)
) { Text(stringResource(R.string.voices_delete)) }
}
VoiceState.PROCESSING -> {
Text(
"Extraction en cours sur le PC…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
VoiceState.ERROR -> {
TextButton(onClick = {}) { Text(stringResource(R.string.voices_rerecord)) }
Spacer(Modifier.weight(1f))
TextButton(
onClick = onDeleteClick,
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = StateError
)
) { Text(stringResource(R.string.voices_delete)) }
}
}
}
}
}
}

View File

@ -0,0 +1,198 @@
package com.kazeia.admin.voice
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.log10
import kotlin.math.sqrt
/**
* Capture audio 16 kHz mono PCM 16-bit pour le clonage vocal Kazeia.
*
* - Buffer en RAM (~10-15 s × 32 KB/s = ~480 KB max, safe)
* - Émet le niveau RMS courant via [rmsLevel] pour la waveform live
* - Pas de stop automatique sur silence en MVP (le clinicien clique stop)
* - Le WAV final est écrit séparément via [WavWriter] depuis [samples]
*/
class AudioRecorder(private val context: Context) {
enum class State { IDLE, RECORDING, FINISHED }
companion object {
const val SAMPLE_RATE = 16_000
const val CHANNEL = AudioFormat.CHANNEL_IN_MONO
const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
const val MIN_DURATION_S = 10
const val MAX_DURATION_S = 25
}
private val _state = MutableStateFlow(State.IDLE)
val state: StateFlow<State> = _state.asStateFlow()
private val _rmsLevel = MutableStateFlow(0f) // 0..1 normalisé
val rmsLevel: StateFlow<Float> = _rmsLevel.asStateFlow()
private val _rmsDbfs = MutableStateFlow(-120f)
val rmsDbfs: StateFlow<Float> = _rmsDbfs.asStateFlow()
private val _peakDbfs = MutableStateFlow(-120f)
val peakDbfs: StateFlow<Float> = _peakDbfs.asStateFlow()
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
@Volatile private var samplesBuffer: ShortArray = ShortArray(0)
@Volatile private var stopRequested: Boolean = false
private var captureJob: Job? = null
private var record: AudioRecord? = null
private var agc: AutomaticGainControl? = null
private var ns: NoiseSuppressor? = null
fun hasRecordPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
/** Renvoie les samples capturés (read-only snapshot). Vide si pas encore enregistré. */
fun samples(): ShortArray = samplesBuffer
@SuppressLint("MissingPermission")
fun start(scope: CoroutineScope): Result<Unit> {
if (!hasRecordPermission()) return Result.failure(SecurityException("RECORD_AUDIO non accordée"))
if (_state.value == State.RECORDING) return Result.success(Unit)
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL, ENCODING)
if (minBuf <= 0) return Result.failure(IllegalStateException("AudioRecord.getMinBufferSize=$minBuf"))
val bufSize = maxOf(minBuf * 4, 8192)
// VOICE_RECOGNITION engage l'AGC + NS du device par défaut sur la
// plupart des Android, ce qui normalise le niveau de la voix
// proche-micro (la source MIC brute donne -35 dB sur tablette,
// VOICE_RECOGNITION ramène autour de -20 à -15 dB). Pour le
// cloning, c'est un compromis acceptable — l'encodeur Qwen3-TTS
// normalise lui-même les embeddings.
val rec = try {
AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION,
SAMPLE_RATE, CHANNEL, ENCODING, bufSize)
} catch (e: Throwable) {
return Result.failure(e)
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
rec.release()
return Result.failure(IllegalStateException("AudioRecord state=${rec.state}"))
}
// AGC + NS explicitement activés si dispos (sécurité par-dessus
// le profil VOICE_RECOGNITION) — Log si non supporté.
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(rec.audioSessionId)?.also { it.enabled = true }
Log.i("AudioRecorder", "AGC enabled=${agc?.enabled}")
} else {
Log.i("AudioRecorder", "AGC not available on this device")
}
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(rec.audioSessionId)?.also { it.enabled = true }
Log.i("AudioRecorder", "NS enabled=${ns?.enabled}")
}
record = rec
// Pré-alloue MAX_DURATION_S + 5s (sécurité hard cap)
val maxSamples = SAMPLE_RATE * (MAX_DURATION_S + 5)
val accum = ShortArray(maxSamples)
var written = 0
stopRequested = false
_state.value = State.RECORDING
_rmsLevel.value = 0f
_durationMs.value = 0L
val t0 = System.currentTimeMillis()
rec.startRecording()
captureJob = scope.launch(Dispatchers.IO) {
val chunk = ShortArray(SAMPLE_RATE / 10) // 100 ms chunks
var peakInt = 0
try {
while (!stopRequested) {
val n = rec.read(chunk, 0, chunk.size)
if (n <= 0) continue
val room = accum.size - written
val copy = minOf(n, room)
System.arraycopy(chunk, 0, accum, written, copy)
written += copy
val rms = computeRms(chunk, n)
_rmsLevel.value = (rms / 32768f).coerceIn(0f, 1f)
_rmsDbfs.value = amplitudeToDbfs(rms)
for (i in 0 until n) {
val a = if (chunk[i] == Short.MIN_VALUE) Short.MAX_VALUE.toInt()
else kotlin.math.abs(chunk[i].toInt())
if (a > peakInt) peakInt = a
}
_peakDbfs.value = amplitudeToDbfs(peakInt.toFloat())
_durationMs.value = System.currentTimeMillis() - t0
if (written >= accum.size) break // hard cap
}
} catch (_: Throwable) { /* swallow */ }
try { rec.stop() } catch (_: Throwable) {}
try { rec.release() } catch (_: Throwable) {}
try { agc?.release() } catch (_: Throwable) {}
try { ns?.release() } catch (_: Throwable) {}
agc = null; ns = null
record = null
// ORDRE IMPORTANT : copier les samples AVANT de publier FINISHED,
// sinon le collector Compose lit un buffer encore vide (race).
samplesBuffer = accum.copyOf(written)
_rmsLevel.value = 0f
_state.value = State.FINISHED
}
return Result.success(Unit)
}
/** Demande l'arrêt la coroutine d'IO finit son tour de boucle, copie
* les samples, puis publie [State.FINISHED]. Bloquant côté UI : on
* observe le StateFlow pour savoir quand la copie est terminée. */
fun stop() {
if (_state.value != State.RECORDING) return
stopRequested = true
}
fun reset() {
captureJob?.cancel()
try { record?.stop() } catch (_: Throwable) {}
try { record?.release() } catch (_: Throwable) {}
record = null
samplesBuffer = ShortArray(0)
stopRequested = false
_state.value = State.IDLE
_rmsLevel.value = 0f
_durationMs.value = 0L
}
private fun computeRms(buf: ShortArray, n: Int): Float {
if (n <= 0) return 0f
var sum = 0.0
for (i in 0 until n) {
val v = buf[i].toDouble()
sum += v * v
}
return sqrt(sum / n).toFloat()
}
private fun amplitudeToDbfs(amplitude: Float): Float {
if (amplitude <= 1f) return -120f
return 20f * log10(amplitude / 32768f)
}
}

View File

@ -0,0 +1,115 @@
package com.kazeia.admin.voice
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.sqrt
/**
* Validation qualité d'un enregistrement audio pour le clonage vocal.
* Calcule durée, RMS, peak, SNR estimé, et produit un verdict UI.
*
* Critères calibrés pour le pipeline Qwen3-TTS (cf incident Amir 2026-05-14
* une norme speaker hors plage = mode collapse Talker "beg beg beg").
*/
object VoiceQualityValidator {
data class Report(
val durationSeconds: Float,
val rmsDbfs: Float,
val peakDbfs: Float,
val snrEstimateDb: Float,
val issues: List<String>
) {
val ok: Boolean get() = issues.isEmpty()
}
private const val SAMPLE_RATE = 16_000
// Plages calibrées pour la phrase de référence avec consentement
// (~13-15s lecture normale). Tolérance large pour ne pas pénaliser
// les locuteurs lents ou les tablettes au micro un peu sourd.
private const val MIN_DURATION_S = 10f
private const val MAX_DURATION_S = 20f
// Seuil RMS relâché à -34 dB après tests réels sur OnePlus Pad 3 :
// VOICE_RECOGNITION + AGC du device donne ~-20 à -30 dB en parole
// posée proche-micro, le seuil doit accommoder la variance.
private const val MIN_RMS_DBFS = -34f
private const val MAX_RMS_DBFS = -6f
private const val MAX_PEAK_DBFS = -1f
private const val MIN_SNR_DB = 15f
fun analyze(samples: ShortArray): Report {
if (samples.isEmpty()) return Report(0f, -120f, -120f, 0f, listOf("Audio vide"))
val duration = samples.size.toFloat() / SAMPLE_RATE
val rms = computeRms(samples)
val peak = computePeak(samples)
val snr = estimateSnr(samples)
val issues = mutableListOf<String>()
when {
duration < MIN_DURATION_S -> issues += "Audio trop court (%.1fs, min %.0fs)".format(duration, MIN_DURATION_S)
duration > MAX_DURATION_S -> issues += "Audio trop long (%.1fs, max %.0fs)".format(duration, MAX_DURATION_S)
}
when {
rms < MIN_RMS_DBFS -> issues += "Volume trop faible (%.0fdB, min %.0fdB) — rapprochez-vous du micro".format(rms, MIN_RMS_DBFS)
rms > MAX_RMS_DBFS -> issues += "Volume trop fort (%.0fdB, max %.0fdB) — éloignez-vous du micro".format(rms, MAX_RMS_DBFS)
}
if (peak > MAX_PEAK_DBFS) {
issues += "Saturation détectée (peak %.1fdB) — parlez moins fort".format(peak)
}
if (snr < MIN_SNR_DB) {
issues += "Bruit ambiant trop élevé (SNR %.0fdB, min %.0fdB) — endroit plus calme".format(snr, MIN_SNR_DB)
}
return Report(duration, rms, peak, snr, issues)
}
private fun computeRms(s: ShortArray): Float {
var sum = 0.0
for (v in s) {
val d = v.toDouble()
sum += d * d
}
val rms = sqrt(sum / s.size)
return amplitudeToDbfs(rms.toFloat())
}
private fun computePeak(s: ShortArray): Float {
var maxAbs = 0
for (v in s) {
val a = if (v == Short.MIN_VALUE) Short.MAX_VALUE.toInt() else kotlin.math.abs(v.toInt())
if (a > maxAbs) maxAbs = a
}
return amplitudeToDbfs(maxAbs.toFloat())
}
/**
* SNR grossier : compare le RMS du signal vs RMS estimé du bruit
* (15 percentile des RMS de fenêtres 100 ms). C'est une heuristique
* suffisante pour détecter un environnement clairement bruité.
*/
private fun estimateSnr(s: ShortArray): Float {
if (s.size < SAMPLE_RATE) return 0f
val frame = SAMPLE_RATE / 10 // 100 ms
val rmsPerFrame = mutableListOf<Float>()
var i = 0
while (i + frame <= s.size) {
var sum = 0.0
for (j in i until i + frame) {
val d = s[j].toDouble(); sum += d * d
}
rmsPerFrame.add(sqrt(sum / frame).toFloat())
i += frame
}
if (rmsPerFrame.size < 5) return 0f
rmsPerFrame.sort()
val noiseRms = max(rmsPerFrame[rmsPerFrame.size / 7], 1f) // 15e percentile
val signalRms = rmsPerFrame.last()
return 20f * log10(signalRms / noiseRms)
}
private fun amplitudeToDbfs(amplitude: Float): Float {
if (amplitude <= 1f) return -120f
return 20f * log10(amplitude / 32768f)
}
}

View File

@ -0,0 +1,105 @@
package com.kazeia.admin.voice
import android.content.Context
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Persistance locale des voix enregistrées via l'admin app.
* Stockage : `getExternalFilesDir("voix")` = `/storage/emulated/0/Android/data/com.kazeia.admin/files/voix/`
*
* - Lisible/écrivable uniquement par l'admin app (SELinux app_data_file)
* - Visible via `adb pull` workflow PC tool peut ramasser les WAV
* - Pas d'embeddings ici (extraction PC) état = "recorded"
*/
class VoiceStorage(private val context: Context) {
data class RecordedVoice(
val id: String,
val displayName: String,
val wavFile: File,
val manifestFile: File,
val createdAt: Long,
val durationSeconds: Float
)
companion object {
private const val SUBDIR = "voix"
private val SLUG_RE = Regex("[^a-z0-9_-]")
private val TS_FMT = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
}
private fun dir(): File {
val base = context.getExternalFilesDir(null) ?: context.filesDir
return File(base, SUBDIR).also { it.mkdirs() }
}
private fun slug(name: String): String {
val lower = name.trim().lowercase().replace(' ', '_')
return SLUG_RE.replace(lower, "")
}
fun save(
displayName: String,
samples: ShortArray,
durationSeconds: Float,
referenceText: String
): RecordedVoice {
val ts = TS_FMT.format(System.currentTimeMillis())
val id = "${slug(displayName)}_$ts"
val wav = File(dir(), "$id.wav")
WavWriter.write(wav, samples)
val manifest = File(dir(), "$id.json")
val json = JSONObject().apply {
put("id", id)
put("name", displayName.trim())
put("state", "recorded")
put("wav", wav.name)
put("wav_size_bytes", wav.length())
put("duration_seconds", durationSeconds.toDouble())
put("created_at", System.currentTimeMillis())
put("reference_text", referenceText)
put("source_device", "tablet-admin-recording")
}
manifest.writeText(json.toString(2))
return RecordedVoice(
id = id,
displayName = displayName,
wavFile = wav,
manifestFile = manifest,
createdAt = json.getLong("created_at"),
durationSeconds = durationSeconds
)
}
fun listLocal(): List<RecordedVoice> {
val out = mutableListOf<RecordedVoice>()
dir().listFiles { f -> f.name.endsWith(".json") }?.forEach { mf ->
runCatching {
val js = JSONObject(mf.readText())
val wavName = js.optString("wav")
val wavFile = File(dir(), wavName)
if (!wavFile.exists()) return@forEach
out += RecordedVoice(
id = js.getString("id"),
displayName = js.optString("name", js.getString("id")),
wavFile = wavFile,
manifestFile = mf,
createdAt = js.optLong("created_at", mf.lastModified()),
durationSeconds = js.optDouble("duration_seconds", 0.0).toFloat()
)
}
}
return out.sortedByDescending { it.createdAt }
}
fun delete(voice: RecordedVoice): Boolean {
val ok1 = voice.wavFile.delete()
val ok2 = voice.manifestFile.delete()
return ok1 || ok2
}
}

View File

@ -0,0 +1,71 @@
package com.kazeia.admin.voice
import java.io.DataOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Écrit un fichier WAV PCM 16-bit, mono, à partir d'un tableau de samples
* `ShortArray`. Header WAV minimal mais conforme aux outils standards
* (Audacity, ffmpeg, lecteur Android, l'encodeur PC Qwen3-TTS).
*/
object WavWriter {
fun write(file: File, samples: ShortArray, sampleRate: Int = 16_000): File {
file.parentFile?.mkdirs()
val byteData = ByteBuffer
.allocate(samples.size * 2)
.order(ByteOrder.LITTLE_ENDIAN)
.apply { for (s in samples) putShort(s) }
.array()
FileOutputStream(file).use { fos ->
DataOutputStream(fos).use { out ->
writeHeader(out, byteData.size, sampleRate, 1, 16)
out.write(byteData)
}
}
return file
}
private fun writeHeader(
out: DataOutputStream,
dataSize: Int,
sampleRate: Int,
channels: Int,
bitsPerSample: Int
) {
val byteRate = sampleRate * channels * bitsPerSample / 8
val blockAlign = channels * bitsPerSample / 8
out.writeBytes("RIFF")
out.writeIntLE(36 + dataSize)
out.writeBytes("WAVE")
out.writeBytes("fmt ")
out.writeIntLE(16) // fmt chunk size (PCM)
out.writeShortLE(1) // audio format = PCM
out.writeShortLE(channels)
out.writeIntLE(sampleRate)
out.writeIntLE(byteRate)
out.writeShortLE(blockAlign)
out.writeShortLE(bitsPerSample)
out.writeBytes("data")
out.writeIntLE(dataSize)
}
private fun DataOutputStream.writeIntLE(v: Int) {
write(v and 0xff)
write((v shr 8) and 0xff)
write((v shr 16) and 0xff)
write((v shr 24) and 0xff)
}
private fun DataOutputStream.writeShortLE(v: Int) {
write(v and 0xff)
write((v shr 8) and 0xff)
}
}

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple "K" + gear-like halo for admin -->
<path
android:fillColor="#BCA4E8"
android:pathData="M54 28 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0" />
<path
android:fillColor="#BCA4E8"
android:pathData="M40 42 L40 80 L48 80 L48 64 L60 80 L70 80 L56 62 L70 42 L60 42 L48 58 L48 42 Z" />
<path
android:strokeColor="#7A6BAA"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M54 16 a38 38 0 1 0 0 76 a38 38 0 1 0 0 -76" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_admin_background"/>
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_admin_background"/>
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_admin_background">#1C1B2B</color>
</resources>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kazeia Admin</string>
<!-- Navigation -->
<string name="nav_voices">Voix</string>
<string name="nav_profiles">Profils patients</string>
<string name="nav_prompts">Prompts</string>
<string name="nav_rag">Base documentaire</string>
<string name="nav_history">Historique</string>
<string name="nav_telemetry">Telemetry</string>
<string name="nav_system">Système</string>
<!-- Login -->
<string name="login_title">Accès clinicien</string>
<string name="login_subtitle">Saisissez le code PIN</string>
<string name="login_error_attempts">Erreur — %1$d essai(s) restant(s)</string>
<string name="login_locked">Verrouillé. Réessayez plus tard.</string>
<!-- Voices -->
<string name="voices_title">Voix disponibles</string>
<string name="voices_add">+ Enregistrer nouvelle voix</string>
<string name="voices_state_recorded">À finaliser sur PC</string>
<string name="voices_state_processing">En cours d\'extraction sur PC</string>
<string name="voices_state_ready">Prête</string>
<string name="voices_state_error">Erreur</string>
<string name="voices_builtin">Système</string>
<string name="voices_test">Tester</string>
<string name="voices_choose">Choisir</string>
<string name="voices_delete">Supprimer</string>
<string name="voices_rerecord">Réenregistrer</string>
<!-- Recording -->
<string name="rec_name_label">Nom de la voix</string>
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux.</string>
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
<string name="rec_start">Commencer l\'enregistrement</string>
<string name="rec_stop">Arrêter</string>
<string name="rec_replay">Réécouter</string>
<string name="rec_redo">Refaire</string>
<string name="rec_save">Enregistrer cette voix</string>
<string name="rec_quality_ok">Qualité audio OK</string>
<string name="rec_quality_too_short">Audio trop court (min 8 s)</string>
<string name="rec_quality_too_long">Audio trop long (max 15 s)</string>
<string name="rec_quality_too_quiet">Trop loin du micro</string>
<string name="rec_quality_too_loud">Trop près du micro / saturation</string>
<string name="rec_quality_noisy">Bruit ambiant trop élevé</string>
<!-- Common -->
<string name="cancel">Annuler</string>
<string name="confirm">Confirmer</string>
<string name="delete">Supprimer</string>
<string name="back">Retour</string>
<!-- Stub screens -->
<string name="stub_coming_soon">Bientôt disponible</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KazeiaAdmin" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -1,19 +1,56 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Identifiants WebDAV de distribution (Nextcloud) lus depuis local.properties
// — jamais commités. Voir local.properties + project_kazeia_installer_updates.
val localProps = Properties().apply {
val f = rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
fun localProp(key: String, default: String = ""): String =
localProps.getProperty(key) ?: default
// Keystore release — identifiants lus depuis /opt/Kazeia/keystore/credentials.properties
// (hors git, à SAUVEGARDER hors machine). Voir docs/RELEASE_SIGNING.md.
// Absent (autre machine) → le build release retombe sur la clé debug avec un warning.
val releaseKeystoreProps = Properties().apply {
val f = file("/opt/Kazeia/keystore/credentials.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
val hasReleaseKeystore = releaseKeystoreProps.getProperty("storeFile")
?.let { file(it).exists() } == true
android {
namespace = "com.kazeia"
compileSdk = 36
ndkVersion = "27.3.13750724"
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(releaseKeystoreProps.getProperty("storeFile"))
storePassword = releaseKeystoreProps.getProperty("storePassword")
keyAlias = releaseKeystoreProps.getProperty("keyAlias")
keyPassword = releaseKeystoreProps.getProperty("keyPassword")
}
}
}
defaultConfig {
applicationId = "com.kazeia"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "0.1.0-mvp"
versionCode = 11
versionName = "0.1.10"
// WebDAV de distribution — injecté depuis local.properties.
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
buildConfigField("String", "WEBDAV_USER", "\"${localProp("webdav.user")}\"")
buildConfigField("String", "WEBDAV_PASS", "\"${localProp("webdav.pass")}\"")
ndk {
abiFilters += "arm64-v8a"
@ -41,6 +78,12 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (hasReleaseKeystore) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn("⚠ keystore release absent (/opt/Kazeia/keystore/) — release signé en DEBUG. Voir docs/RELEASE_SIGNING.md")
signingConfig = signingConfigs.getByName("debug")
}
}
}
@ -65,7 +108,21 @@ android {
}
}
// Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent
// correspondre au manifest versionné. Un build RELEASE avec des libs divergentes
// échoue ; le debug reste libre (itération engine). Cf scripts/jnilibs.sh.
val verifyJniLibs = tasks.register<Exec>("verifyJniLibs") {
commandLine("bash", rootProject.file("scripts/jnilibs.sh").absolutePath, "verify")
}
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
dependsOn(verifyJniLibs)
}
dependencies {
// Tests unitaires JVM (src/test/) — logique pure : CrisisGuard, VectorIndex, Chunker.
// Lancer : ./gradlew :app:testDebugUnitTest
testImplementation("junit:junit:4.13.2")
// Android
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
@ -94,6 +151,18 @@ dependencies {
implementation("com.facebook.soloader:nativeloader:0.10.5")
implementation(files("libs/executorch.jar"))
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// WorkManager — orchestration du téléchargement modèles (foreground, reprise)
implementation("androidx.work:work-runtime-ktx:2.9.1")
// SQLCipher for encrypted conversation history (RGPD / secret médical)
implementation("net.zetetic:sqlcipher-android:4.5.4@aar")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
// EncryptedSharedPreferences pour stocker la passphrase SQLCipher
// (clef wrappée par Android Keystore, persistante).
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Unity as a Library (UaaL) — DISABLED
// implementation(project(":unityLibrary"))
// implementation("androidx.games:games-activity:3.0.5")

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -7,8 +8,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".KazeiaApplication"
@ -20,9 +23,9 @@
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
<!-- LAUNCHER: Splash screen (loads ML models) -->
<!-- LAUNCHER: Onboarding (vérifie/télécharge les modèles au 1er lancement) -->
<activity
android:name=".ui.SplashActivity"
android:name=".ui.OnboardingActivity"
android:exported="true"
android:screenOrientation="unspecified"
android:theme="@style/Theme.Kazeia.Splash">
@ -32,6 +35,13 @@
</intent-filter>
</activity>
<!-- Splash screen (loads ML models) — lancé par OnboardingActivity -->
<activity
android:name=".ui.SplashActivity"
android:exported="false"
android:screenOrientation="unspecified"
android:theme="@style/Theme.Kazeia.Splash" />
<!-- Unity AvatarActivity — DISABLED -->
<!--
<activity
@ -49,6 +59,12 @@
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.ProfilePickerActivity"
android:exported="false"
android:screenOrientation="unspecified"
android:theme="@style/Theme.Kazeia" />
<service
android:name=".service.KazeiaService"
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
@ -58,5 +74,47 @@
android:value="AI inference service for emotional support chatbot" />
</service>
<!-- v2 rewrite : ChatActivityV2 test page + KazeiaServiceV2 (Phase 1 STT) -->
<activity
android:name=".v2.ChatActivityV2"
android:exported="true"
android:label="Kazeia v2"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".v2.KazeiaServiceV2"
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
android:exported="false" />
<!-- WorkManager : déclare le type FGS dataSync sur son service foreground
(requis API 34+ pour le worker de téléchargement modèles). -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Statut des sessions PackageInstaller (self-update APK). -->
<receiver
android:name=".dist.ApkInstallReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.kazeia.action.APK_INSTALL_STATUS" />
</intent-filter>
</receiver>
<!-- Telemetry endpoint exposed to com.kazeia.admin (read-only).
No sensitive data — CPU/RAM/timings only. To be tightened
with a signature-based permission once shared keystore in. -->
<provider
android:name=".telemetry.KazeiaTelemetryProvider"
android:authorities="com.kazeia.provider"
android:exported="true" />
</application>
</manifest>

View File

@ -3,21 +3,25 @@ package com.kazeia
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
class KazeiaApplication : Application() {
companion object {
const val CHANNEL_ID = "kazeia_service_channel"
const val MODELS_DIR = "/data/local/tmp/kazeia/models"
/** LLM backend selector :
* - "executorch" : Qwen3-4B via ExecuTorch QNN on NPU (legacy, current default)
* - "llamacpp" : Qwen3.5-4B via llama.cpp upstream on CPU (new primary path)
* Can be overridden at boot via `adb shell setprop debug.kazeia.llm_backend llamacpp`
* (property is read by KazeiaService). */
const val LLM_BACKEND = "executorch"
const val LLAMACPP_MODEL_FILENAME = "Qwen3.5-4B-Q4_0-pure.gguf"
/** Racine modèles STT/TTS — résolue par [KazeiaPaths]. */
val MODELS_DIR: String get() = KazeiaPaths.modelsDir
/** Racine modèles LLM ExecuTorch (`.pte`) — résolue par [KazeiaPaths]. */
val LLM_DIR: String get() = KazeiaPaths.llmDir
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
// Résolution des racines modèles AVANT tout ContentProvider/Activity.
KazeiaPaths.init(base)
}
override fun onCreate() {
@ -26,6 +30,34 @@ class KazeiaApplication : Application() {
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
// requires its own initialization sequence. Loading libs out of order causes
// native crashes. Unity handles lib loading internally in onCreate().
// Bench QNN options : déclenché par `touch <files>/bench_qnn.trigger` puis relance app.
// Charge WhisperHybridEngine + transcrit /files/bench_audio/*.wav (3×, médiane).
val trig = java.io.File(getExternalFilesDir(null), "bench_qnn.trigger")
if (trig.exists()) {
trig.delete()
Thread {
com.kazeia.stt.BenchQnnHarness.runBench(
ctx = this@KazeiaApplication,
audioDir = java.io.File(getExternalFilesDir(null), "bench_audio"),
modelDir = "$MODELS_DIR/whisper-small-sm8750"
)
}.start()
}
// Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app.
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
if (trigLlm.exists()) {
trigLlm.delete()
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
}
// Bench TTS (lib CPU-only) : `touch <files>/bench_tts.trigger`, relance app.
// Charge libkazeia_tts + 1 synthèse FR vers WAV.
val trigTts = java.io.File(getExternalFilesDir(null), "bench_tts.trigger")
if (trigTts.exists()) {
trigTts.delete()
Thread { com.kazeia.tts.BenchTtsHarness.runBench(this@KazeiaApplication) }.start()
}
}
private fun createNotificationChannel() {

View File

@ -0,0 +1,80 @@
package com.kazeia
import android.content.Context
import android.util.Log
import java.io.File
/**
* Résolution centralisée des racines de modèles Kazeia.
*
* Deux scénarios :
* - **Tablette de dev** : modèles poussés via `adb push` dans
* `/data/local/tmp/kazeia/...` (contexte SELinux `shell_data_file`,
* lecture seule pour l'app). Conservé comme fallback.
* - **Tablette de production** : modèles téléchargés par l'installeur dans
* `getExternalFilesDir("kazeia")` (écriture app, gérable / réparable).
*
* Règle : si la racine legacy `/data/local/tmp` existe ET contient des
* fichiers, elle prime zéro régression sur les tablettes existantes.
* Sinon, stockage externe app rempli par l'installeur.
*
* Voir `project_kazeia_installer_updates` en mémoire.
*/
object KazeiaPaths {
private const val TAG = "KazeiaPaths"
private const val LEGACY_MODELS = "/data/local/tmp/kazeia/models"
private const val LEGACY_LLM = "/data/local/tmp/kazeia-et"
private const val LEGACY_RAG = "/data/local/tmp/kazeia/rag_corpus"
/** Racine modèles STT/TTS (Whisper, GGUF, qwen3-tts-npu ; voix via `/../`). */
@Volatile var modelsDir: String = LEGACY_MODELS
private set
/** Racine modèles LLM ExecuTorch (`.pte` + tokenizer). */
@Volatile var llmDir: String = LEGACY_LLM
private set
/** Corpus RAG. Priorité INVERSE des modèles : l'EXTERNE prime dès qu'il est
* non vide, car c'est que l'installeur/OTA dépose le composant
* `rag_corpus` sur une tablette dev, les modèles restent legacy mais le
* corpus à jour arrive par le catalogue. Fallback legacy (adb push) sinon. */
@Volatile var ragCorpusDir: String = LEGACY_RAG
private set
@Volatile private var initialized = false
/**
* Résout les racines. À appeler le plus tôt possible
* ([KazeiaApplication.attachBaseContext], avant tout ContentProvider).
* Idempotent. Tant qu'il n'est pas appelé, les getters renvoient les
* chemins legacy aucun risque même en cas d'accès précoce.
*/
fun init(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
val root = File(context.getExternalFilesDir(null), "kazeia")
modelsDir = resolve(File(root, "models"), File(LEGACY_MODELS))
llmDir = resolve(File(root, "llm"), File(LEGACY_LLM))
ragCorpusDir = resolveExternalFirst(File(root, "rag_corpus"), File(LEGACY_RAG))
initialized = true
Log.i(TAG, "modelsDir=$modelsDir | llmDir=$llmDir | ragCorpusDir=$ragCorpusDir")
}
}
/** Legacy si présent et non vide (tablette dev), sinon stockage externe. */
private fun resolve(external: File, legacy: File): String =
if (legacy.isDirectory && !legacy.list().isNullOrEmpty())
legacy.absolutePath
else
external.absolutePath
/** Externe si non vide (déposé par l'installeur/OTA), sinon legacy (adb push). */
private fun resolveExternalFirst(external: File, legacy: File): String =
if (external.isDirectory && !external.list().isNullOrEmpty())
external.absolutePath
else
legacy.absolutePath
}

View File

@ -1,113 +0,0 @@
package com.kazeia.audio
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import com.kazeia.core.VadEngine
import kotlin.concurrent.thread
class AudioCaptureManager(
private val sampleRate: Int = 16000
) {
companion object {
private const val TAG = "AudioCapture"
}
private var audioRecord: AudioRecord? = null
private var isRunning = false
private var listenerThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(
vad: VadEngine,
silenceDurationMs: Int = 800,
speechMinDurationMs: Int = 150,
onSpeechSegment: (ShortArray) -> Unit
) {
Log.i(TAG, "Starting audio capture with VAD")
val frameSize = 512 // 32ms at 16kHz
val frameDurationMs = (frameSize.toFloat() / sampleRate * 1000).toInt()
val bufferSize = maxOf(
AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
),
sampleRate * 2
)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
).also { it.startRecording() }
isRunning = true
listenerThread = thread(name = "AudioCapture-VAD") {
val frame = ShortArray(frameSize)
val speechBuffer = mutableListOf<ShortArray>()
var speechFrameCount = 0
var silenceFrameCount = 0
var isSpeechActive = false
val silenceFramesNeeded = silenceDurationMs / frameDurationMs
val speechFramesNeeded = speechMinDurationMs / frameDurationMs
while (isRunning) {
val read = audioRecord?.read(frame, 0, frameSize) ?: 0
if (read != frameSize) continue
val isSpeech = try {
vad.isSpeech(frame)
} catch (e: Exception) {
Log.e(TAG, "VAD error", e)
false
}
if (isSpeech) {
silenceFrameCount = 0
speechFrameCount++
speechBuffer.add(frame.copyOf())
if (speechFrameCount >= speechFramesNeeded && !isSpeechActive) {
isSpeechActive = true
}
} else {
if (isSpeechActive) {
silenceFrameCount++
speechBuffer.add(frame.copyOf())
if (silenceFrameCount >= silenceFramesNeeded) {
val fullAudio = speechBuffer.flatMap { it.toList() }.toShortArray()
Log.i(TAG, "Speech segment: ${fullAudio.size} samples")
onSpeechSegment(fullAudio)
speechBuffer.clear()
speechFrameCount = 0
silenceFrameCount = 0
isSpeechActive = false
}
} else {
speechBuffer.clear()
speechFrameCount = 0
}
}
}
}
}
fun stop() {
isRunning = false
listenerThread?.join(1000)
listenerThread = null
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
}
}

View File

@ -0,0 +1,217 @@
package com.kazeia.config
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
/**
* Persistance JSON de la configuration runtime de Kazeia :
* - cascade activée / désactivée
* - modèle Speaker (id du registre)
* - modèle Thinker (id du registre, utilisé seulement si cascade ON)
* - prompt système Speaker
* - prompt système Thinker
* - températures
*
* Stocké dans `<Context.filesDir>/config/runtime.json`. Lu au démarrage,
* écrit par le ContentProvider quand l'admin app fait un update.
*
* Pattern observateur : KazeiaService s'abonne via `addListener` pour
* appliquer les changements à chaud (swap prompt, reload Speaker, etc.).
*/
class ConfigStore private constructor(private val file: File) {
data class ModelConfig(
val modelId: String,
val systemPrompt: String,
val temperature: Float
)
data class RuntimeConfig(
val cascadeEnabled: Boolean,
val speaker: ModelConfig,
val thinker: ModelConfig,
/** Synthèse vocale active. Si false : pas de TTS, réponse affichée
* progressivement (token par token). Pilotable depuis l'app admin. */
val ttsEnabled: Boolean = true,
/** Backend STT : "prod" = WhisperHybridEngine gelé, "lib" = libkazeia_stt
* (Kazeia-Engine unifié, A/B identique sur set 6 audios mais à valider plus large).
* Pilotable depuis l'app admin / provider. Defaults safe = "prod". */
val sttEngine: String = "prod",
/** Backend LLM : "lib" = EngineLlmAdapter (libkazeia_engine + GGUF Qwen3.5,
* q35-lmq4 par défaut) défaut depuis 2026-06-15 : on n'utilise PLUS le
* .pte avec kazeia-engine (directive). "prod" = ExecuTorchLlmEngine (.pte)
* conservé en repli d'urgence uniquement ; sur cette plateforme le runner QNN
* du .pte échoue au load (« Failed to load llm runner [1] »), donc à éviter.
* "lib" valide device : load q35-lmq4.gguf 2.6s + génération FR cohérente. */
val llmEngine: String = "lib",
/** Backend TTS : "lib" = libkazeia_tts unifié (SEUL compatible avec le
* libllama.so fork ql embarqué depuis 2026-06-02), "prod" = Qwen3TtsEngine
* legacy CRASHE (SIGSEGV nativeInit, dérive ABI llama_context_params) tant
* que les .so legacy ne sont pas recompilés contre les headers fork ql.
* "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues, CPU-only)
* câblé mais NON activable tant que la collision libggml des libs livrées
* n'est pas levée côté engine (cf CosyVoiceTtsEngine.kt).
* Default "lib" depuis 2026-06-11 : l'ancien défaut "prod" mettait toute
* install fraîche en crash-loop au démarrage du service. */
val ttsEngine: String = "lib",
/** 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,
/** 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 {
private const val TAG = "ConfigStore"
const val DEFAULT_SPEAKER_PROMPT =
"Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle " +
"simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. " +
"Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : " +
"ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de " +
"médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à " +
"en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou " +
"simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes " +
"échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
const val DEFAULT_THINKER_PROMPT =
"Tu es un psychologue clinicien experimente qui ecoute attentivement ton " +
"patient. Avant de repondre, tu prepares une analyse silencieuse pour " +
"orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict " +
"suivant :\n" +
"- emotion : <emotion principale ressentie, 5 mots max>\n" +
"- besoin : <besoin sous-jacent du patient, 5 mots max>\n" +
"- approche : <posture therapeutique adaptee, 5 mots max>\n" +
"- axe : <point cle a valider ou explorer, 5 mots max>\n" +
"Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"
@Volatile private var INSTANCE: ConfigStore? = null
fun get(context: Context): ConfigStore = INSTANCE ?: synchronized(this) {
INSTANCE ?: build(context).also { INSTANCE = it }
}
private fun build(context: Context): ConfigStore {
val dir = File(context.filesDir, "config").also { it.mkdirs() }
val file = File(dir, "runtime.json")
val store = ConfigStore(file)
store.load()
return store
}
fun defaultConfig(): RuntimeConfig = RuntimeConfig(
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
speaker = ModelConfig(
modelId = ModelRegistry.defaultSpeaker().id,
systemPrompt = DEFAULT_SPEAKER_PROMPT,
temperature = 0.7f
),
thinker = ModelConfig(
modelId = ModelRegistry.defaultThinker().id,
systemPrompt = DEFAULT_THINKER_PROMPT,
temperature = 0.0f
),
ttsEnabled = true,
sttEngine = "prod",
llmEngine = "prod",
ttsEngine = "lib" // legacy "prod" = SIGSEGV avec le libllama fork ql embarqué
)
}
@Volatile private var current: RuntimeConfig = defaultConfig()
private val listeners = CopyOnWriteArrayList<(RuntimeConfig) -> Unit>()
fun current(): RuntimeConfig = current
fun addListener(l: (RuntimeConfig) -> Unit) { listeners.add(l) }
fun load() {
if (!file.exists()) {
Log.i(TAG, "no config file, using defaults")
save(current)
return
}
try {
val text = file.readText()
current = fromJson(text)
Log.i(TAG, "config loaded: cascade=${current.cascadeEnabled} " +
"speaker=${current.speaker.modelId} thinker=${current.thinker.modelId}")
} catch (e: Exception) {
Log.w(TAG, "failed to parse config, using defaults: ${e.message}")
current = defaultConfig()
save(current)
}
}
fun save(cfg: RuntimeConfig) {
try {
file.parentFile?.mkdirs()
file.writeText(toJson(cfg))
current = cfg
listeners.forEach { it(cfg) }
Log.i(TAG, "config saved + listeners notified")
} catch (e: Exception) {
Log.e(TAG, "save failed: ${e.message}")
}
}
private fun toJson(cfg: RuntimeConfig): String {
val root = JSONObject()
root.put("cascade_enabled", cfg.cascadeEnabled)
root.put("tts_enabled", cfg.ttsEnabled)
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("rag_threshold", cfg.ragThreshold.toDouble())
root.put("rag_top_k", cfg.ragTopK)
root.put("speaker", JSONObject().apply {
put("model_id", cfg.speaker.modelId)
put("system_prompt", cfg.speaker.systemPrompt)
put("temperature", cfg.speaker.temperature)
})
root.put("thinker", JSONObject().apply {
put("model_id", cfg.thinker.modelId)
put("system_prompt", cfg.thinker.systemPrompt)
put("temperature", cfg.thinker.temperature)
})
return root.toString(2)
}
private fun fromJson(text: String): RuntimeConfig {
val root = JSONObject(text)
val defaults = defaultConfig()
return RuntimeConfig(
cascadeEnabled = root.optBoolean("cascade_enabled", defaults.cascadeEnabled),
ttsEnabled = root.optBoolean("tts_enabled", defaults.ttsEnabled),
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),
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
speaker = root.optJSONObject("speaker")?.let { js ->
ModelConfig(
modelId = js.optString("model_id", defaults.speaker.modelId),
systemPrompt = js.optString("system_prompt", defaults.speaker.systemPrompt),
temperature = js.optDouble("temperature", defaults.speaker.temperature.toDouble()).toFloat()
)
} ?: defaults.speaker,
thinker = root.optJSONObject("thinker")?.let { js ->
ModelConfig(
modelId = js.optString("model_id", defaults.thinker.modelId),
systemPrompt = js.optString("system_prompt", defaults.thinker.systemPrompt),
temperature = js.optDouble("temperature", defaults.thinker.temperature.toDouble()).toFloat()
)
} ?: defaults.thinker
)
}
}

View File

@ -127,7 +127,9 @@ object ModelRegistry {
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
fun defaultSpeaker(): ModelInfo = byId("qwen3-4b-seq1024")
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque.
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
fun defaultThinker(): ModelInfo = byId("guard06b")

View File

@ -6,6 +6,10 @@ sealed class PipelineState {
object SpeechDetected : PipelineState()
object Transcribing : PipelineState()
data class Transcribed(val text: String) : PipelineState()
/** Résultat Whisper streaming intermédiaire (utilisateur parle encore).
* NE bloque PAS le mic-loop (contrairement à Transcribed) la finalisation
* du segment continue normalement via VAD silence. */
data class StreamingTranscript(val text: String) : PipelineState()
object Thinking : PipelineState()
data class TokenGenerated(val token: String, val fullText: String) : PipelineState()
data class ResponseReady(val text: String) : PipelineState()

View File

@ -0,0 +1,161 @@
package com.kazeia.core
import android.util.Log
/**
* Timing recorder for one userKazeia interaction, from the moment input is
* received (user speech or text) to the end of TTS playback.
*
* All durations are in milliseconds, measured against [t0] which is set to
* the wall-clock when [begin] is called. Each event timestamp is relative
* to t0 so you can immediately see cumulative latency, not just per-stage.
*
* The final summary is a single multi-line log tagged `[PIPELINE]` so you
* can grep it easily :
*
* adb logcat | grep -E "\\[PIPELINE\\]"
*
* The class is thread-safe for the append-then-read pattern used here :
* multiple coroutines mark events from callbacks (TTS seg playing fires
* on a different thread than LLM decode), the summary is printed at the
* end on one thread.
*/
class PipelineMetrics(val tag: String = "Pipeline") {
data class Event(
val name: String,
val tMs: Long,
val meta: String = "",
)
@Volatile var t0: Long = 0L
private set
private val events = java.util.concurrent.CopyOnWriteArrayList<Event>()
private var inputKind: String = ""
private var inputPayload: String = ""
fun begin(kind: String, payload: String) {
t0 = System.currentTimeMillis()
inputKind = kind
inputPayload = payload.take(120)
events.clear()
Log.i(tag, "[PIPELINE] BEGIN kind=$kind payload=\"$inputPayload\"")
}
/** Mark a named event with an optional string payload. Timestamp is
* automatically computed as (now - t0). Fine-grained progress events
* can use [trace] which only logs at DEBUG level. */
fun mark(name: String, meta: String = "") {
val ms = if (t0 > 0) System.currentTimeMillis() - t0 else 0
events += Event(name, ms, meta)
if (meta.isEmpty()) {
Log.i(tag, "[PIPELINE] +${ms.pad(5)}ms $name")
} else {
Log.i(tag, "[PIPELINE] +${ms.pad(5)}ms $name $meta")
}
}
/** Lower-severity progress trace kept in the event list but not in
* the terminal summary table. Use sparingly : one per step at most. */
fun trace(name: String, meta: String = "") {
val ms = if (t0 > 0) System.currentTimeMillis() - t0 else 0
events += Event(name, ms, meta)
Log.d(tag, "[PIPELINE] +${ms.pad(5)}ms $name $meta")
}
/** Called when the interaction is finished (last TTS segment done or
* an error path aborted the pipeline). Emits a tabulated summary. */
fun end(status: String = "ok") {
if (t0 == 0L) return
val total = System.currentTimeMillis() - t0
events += Event("END", total, status)
val sb = StringBuilder()
sb.appendLine("[PIPELINE] ==== END $status total=${total}ms input=$inputKind ====")
sb.appendLine("[PIPELINE] payload=\"$inputPayload\"")
val stageOrder = listOf(
"STT.start", "STT.end",
"LLM.start", "LLM.first_token", "LLM.end",
"TTS.seg.start", "TTS.seg.synth_done", "TTS.seg.play_start", "TTS.seg.play_end",
"PIPELINE.first_audio", "END",
)
// Print every event row in arrival order so per-segment details
// stay interleaved with their respective stages.
sb.appendLine("[PIPELINE] t(ms) event")
sb.appendLine("[PIPELINE] ----- -----")
for (e in events) {
val metaStr = if (e.meta.isEmpty()) "" else " (${e.meta})"
sb.append("[PIPELINE] ")
.append(e.tMs.pad(5))
.append(" ")
.append(e.name)
.append(metaStr)
.append('\n')
}
// Derived KPIs that matter for perceived latency.
val sttEnd = firstT("STT.end")
val llmStart = firstT("LLM.start")
val llmFirstTok = firstT("LLM.first_token")
val llmEnd = firstT("LLM.end")
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
val lastAudio = lastT("TTS.seg.play_end")
sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----")
if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd")
if (llmStart != null && llmFirstTok != null)
sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}")
if (llmStart != null && llmEnd != null)
sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}")
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long")
if (firstAudio != null && lastAudio != null)
sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}")
sb.appendLine("[PIPELINE] Total wall time : $total")
// Single log call so it shows up contiguously even if the logger
// interleaves other threads.
Log.i(tag, sb.toString().trimEnd())
// Push a structured snapshot to TelemetryHolder so the admin app
// can pull it via ContentProvider. Best-effort — if anything goes
// wrong here it must not affect the pipeline.
try {
val tokensMatch = events.firstOrNull { it.name == "LLM.end" }?.meta
?.let { Regex("tokens=(\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
com.kazeia.telemetry.TelemetryHolder.pushTurn(
com.kazeia.telemetry.TelemetryHolder.TurnSnapshot(
timestamp = System.currentTimeMillis(),
inputKind = inputKind,
status = status,
totalMs = total,
sttDurationMs = sttEnd,
llmTtftMs = if (llmStart != null && llmFirstTok != null)
llmFirstTok - llmStart else null,
llmGenerationMs = if (llmStart != null && llmEnd != null)
llmEnd - llmStart else null,
llmTokens = tokensMatch,
timeToFirstAudioMs = firstAudio,
audioPlaybackMs = if (firstAudio != null && lastAudio != null)
lastAudio - firstAudio else null
)
)
} catch (e: Throwable) {
Log.w(tag, "TelemetryHolder push failed: ${e.message}")
}
// Reset state so the next interaction starts clean.
t0 = 0
events.clear()
inputKind = ""
inputPayload = ""
}
private fun firstT(name: String): Long? = events.firstOrNull { it.name == name }?.tMs
private fun lastT(name: String): Long? = events.lastOrNull { it.name == name }?.tMs
private fun Long.pad(w: Int): String {
val s = this.toString()
return if (s.length >= w) s else " ".repeat(w - s.length) + s
}
}

View File

@ -1,11 +0,0 @@
package com.kazeia.core
import android.content.Context
interface VadEngine {
fun load(context: Context)
fun isLoaded(): Boolean
fun isSpeech(frame: ShortArray): Boolean
fun resetState()
fun release()
}

View File

@ -0,0 +1,40 @@
package com.kazeia.dist
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
/**
* Reçoit le statut d'une session [PackageInstaller] (self-update APK).
*
* Le cas important est [PackageInstaller.STATUS_PENDING_USER_ACTION] : Android
* fournit alors un Intent de confirmation à présenter à l'utilisateur (le
* dialogue système « Installer la mise à jour ? »). On le lance.
*/
class ApkInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
@Suppress("DEPRECATION")
val confirm = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
confirm?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(confirm) }
.onFailure { Log.w(TAG, "confirm intent: ${it.message}") }
}
PackageInstaller.STATUS_SUCCESS ->
Log.i(TAG, "mise à jour APK installée")
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.w(TAG, "install APK statut=$status : $msg")
}
}
}
companion object {
private const val TAG = "ApkInstallReceiver"
const val ACTION = "com.kazeia.action.APK_INSTALL_STATUS"
}
}

View File

@ -0,0 +1,81 @@
package com.kazeia.dist
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
import com.kazeia.BuildConfig
import java.io.File
/**
* Auto-mise à jour de l'APK Kazeia (distribution hors-store).
*
* Le catalogue porte une section `app` (version, URL, SHA-256). Si elle
* annonce une version plus récente que celle installée, on télécharge l'APK
* et on lance une session [PackageInstaller] l'utilisateur confirme le
* dialogue système. Nécessite la permission `REQUEST_INSTALL_PACKAGES`.
*/
class ApkUpdater(private val context: Context) {
/** [AppRelease] du catalogue si une version plus récente est dispo, sinon null. */
fun availableUpdate(catalog: Catalog): AppRelease? {
val app = catalog.app ?: return null
return if (app.versionCode > BuildConfig.VERSION_CODE) app else null
}
/** Télécharge l'APK dans le cache et vérifie son SHA-256. */
suspend fun downloadApk(
release: AppRelease,
onProgress: (Long, Long) -> Unit
): File? {
val rf = RemoteFile(
remote = release.remote,
local = APK_NAME,
size = release.size,
sha256 = release.sha256
)
val downloader = ModelDownloader(KazeiaDistClient(context), context.cacheDir)
return when (downloader.download(rf, onProgress)) {
is ModelDownloader.Result.Success -> File(context.cacheDir, APK_NAME)
is ModelDownloader.Result.Failed -> null
}
}
/**
* Lance l'installation de l'APK via [PackageInstaller]. Le système
* affiche un dialogue de confirmation (cf [ApkInstallReceiver]).
*/
fun install(apk: File): Boolean = runCatching {
val installer = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
val sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
apk.inputStream().use { input ->
session.openWrite(APK_NAME, 0, apk.length()).use { output ->
input.copyTo(output, 1 shl 16)
session.fsync(output)
}
}
val statusIntent = Intent(ApkInstallReceiver.ACTION)
.setPackage(context.packageName)
val pending = PendingIntent.getBroadcast(
context, sessionId, statusIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pending.intentSender)
}
Log.i(TAG, "session install APK committée")
true
}.getOrElse {
Log.e(TAG, "install APK échec: ${it.message}")
false
}
companion object {
private const val TAG = "ApkUpdater"
private const val APK_NAME = "kazeia-update.apk"
}
}

View File

@ -0,0 +1,112 @@
package com.kazeia.dist
import org.json.JSONObject
/**
* Catalogue de distribution Kazeia source de vérité hébergée sur le
* Nextcloud (`soft/catalog.json`). Décrit l'APK courante + les composants
* de modèles (LLM / STT / TTS) à télécharger.
*
* Voir `project_kazeia_installer_updates` en mémoire pour le contexte.
*/
/** Un fichier distant : chemin WebDAV relatif + cible locale + intégrité. */
data class RemoteFile(
/** Chemin relatif à `BuildConfig.WEBDAV_BASE` (ex. `models/llm/x.pte`). */
val remote: String,
/** Chemin relatif au répertoire modèles de l'app (ex. `llm/x.pte`). */
val local: String,
val size: Long,
val sha256: String
) {
companion object {
fun fromJson(o: JSONObject) = RemoteFile(
remote = o.getString("remote"),
local = o.getString("local"),
size = o.getLong("size"),
sha256 = o.getString("sha256").lowercase()
)
}
}
/** Un composant logique versionné (ex. `llm_speaker`, `tts_talker`). */
data class Component(
val id: String,
val label: String,
val version: String,
val mandatory: Boolean,
val files: List<RemoteFile>
) {
val totalSize: Long get() = files.sumOf { it.size }
companion object {
fun fromJson(o: JSONObject): Component {
val filesArr = o.getJSONArray("files")
val files = (0 until filesArr.length())
.map { RemoteFile.fromJson(filesArr.getJSONObject(it)) }
return Component(
id = o.getString("id"),
label = o.optString("label", o.getString("id")),
version = o.getString("version"),
mandatory = o.optBoolean("mandatory", true),
files = files
)
}
}
}
/** Métadonnées de l'APK la plus récente disponible (self-update). */
data class AppRelease(
val versionCode: Int,
val versionName: String,
val remote: String,
val size: Long,
val sha256: String
) {
companion object {
fun fromJson(o: JSONObject) = AppRelease(
versionCode = o.getInt("version_code"),
versionName = o.getString("version_name"),
remote = o.getString("remote"),
size = o.getLong("size"),
sha256 = o.getString("sha256").lowercase()
)
}
}
data class Catalog(
val schema: Int,
val catalogVersion: Int,
val generatedAt: String,
val minAppVersionCode: Int,
val app: AppRelease?,
val components: List<Component>
) {
val totalSize: Long get() = components.sumOf { it.totalSize }
fun mandatoryComponents(): List<Component> = components.filter { it.mandatory }
fun component(id: String): Component? = components.firstOrNull { it.id == id }
companion object {
/** Version de schéma comprise par cette version de l'app. */
const val SUPPORTED_SCHEMA = 1
/** Parse le JSON du catalogue. Lève une exception si malformé. */
fun parse(json: String): Catalog {
val o = JSONObject(json)
val schema = o.getInt("schema")
val compArr = o.getJSONArray("components")
val components = (0 until compArr.length())
.map { Component.fromJson(compArr.getJSONObject(it)) }
return Catalog(
schema = schema,
catalogVersion = o.getInt("catalog_version"),
generatedAt = o.optString("generated_at", ""),
minAppVersionCode = o.optInt("min_app_version_code", 0),
app = o.optJSONObject("app")?.let { AppRelease.fromJson(it) },
components = components
)
}
}
}

View File

@ -0,0 +1,77 @@
package com.kazeia.dist
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.kazeia.BuildConfig
/** Configuration WebDAV de distribution (serveur Nextcloud). */
data class DistConfig(val base: String, val user: String, val pass: String)
/**
* Stockage runtime de la config WebDAV, modifiable depuis l'app admin.
*
* `BuildConfig.WEBDAV_*` (issu de `local.properties`) sert de **défaut
* d'usine** : l'APK fonctionne dès l'installation. Dès que l'admin enregistre
* une config, l'override runtime (chiffré, EncryptedSharedPreferences) prime.
*
* Identifiants sensibles chiffrés par une clef Android Keystore.
*/
object DistConfigStore {
private const val TAG = "DistConfigStore"
private const val PREFS = "kazeia_dist_config"
private const val K_BASE = "webdav_base"
private const val K_USER = "webdav_user"
private const val K_PASS = "webdav_pass"
private fun prefs(context: Context): SharedPreferences {
val master = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context.applicationContext,
PREFS,
master,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/** Config courante : override runtime si défini, sinon défaut d'usine. */
fun get(context: Context): DistConfig {
val p = prefs(context)
return DistConfig(
base = p.getString(K_BASE, null)?.takeIf { it.isNotBlank() }
?: BuildConfig.WEBDAV_BASE,
user = p.getString(K_USER, null)?.takeIf { it.isNotBlank() }
?: BuildConfig.WEBDAV_USER,
pass = p.getString(K_PASS, null)?.takeIf { it.isNotBlank() }
?: BuildConfig.WEBDAV_PASS
)
}
/** Vrai si une config runtime a été enregistrée (≠ défaut d'usine). */
fun isCustomized(context: Context): Boolean = prefs(context).contains(K_BASE)
/**
* Enregistre une config. `pass == null` mot de passe inchangé
* (l'admin n'a pas re-saisi le mot de passe).
*/
fun update(context: Context, base: String, user: String, pass: String?) {
prefs(context).edit().apply {
putString(K_BASE, base.trim())
putString(K_USER, user.trim())
if (pass != null) putString(K_PASS, pass)
}.apply()
Log.i(TAG, "config WebDAV mise à jour (base=${base.trim()})")
}
/** Réinitialise au défaut d'usine ([BuildConfig]). */
fun reset(context: Context) {
prefs(context).edit().clear().apply()
Log.i(TAG, "config WebDAV réinitialisée au défaut d'usine")
}
}

View File

@ -0,0 +1,103 @@
package com.kazeia.dist
import android.content.Context
import android.util.Log
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.TimeUnit
/**
* Client WebDAV pour le Nextcloud de distribution Kazeia (`box.kazeia.com`).
*
* Lecture seule en pratique : récupération du catalogue + des fichiers de
* modèles. L'authentification Basic et l'URL de base viennent de
* `BuildConfig` (injectés depuis `local.properties`, jamais commités).
*
* Le moteur de téléchargement résumable (Range, SHA-256, WorkManager) est
* construit par-dessus [openStream].
*
* La config WebDAV (URL + identifiants) est lue depuis [DistConfigStore] à la
* construction éditable depuis l'app admin, défaut d'usine = `BuildConfig`.
*/
class KazeiaDistClient(context: Context) {
private val config: DistConfig = DistConfigStore.get(context)
private val base: String = config.base.trimEnd('/') + "/"
private val auth: String = Credentials.basic(config.user, config.pass)
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
// Pas de timeout global : les fichiers modèles font plusieurs Go.
.callTimeout(0, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(true)
.build()
/** URL absolue d'un chemin distant relatif à la base de distribution. */
fun urlFor(remote: String): String = base + remote.trimStart('/')
private fun newRequest(remote: String): Request.Builder =
Request.Builder()
.url(urlFor(remote))
.header("Authorization", auth)
/** Vérifie que le serveur répond et que les identifiants sont valides. */
fun probe(): Boolean = runCatching {
http.newCall(newRequest("").head().build()).execute().use { it.isSuccessful }
}.getOrDefault(false)
/** GET texte (catalogue, manifestes). `null` si échec réseau/HTTP. */
fun fetchText(remote: String): String? = runCatching {
http.newCall(newRequest(remote).get().build()).execute().use { resp ->
if (resp.isSuccessful) resp.body?.string() else {
Log.w(TAG, "fetchText $remote → HTTP ${resp.code}")
null
}
}
}.getOrElse { Log.w(TAG, "fetchText $remote failed: ${it.message}"); null }
/**
* Récupère et parse un catalogue (modèles par défaut, ou voix).
* `null` si injoignable ou malformé.
*/
fun fetchCatalog(path: String = CATALOG_PATH): Catalog? {
val txt = fetchText(path) ?: return null
return runCatching { Catalog.parse(txt) }
.onFailure { Log.w(TAG, "catalogue $path malformé: ${it.message}") }
.getOrNull()
}
/**
* Ouvre un flux GET sur un fichier distant, avec reprise optionnelle.
* L'appelant DOIT fermer la [Response]. Utilisé par le moteur de
* téléchargement (phase 2).
*
* @param offsetBytes octets déjà téléchargés localement ; si > 0, un
* en-tête `Range` est envoyé pour reprendre.
*/
fun openStream(remote: String, offsetBytes: Long = 0): Response {
val req = newRequest(remote).get().apply {
if (offsetBytes > 0) header("Range", "bytes=$offsetBytes-")
}.build()
return http.newCall(req).execute()
}
/** Taille distante d'un fichier (`Content-Length` via HEAD), ou -1. */
fun remoteSize(remote: String): Long = runCatching {
http.newCall(newRequest(remote).head().build()).execute().use { resp ->
if (resp.isSuccessful)
resp.header("Content-Length")?.toLongOrNull() ?: -1L
else -1L
}
}.getOrDefault(-1L)
companion object {
private const val TAG = "KazeiaDistClient"
/** Catalogue modèles, relatif à la base de distribution. */
const val CATALOG_PATH = "catalog.json"
/** Catalogue voix (dynamique, cycle de vie séparé). */
const val VOICES_CATALOG_PATH = "voices_catalog.json"
}
}

View File

@ -0,0 +1,109 @@
package com.kazeia.dist
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import java.io.File
import java.io.RandomAccessFile
import kotlin.coroutines.coroutineContext
/**
* Télécharge un fichier de modèle ([RemoteFile]) de façon robuste :
*
* - **reprise** : écriture dans `<cible>.part`, l'octet de reprise est la
* taille du `.part` (en-tête HTTP `Range`) ;
* - **vérification** : SHA-256 contrôlé avant toute mise en service ;
* - **atomicité** : `rename` du `.part` vers la cible seulement après
* vérification jamais de modèle à moitié écrit chargé en production.
*
* Toutes les écritures vont sous [root] (= `getExternalFilesDir("kazeia")`),
* le chemin `RemoteFile.local` étant relatif à cette racine.
*/
class ModelDownloader(
private val client: KazeiaDistClient,
private val root: File
) {
sealed class Result {
object Success : Result()
data class Failed(val reason: String) : Result()
}
/**
* @param onProgress (octetsTéléchargés, octetsTotal) appelé en continu.
*/
suspend fun download(file: RemoteFile, onProgress: (Long, Long) -> Unit): Result {
val target = File(root, file.local)
target.parentFile?.mkdirs()
// Déjà présent et intègre → rien à faire.
if (target.exists() && target.length() == file.size &&
runCatching { Sha256.ofFile(target) == file.sha256 }.getOrDefault(false)
) {
onProgress(file.size, file.size)
return Result.Success
}
val part = File(target.parentFile, target.name + ".part")
var offset = if (part.exists()) part.length() else 0L
if (offset > file.size) { // .part corrompu / catalogue changé
part.delete(); offset = 0L
}
try {
client.openStream(file.remote, offset).use { resp ->
if (!resp.isSuccessful) return Result.Failed("HTTP ${resp.code}")
// Range demandé mais serveur renvoie 200 (full) → on repart de 0.
if (offset > 0 && resp.code != 206) {
part.delete(); offset = 0L
}
val body = resp.body ?: return Result.Failed("réponse vide")
RandomAccessFile(part, "rw").use { raf ->
raf.seek(offset)
body.byteStream().use { ins ->
val buf = ByteArray(1 shl 16)
var done = offset
onProgress(done, file.size)
while (true) {
coroutineContext.ensureActive() // annulation propre
val n = ins.read(buf)
if (n < 0) break
raf.write(buf, 0, n)
done += n
onProgress(done, file.size)
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "download ${file.local} interrompu: ${e.message}")
return Result.Failed(e.message ?: "erreur réseau")
}
// Vérification taille + SHA-256 avant mise en service.
if (part.length() != file.size) {
return Result.Failed("taille ${part.length()}${file.size}")
}
val sha = runCatching { Sha256.ofFile(part) }.getOrElse {
return Result.Failed("SHA illisible: ${it.message}")
}
if (!sha.equals(file.sha256, ignoreCase = true)) {
part.delete()
return Result.Failed("SHA-256 incorrect (fichier corrompu)")
}
// Rename atomique.
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
return Result.Failed("rename ${part.name}${target.name} échoué")
}
Log.i(TAG, "${file.local} (${file.size} o)")
return Result.Success
}
companion object {
private const val TAG = "ModelDownloader"
}
}

View File

@ -0,0 +1,127 @@
package com.kazeia.dist
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.kazeia.KazeiaPaths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Orchestrateur du provisionnement modèles : récupère le catalogue, calcule
* le diff avec l'état local, télécharge les composants manquants ou périmés,
* et met à jour [ProvisioningState].
*
* Les fichiers sont écrits sous `getExternalFilesDir("kazeia")` ; les chemins
* `RemoteFile.local` sont relatifs à cette racine (`models/...`, `llm/...`)
* et correspondent à la résolution de [KazeiaPaths] sur tablette de production.
*/
class ProvisioningManager(context: Context) {
private val appContext = context.applicationContext
private val client = KazeiaDistClient(appContext)
/** Racine d'écriture des modèles téléchargés. */
val root: File = File(appContext.getExternalFilesDir(null), "kazeia")
private val downloader = ModelDownloader(client, root)
/** Progression rapportée pendant le provisionnement. */
data class Progress(
val componentLabel: String,
val currentFile: String,
val overallDone: Long,
val overallTotal: Long
)
sealed class Outcome {
object Success : Outcome()
data class Failed(val reason: String) : Outcome()
}
fun probe(): Boolean = client.probe()
suspend fun fetchCatalog(): Catalog? = withContext(Dispatchers.IO) { client.fetchCatalog() }
/**
* Catalogue voix optionnel et dynamique (le clinicien ajoute des voix
* sans toucher au catalogue modèles figé). `null` si absent du Nextcloud.
*/
suspend fun fetchVoicesCatalog(): Catalog? = withContext(Dispatchers.IO) {
client.fetchCatalog(KazeiaDistClient.VOICES_CATALOG_PATH)
}
/** Espace libre (octets) sur le volume de stockage des modèles. */
fun freeBytes(): Long = runCatching {
root.mkdirs()
StatFs(root.path).availableBytes
}.getOrDefault(0L)
/**
* Heuristique hors-ligne : les modèles cœur sont-ils présents sur le
* disque résolu par [KazeiaPaths] ? Vrai sur tablette de dev (legacy
* `/data/local/tmp` peuplé) comme sur tablette de production déjà
* provisionnée permet à l'onboarding de court-circuiter sans réseau.
*/
fun hasCoreModels(): Boolean {
val llm = File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b.pte").exists() ||
File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b_seq1024.pte").exists()
val talker = File(KazeiaPaths.modelsDir, "talker_f32.gguf").exists() ||
File(KazeiaPaths.modelsDir, "talker_f16.gguf").exists()
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
return llm && talker && whisper
}
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */
fun pending(catalog: Catalog): List<Component> =
ProvisioningState.load(appContext).missingOrOutdated(catalog)
/**
* Télécharge tous les composants manquants/périmés du catalogue.
* Met à jour [ProvisioningState] après CHAQUE composant complet un
* arrêt en cours de route ne reperd que le composant courant.
*/
suspend fun provision(
catalog: Catalog,
onProgress: (Progress) -> Unit
): Outcome = withContext(Dispatchers.IO) {
val state = ProvisioningState.load(appContext)
val todo = state.missingOrOutdated(catalog)
if (todo.isEmpty()) return@withContext Outcome.Success
val overallTotal = todo.sumOf { it.totalSize }
var overallBase = 0L
for (comp in todo) {
for (rf in comp.files) {
val res = downloader.download(rf) { done, _ ->
onProgress(
Progress(
componentLabel = comp.label,
currentFile = rf.local.substringAfterLast('/'),
overallDone = overallBase + done,
overallTotal = overallTotal
)
)
}
if (res is ModelDownloader.Result.Failed) {
Log.w(TAG, "provision échec sur ${rf.local}: ${res.reason}")
return@withContext Outcome.Failed("${rf.local} : ${res.reason}")
}
overallBase += rf.size
}
// Composant complet et vérifié → état persisté.
state.markInstalled(comp.id, comp.version)
state.catalogVersion = catalog.catalogVersion
state.lastCheck = System.currentTimeMillis()
ProvisioningState.save(appContext, state)
Log.i(TAG, "composant « ${comp.id} » v${comp.version} installé")
}
Outcome.Success
}
companion object {
private const val TAG = "ProvisioningManager"
}
}

View File

@ -0,0 +1,125 @@
package com.kazeia.dist
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
/**
* État local de provisionnement miroir, côté tablette, de ce qui est
* réellement installé et vérifié. Comparé au [Catalog] distant pour calculer
* le diff de mise à jour.
*
* Fichier : `<externalFilesDir>/provisioning_state.json`.
*/
/** État installé d'un composant. */
data class ComponentState(
val installedVersion: String,
val complete: Boolean,
val installedAt: Long
) {
fun toJson(): JSONObject = JSONObject().apply {
put("installed_version", installedVersion)
put("complete", complete)
put("installed_at", installedAt)
}
companion object {
fun fromJson(o: JSONObject) = ComponentState(
installedVersion = o.getString("installed_version"),
complete = o.optBoolean("complete", false),
installedAt = o.optLong("installed_at", 0)
)
}
}
class ProvisioningState private constructor(
var schema: Int,
var catalogVersion: Int,
var lastCheck: Long,
private val components: MutableMap<String, ComponentState>
) {
fun componentState(id: String): ComponentState? = components[id]
/** Marque un composant comme installé et vérifié. */
fun markInstalled(id: String, version: String) {
components[id] = ComponentState(
installedVersion = version,
complete = true,
installedAt = System.currentTimeMillis()
)
}
fun forget(id: String) { components.remove(id) }
/**
* Composants du catalogue manquants ou périmés : présents mais pas
* `complete`, version différente, ou jamais installés.
*/
fun missingOrOutdated(catalog: Catalog): List<Component> =
catalog.components.filter { c ->
val s = components[c.id]
s == null || !s.complete || s.installedVersion != c.version
}
/** Vrai si tous les composants OBLIGATOIRES du catalogue sont à jour. */
fun isReady(catalog: Catalog): Boolean =
catalog.mandatoryComponents().none { c ->
val s = components[c.id]
s == null || !s.complete || s.installedVersion != c.version
}
fun toJson(): JSONObject = JSONObject().apply {
put("schema", schema)
put("catalog_version", catalogVersion)
put("last_check", lastCheck)
put("components", JSONObject().apply {
components.forEach { (id, st) -> put(id, st.toJson()) }
})
}
companion object {
private const val TAG = "ProvisioningState"
const val SCHEMA = 1
private const val FILE_NAME = "provisioning_state.json"
fun file(context: Context): File =
File(context.getExternalFilesDir(null), FILE_NAME)
fun load(context: Context): ProvisioningState {
val f = file(context)
if (!f.exists()) return empty()
return try {
val o = JSONObject(f.readText())
val compObj = o.optJSONObject("components") ?: JSONObject()
val comps = mutableMapOf<String, ComponentState>()
compObj.keys().forEach { id ->
comps[id] = ComponentState.fromJson(compObj.getJSONObject(id))
}
ProvisioningState(
schema = o.optInt("schema", SCHEMA),
catalogVersion = o.optInt("catalog_version", 0),
lastCheck = o.optLong("last_check", 0),
components = comps
)
} catch (e: Exception) {
Log.w(TAG, "parse failed, repartir de zéro: ${e.message}")
empty()
}
}
fun save(context: Context, state: ProvisioningState) {
try {
val f = file(context)
f.parentFile?.mkdirs()
f.writeText(state.toJson().toString(2))
} catch (e: Exception) {
Log.e(TAG, "save failed: ${e.message}")
}
}
private fun empty() = ProvisioningState(SCHEMA, 0, 0, mutableMapOf())
}
}

View File

@ -0,0 +1,127 @@
package com.kazeia.dist
import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.kazeia.KazeiaApplication
import com.kazeia.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.coroutineScope
/**
* Worker WorkManager qui exécute le provisionnement modèles en foreground
* (notification de progression, survit au backgrounding de l'app).
*
* Progression publiée via [setProgress] observée par `OnboardingActivity`.
* La boucle de téléchargement (par chunk 64 ko) écrit dans une variable
* volatile ; un coroutine séparé échantillonne toutes les 500 ms pour ne pas
* saturer la base WorkManager ni le NotificationManager.
*/
class ProvisioningWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
private val manager = ProvisioningManager(appContext)
@Volatile private var latest: ProvisioningManager.Progress? = null
override suspend fun getForegroundInfo(): ForegroundInfo =
foregroundInfo(buildNotification("Préparation de Kazeia…", 0, 0))
override suspend fun doWork(): Result = coroutineScope {
val modelsCatalog = manager.fetchCatalog()
?: return@coroutineScope Result.failure(
workDataOf(KEY_ERROR to "Catalogue injoignable — vérifiez la connexion."))
// Catalogue voix : optionnel — absent du Nextcloud ⇒ null, on l'ignore.
val voicesCatalog = manager.fetchVoicesCatalog()
// Rien à (re)télécharger → succès silencieux, pas de notification
// foreground (utile pour le check de mise à jour en arrière-plan).
val nothingPending = manager.pending(modelsCatalog).isEmpty() &&
(voicesCatalog == null || manager.pending(voicesCatalog).isEmpty())
if (nothingPending) return@coroutineScope Result.success()
setForeground(getForegroundInfo())
// Échantillonneur de progression (découple la boucle tight du setProgress).
val sampler = launch {
while (isActive) {
latest?.let { p -> publish(p) }
delay(500)
}
}
val result = try {
// 1. Modèles — critique : un échec fait échouer le worker.
val models = manager.provision(modelsCatalog) { p -> latest = p }
if (models is ProvisioningManager.Outcome.Failed) {
Result.failure(workDataOf(KEY_ERROR to models.reason))
} else {
// 2. Voix — best-effort : un échec est loggé mais non bloquant.
if (voicesCatalog != null) {
val voices = manager.provision(voicesCatalog) { p -> latest = p }
if (voices is ProvisioningManager.Outcome.Failed) {
android.util.Log.w("ProvisioningWorker",
"voix (best-effort) échec : ${voices.reason}")
}
}
latest?.let { publish(it) }
Result.success()
}
} finally {
sampler.cancel()
}
result
}
private suspend fun publish(p: ProvisioningManager.Progress) {
setProgress(workDataOf(
KEY_LABEL to p.componentLabel,
KEY_FILE to p.currentFile,
KEY_DONE to p.overallDone,
KEY_TOTAL to p.overallTotal
))
val pct = if (p.overallTotal > 0)
(p.overallDone * 100 / p.overallTotal).toInt() else 0
runCatching {
setForeground(foregroundInfo(
buildNotification("Téléchargement : ${p.componentLabel}", pct, 100)))
}
}
private fun foregroundInfo(notification: Notification): ForegroundInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else
ForegroundInfo(NOTIF_ID, notification)
private fun buildNotification(text: String, progress: Int, max: Int): Notification =
NotificationCompat.Builder(applicationContext, KazeiaApplication.CHANNEL_ID)
.setContentTitle("Installation de Kazeia")
.setContentText(text)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setOnlyAlertOnce(true)
.apply { if (max > 0) setProgress(max, progress, false) }
.build()
companion object {
const val NAME = "kazeia_provisioning"
private const val NOTIF_ID = 4242
const val KEY_LABEL = "label"
const val KEY_FILE = "file"
const val KEY_DONE = "done"
const val KEY_TOTAL = "total"
const val KEY_ERROR = "error"
}
}

View File

@ -0,0 +1,22 @@
package com.kazeia.dist
import java.io.File
import java.security.MessageDigest
/** Calcul SHA-256 en streaming — vérification d'intégrité des modèles. */
object Sha256 {
/** Hex minuscule du SHA-256 d'un fichier. Lève si le fichier est illisible. */
fun ofFile(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered(1 shl 16).use { ins ->
val buf = ByteArray(1 shl 16)
while (true) {
val n = ins.read(buf)
if (n < 0) break
md.update(buf, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it) }
}
}

View File

@ -0,0 +1,45 @@
// Harness LLM ADB-pilotable, mirror de BenchQnnHarness STT.
// Déclenchement : `touch <files>/bench_llm.trigger`, relance app.
// Charge EngineLlmEngine (lib CPU-only) + génère 3 prompts FR, log texte + tps + nThreads honoré.
package com.kazeia.llm
import android.content.Context
import android.util.Log
import com.kazeia.KazeiaApplication
object BenchLlmHarness {
private const val TAG = "BenchLlm"
private val PROMPTS = listOf(
"Bonsoir, je me sens seul",
"J'ai du mal à dormir",
"Je n'arrive plus à parler à ma femme"
)
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple."
fun runBench(ctx: Context) {
val gguf = "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
Log.i(TAG, "=== load q35-lmq4 ===")
val t0 = System.currentTimeMillis()
val eng = try {
EngineLlmEngine(gguf, ctx = 2048, nThreads = 6)
} catch (e: Throwable) {
Log.e(TAG, "load FAIL: ${e.message}"); return
}
Log.i(TAG, "load OK (${System.currentTimeMillis() - t0}ms)")
// warmup
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {}
for ((i, p) in PROMPTS.withIndex()) {
val ts = System.currentTimeMillis()
val out = try { eng.generate(SYS, p, 96) } catch (e: Exception) { "[ERR ${e.message}]" }
val ms = System.currentTimeMillis() - ts
val nWords = out.trim().split(Regex("\\s+")).size
val tps = if (ms > 0) nWords * 1000f / ms else 0f
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
Log.i(TAG, " '${out.trim()}'")
}
eng.release()
Log.i(TAG, "=== Bench end ===")
}
}

View File

@ -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,41 @@ 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 <think> (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 (garde le system prefillé). À appeler aux
* bornes de conversation (changement de patient, effacement du chat) sinon
* la mémoire d'un patient fuiterait vers le suivant. sessionReset natif corrigé
* 2026-06-15 (clear + re-prefill system sur archi hybride le rewind partiel
* échoue), donc on l'utilise directement. */
fun resetSession() { session?.reset() }
override fun release() { session = null; engine?.release(); engine = null }
}

View File

@ -0,0 +1,140 @@
package com.kazeia.llm
// Remplace ExecuTorchLlmEngine. Option C: prefill HTP / decode CPU (géré côté natif).
// Mono-tour: generate(sys, usr). Multi-tour: ChatSession (gère l'historique + le template ChatML).
class EngineJni {
/**
* @param nThreads decode CPU threads. Défaut 0 -> 6 (sweet spot Snapdragon 8 Elite,
* mémoire r3). Recommandations : 6 pour Speaker 4B/9B in-app, 4 si cascade
* simultanée Speaker+Thinker pour laisser cœurs au Thinker.
*/
external fun load(ggufPath: String, nCtx: Int, nThreads: Int): Long
external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String
external fun generateRaw(h: Long, prompt: String, maxTok: Int): String // prompt complet déjà formaté
external fun reset(h: Long)
external fun free(h: 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
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
// pipeline Talker→CP→Decoder. Retours conventionnels : 0 = OK, négatif = erreur.
external fun nEmbd(h: Long): Int // taille d'un embed (1024 pour Talker-0.6B)
external fun nVocab(h: Long): Int // 3072 pour le Talker
external fun resetEmbeds(h: Long) // KV clear + pos=0, à appeler en début de génération TTS
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)
init { require(h != 0L) { "Kazeia-Engine: échec du chargement du modèle ($model)" } }
// Mono-tour : system + un message user.
fun generate(sys: String, usr: String, max: Int = 96) = jni.generate(h, sys, usr, max)
// 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) {
private val turns = StringBuilder() // "<|im_start|>role\ntext<|im_end|>\n" accumulés
fun ask(user: String, max: Int = 96): String {
val prompt = buildString {
append("<|im_start|>system\n").append(system).append("<|im_end|>\n")
append(turns)
append("<|im_start|>user\n").append(user).append("<|im_end|>\n")
append("<|im_start|>assistant\n<think>\n\n</think>\n\n") // thinking OFF déterministe
}
val resp = engine.generateRaw(prompt, max)
turns.append("<|im_start|>user\n").append(user).append("<|im_end|>\n")
.append("<|im_start|>assistant\n").append(resp).append("<|im_end|>\n")
return resp
}
fun clear() { turns.setLength(0) }
}

View File

@ -26,21 +26,44 @@ import org.pytorch.executorch.extension.llm.LlmModule
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
*/
/**
* Cascade Option E (in-process multi-LlmModule) :
* Le même process app peut instancier 2 ExecuTorchLlmEngine
* (Speaker + Thinker) qui partagent automatiquement le `QnnBackendBundle`
* via le singleton `QnnBackendUnifiedRegistry` (cf
* executorch/backends/qualcomm/runtime/backends/QnnBackendUnifiedRegistry.cpp).
* Aucune contention DSP chaque instance crée son propre `QnnContext` mais
* réutilise le même handle fastrpc.
*/
class ExecuTorchLlmEngine(
private val context: Context,
private val onLog: ((String) -> Unit)? = null
private val onLog: ((String) -> Unit)? = null,
private val modelPath: String = DEFAULT_MODEL_PATH,
private val tokenizerPath: String = DEFAULT_TOKENIZER_PATH,
private val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
private val temperature: Float = 0.7f,
private val displayName: String = "Qwen3-4B Speaker"
) : LlmEngine {
companion object {
private const val TAG = "ExecuTorchLLM"
// /no_think disables Qwen3's chain-of-thought block. Compact wording
// keeps prefill cost low: this prompt is ~25 tokens vs ~55 in the
// earlier verbose version → saves ~1.5 s of TTFT in kv-only mode.
private const val SYSTEM_PROMPT = "Tu es Kazeia, à l'écoute en français. Réponds en 1-2 phrases courtes, sans raisonnement. /no_think"
// /no_think disables Qwen3's chain-of-thought block.
// UNE OU DEUX phrases courtes (5-8 mots) — l'utilisateur veut des réponses
// brèves. Validation émotionnelle, pas de remplissage. La 1ʳᵉ phrase est
// synthétisée pendant que la 2ᵉ est générée → audio démarre tôt.
const val DEFAULT_SYSTEM_PROMPT = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
private const val MODEL_DIR = "/data/local/tmp/kazeia-et"
private const val MODEL_PATH = "$MODEL_DIR/hybrid_llama_qnn.pte"
private const val TOKENIZER_PATH = "$MODEL_DIR/tokenizer.json"
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
// sur tablette dev, stockage externe app sinon). Fichier régulier, pas
// de symlink : SELinux ColorOS sans Magisk bloque l'accès app aux
// symlinks dans /data/local/tmp/. cf feedback_selinux_symlink.
// Test 2026-05-13 : Speaker seq=1024 (re-export 2026-05-04).
val MODEL_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
val DEFAULT_MODEL_PATH: String get() = "$MODEL_DIR/hybrid_llama_qnn_4b_seq1024.pte"
val DEFAULT_TOKENIZER_PATH: String get() = "$MODEL_DIR/tokenizer.json"
// seqLen = budget TOTAL (prefill + decode). Doit correspondre au
// max_seq_len avec lequel le .pte a été compilé.
const val MODEL_SEQ_LEN = 1024
}
private var llmModule: LlmModule? = null
@ -54,12 +77,12 @@ class ExecuTorchLlmEngine(
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
if (!File(MODEL_PATH).exists()) {
nlog("ERROR: model not found at $MODEL_PATH")
if (!File(this@ExecuTorchLlmEngine.modelPath).exists()) {
nlog("ERROR: model not found at ${this@ExecuTorchLlmEngine.modelPath}")
return@withContext
}
if (!File(TOKENIZER_PATH).exists()) {
nlog("ERROR: tokenizer not found at $TOKENIZER_PATH")
if (!File(tokenizerPath).exists()) {
nlog("ERROR: tokenizer not found at $tokenizerPath")
return@withContext
}
@ -71,8 +94,8 @@ class ExecuTorchLlmEngine(
// TextLLMRunner. Our .pte was exported with
// --decoder_model qwen3-4b which requires this path.
val MODEL_TYPE_QNN_LLAMA = 4
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, MODEL_PATH, TOKENIZER_PATH, 0.7f)
nlog("LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, this@ExecuTorchLlmEngine.modelPath, tokenizerPath, temperature)
nlog("[$displayName] LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
// Load the PTE into QNN HTP (calls the native load()).
val loadResult = llmModule!!.load()
@ -81,11 +104,11 @@ class ExecuTorchLlmEngine(
llmModule = null
return@withContext
}
nlog("LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
nlog("[$displayName] LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
loaded = true
modelName = "Qwen3-4B LlmModule"
nlog("Ready: $modelName")
modelName = displayName
nlog("[$displayName] Ready")
} catch (e: Throwable) {
nlog("ERROR: LlmModule init failed: ${e.javaClass.simpleName}: ${e.message}")
llmModule = null
@ -99,12 +122,43 @@ class ExecuTorchLlmEngine(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = generateWithSystem(prompt, null, params, onToken, tag = "SPEAKER")
/**
* Cascade Option B : permet de passer un system prompt différent par appel
* (par ex. SYS_THINKER pour la passe d'analyse, puis le system prompt par
* défaut pour la passe Speaker). `null` = utilise le `systemPrompt`
* d'instance. Le `tag` préfixe les logs pour distinguer les passes
* cascadées dans logcat.
*/
suspend fun generateWithSystem(
prompt: String,
systemPromptOverride: String?,
params: SamplingParams = SamplingParams(),
onToken: ((String) -> Boolean)? = null,
tag: String = "LLM"
): GenerationResult = withContext(Dispatchers.IO) {
val mod = llmModule ?: throw IllegalStateException("Model not loaded")
// Logger local taggué pour cette passe (THINKER, SPEAKER, …).
val nl: (String) -> Unit = { msg ->
Log.i(TAG, "[$tag] $msg")
onLog?.invoke("[$tag] $msg")
}
val startTime = System.currentTimeMillis()
val fullPrompt = buildChatTemplate(prompt)
nlog("Prompt: '${prompt.take(80)}'")
val fullPrompt = buildChatTemplate(prompt, systemPromptOverride ?: systemPrompt)
nl("Prompt: '${prompt.take(80)}'")
// Clear KV cache → cur_pos_ = 0. Indispensable pour le cascade B :
// sans ça, le cur_pos_ accumule entre passes (Thinker → Speaker) et
// entre tours, et l'assertion ExecuTorch
// `cur_pos_+num_prompt_tokens < seq_len` finit par exploser après
// 2-3 messages. Le prompt est repassé en entier à chaque appel donc
// clear le KV n'altère pas le contexte conversationnel.
try { mod.resetContext() } catch (e: Throwable) {
nl("WARN: resetContext() failed: ${e.message}")
}
val responseBuilder = StringBuilder()
var firstTokenMs = -1L
@ -170,18 +224,23 @@ class ExecuTorchLlmEngine(
}
}
override fun onStats(stats: String) {
nlog("stats: ${stats.take(200)}")
nl("stats: ${stats.take(200)}")
}
}
val seqLen = minOf(params.maxNewTokens, 512)
// seqLen est le budget TOTAL (prefill + decode) pour QNN runner.
// Doit matcher le max_seq_len du .pte compilé (cf MODEL_SEQ_LEN).
// Test 2026-05-13 : seq=1024 + reload-after-turn (Speaking state →
// release+reload caché derrière TTS playback) pour éviter
// l'accumulation cur_pos qui causait l'OOM précédent.
val seqLen = MODEL_SEQ_LEN
val rc = try {
// echo=false so onResult() only receives the generated completion,
// not the prompt tokens echoed back — otherwise the sentence
// streamer would feed '<|im_start|>user …' to the TTS.
mod.generate(fullPrompt, seqLen, cb, /* echo */ false)
} catch (e: Throwable) {
nlog("generate() threw: ${e.message}")
nl("generate() threw: ${e.message}")
-1
}
@ -198,8 +257,8 @@ class ExecuTorchLlmEngine(
val tokenCount = rawText.length / 4 // rough estimate without a tokenizer
val rate = if (elapsed > 0) (tokenCount * 1000f) / elapsed else 0f
nlog("Response: '${responseText.take(80)}'")
nlog("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
nl("Response: '${responseText.take(80)}'")
nl("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
GenerationResult(
text = responseText,
@ -216,11 +275,11 @@ class ExecuTorchLlmEngine(
* .pte was trained with it). Terminates with `<|im_start|>assistant` with
* no trailing newline, matching the binary exactly.
*/
private fun buildChatTemplate(userInput: String): String {
private fun buildChatTemplate(userInput: String, sysPrompt: String = systemPrompt): String {
val sb = StringBuilder()
sb.append("<|im_start|>user\n").append(userInput).append("<|im_end|>\n")
if (SYSTEM_PROMPT.isNotEmpty()) {
sb.append("<|im_start|>system\n").append(SYSTEM_PROMPT).append("<|im_end|>\n")
if (sysPrompt.isNotEmpty()) {
sb.append("<|im_start|>system\n").append(sysPrompt).append("<|im_end|>\n")
}
sb.append("<|im_start|>assistant")
return sb.toString()
@ -250,4 +309,18 @@ class ExecuTorchLlmEngine(
llmModule = null
loaded = false
}
/**
* @deprecated reload-after-turn workaround. Test 2026-05-13 sur seq=1024 .pte
* a montré que (1) resetContext() au début de generate() suffit (cur_pos
* remis à 0 via Runner::reset côté C++), et (2) le release()+load()
* échoue avec ExecutorchInvalidArgumentException "Failed to load llm runner: [1]"
* sur la 2 instanciation (probable bug singleton QnnBackendUnifiedRegistry).
* Conservé pour rétro-compat ; ne plus appeler.
*/
@Deprecated("Use resetContext() — already called at start of generate()")
suspend fun reloadAfterTurn() {
release()
load("", com.kazeia.core.LlmConfig())
}
}

View File

@ -1,85 +0,0 @@
package com.kazeia.llm
/**
* JNI bridge to the KazeiaLLM C++ wrapper over llama.cpp.
*
* Native impl in jni/kazeia_llm_jni.cpp. Underlying C++ library source :
* /opt/Kazeia/kazeia-llm/ (compiled inline via the app CMake).
*
* One [load] allocates a native handle (opaque Long) tied to a loaded model +
* context. Same handle is then passed to every call. Always call [free] when
* done to release the model and KV buffer.
*
* KV cache is persistent across [generate] calls : it stores the full
* conversation so subsequent turns only re-prefill the new user message +
* assistant prefix. Call [reset] to clear.
*/
object KazeiaLlmJni {
init {
// ggml is shared with whisper ; llama is the new addition.
System.loadLibrary("ggml-base")
System.loadLibrary("ggml-cpu")
System.loadLibrary("ggml")
System.loadLibrary("llama")
System.loadLibrary("kazeia_llm_jni")
}
/**
* Load a GGUF model + create context. Returns native handle (0 on failure).
* @param gguf absolute path to the .gguf file (e.g. Qwen3.5-4B-Q4_0-pure.gguf).
* @param nCtx max context tokens (default 4096).
* @param nThreads CPU threads for decode (default 6 on OPD2415).
*/
external fun nativeLoad(
gguf: String,
nCtx: Int = 4096,
nThreads: Int = 6,
temperature: Float = 0.7f,
topP: Float = 0.9f,
repeatPenalty: Float = 1.1f
): Long
/** Free model + context. Idempotent for handle=0. */
external fun nativeFree(handle: Long)
/** Set system prompt (clears KV, re-evaluates). Returns 0 on success. */
external fun nativeSetSystem(handle: Long, system: String): Int
/**
* Append user message to conversation and generate an assistant reply.
* KV cache is updated with user turn + assistant reply.
* @param maxTokens hard cap on generated tokens (model stops earlier on <|im_end|>).
* @param callback optional streaming callback ; return false to stop early.
* @return full assistant text.
*/
external fun nativeGenerate(
handle: Long,
userMessage: String,
maxTokens: Int,
callback: TokenCallback?
): String
/** Clear the entire KV cache. System prompt must be re-set after this. */
external fun nativeReset(handle: Long)
/**
* Stats from the last [nativeGenerate] call.
* Returns [promptTokens, decodeTokens, promptMs, decodeMs, promptTps, decodeTps].
*/
external fun nativeLastStats(handle: Long): DoubleArray
/** Save current KV / conversation state to a file. Returns 0 on success. */
external fun nativeSaveSnapshot(handle: Long, path: String): Int
/** Load conversation state from a file (clears current first). Returns 0 on success. */
external fun nativeRestoreSnapshot(handle: Long, path: String): Int
/** Current number of tokens held in the KV cache (debug / UI). */
external fun nativeKvTokens(handle: Long): Int
fun interface TokenCallback {
/** Called for each generated piece. Return false to halt generation. */
fun onToken(token: String): Boolean
}
}

View File

@ -1,149 +0,0 @@
package com.kazeia.llm
import android.util.Log
import com.kazeia.core.GenerationResult
import com.kazeia.core.LlmConfig
import com.kazeia.core.LlmEngine
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Kazeia LLM backed by llama.cpp (upstream master) via KazeiaLlmJni.
*
* Replaces the previous GenieLlmEngine (Qualcomm Genie SDK on QNN) for the
* therapeutic conversation path. Measured ~11 tok/s sustained on Qwen3.5-4B
* Q4_0 on OPD2415 +57 % vs the legacy briques runtime.
*
* Lifecycle : [load] once per app session ; [generate] called for each user
* turn. The native side keeps a persistent KV cache between generate() calls,
* so the conversation history need not be re-prefilled every turn we only
* append the new user + assistant tokens. Call [release] at app shutdown.
*
* The [modelPath] passed to [load] must point at the GGUF file directly
* (e.g. ".../kazeia/models/Qwen3.5-4B-Q4_0-pure.gguf"), not a config file.
*
* System prompt is set via [setSystem] (defaults to a French therapeutic
* assistant prompt if never called).
*/
class LlamaCppLlmEngine : LlmEngine {
companion object {
private const val TAG = "LlamaCppLlmEngine"
private const val DEFAULT_SYSTEM =
"Tu es un assistant psychologique bienveillant qui écoute et répond " +
"avec empathie et chaleur. Réponses courtes et naturelles en français. " +
"Ne jamais afficher ton raisonnement, réponds directement à l'utilisateur."
}
@Volatile private var handle: Long = 0L
@Volatile private var loaded = false
private var currentSystem: String = DEFAULT_SYSTEM
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
Log.i(TAG, "Loading Qwen3.5 via llama.cpp : $modelPath")
handle = KazeiaLlmJni.nativeLoad(
gguf = modelPath,
nCtx = config.maxContextLength,
nThreads = 6,
temperature = 0.7f,
topP = 0.9f,
repeatPenalty = 1.1f,
)
if (handle == 0L) {
throw RuntimeException("llama.cpp nativeLoad failed for $modelPath")
}
val rc = KazeiaLlmJni.nativeSetSystem(handle, currentSystem)
if (rc != 0) {
Log.w(TAG, "nativeSetSystem returned $rc ; continuing with empty system")
}
loaded = true
Log.i(TAG, "llama.cpp ready handle=$handle")
}
}
override fun isLoaded(): Boolean = loaded
/**
* Override the system prompt. Clears the KV cache (conversation restarts).
*/
fun setSystem(system: String) {
if (!loaded) {
currentSystem = system
return
}
currentSystem = system
val rc = KazeiaLlmJni.nativeSetSystem(handle, system)
if (rc != 0) Log.w(TAG, "setSystem rc=$rc")
}
/**
* Clear the conversation history. [setSystem] must be called after (or
* the previously-set system is re-applied automatically by reset+eval
* if the native side keeps it).
*/
fun resetConversation() {
if (!loaded) return
KazeiaLlmJni.nativeReset(handle)
// Re-apply system so the next generate() starts on a valid turn.
KazeiaLlmJni.nativeSetSystem(handle, currentSystem)
}
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?,
): GenerationResult = withContext(Dispatchers.IO) {
check(loaded) { "LlamaCppLlmEngine: model not loaded" }
val startTime = System.currentTimeMillis()
val cb = onToken?.let {
KazeiaLlmJni.TokenCallback { piece -> it(piece) }
}
val reply = KazeiaLlmJni.nativeGenerate(
handle = handle,
userMessage = prompt,
maxTokens = params.maxNewTokens,
callback = cb,
)
val elapsed = System.currentTimeMillis() - startTime
val stats = KazeiaLlmJni.nativeLastStats(handle)
val decodeTokens = stats.getOrNull(1)?.toInt() ?: 0
val decodeMs = stats.getOrNull(3) ?: elapsed.toDouble()
val decodeTps = stats.getOrNull(5)?.toFloat() ?: 0f
GenerationResult(
text = reply,
tokenCount = decodeTokens,
timeMs = elapsed,
tokensPerSecond = if (decodeMs > 0) decodeTps else 0f,
)
}
/** Save the current conversation state to [path]. */
fun saveSnapshot(path: String): Boolean {
if (!loaded) return false
return KazeiaLlmJni.nativeSaveSnapshot(handle, path) == 0
}
/** Restore conversation state from [path]. */
fun restoreSnapshot(path: String): Boolean {
if (!loaded) return false
return KazeiaLlmJni.nativeRestoreSnapshot(handle, path) == 0
}
/** Debug : current KV token count. */
fun kvTokens(): Int = if (loaded) KazeiaLlmJni.nativeKvTokens(handle) else 0
override fun release() {
if (handle != 0L) {
KazeiaLlmJni.nativeFree(handle)
handle = 0L
loaded = false
Log.i(TAG, "llama.cpp released")
}
}
}

View File

@ -0,0 +1,228 @@
package com.kazeia.profiles
import android.content.ContentValues
import android.content.Context
import android.util.Log
import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import java.util.UUID
/**
* Base SQLCipher unique pour l'historique conversation (un schema simple
* `turns` qui rassemble tous les profils, requêtes filtrées par
* `profile_id`). Schema versionné pour migrations futures.
*
* - Chiffrement AES-256 via passphrase 32 octets fournie par [SecureKeyStore]
* - Index sur (profile_id, session_id) pour requêtes timeline rapides
* - Index sur timestamp pour requêtes globales par date
*/
class ConversationDb private constructor(
context: Context,
passphrase: ByteArray
) : SQLiteOpenHelper(
context, DB_NAME, passphrase,
/* factory */ null,
/* version */ DB_VERSION,
/* minimumSupportedVersion */ 0,
/* errorHandler */ null,
/* hook */ null,
/* enableWriteAheadLogging */ true
) {
companion object {
const val DB_NAME = "conversations.db"
const val DB_VERSION = 1
const val TABLE = "turns"
const val COL_ID = "id"
const val COL_PROFILE = "profile_id"
const val COL_SESSION = "session_id"
const val COL_TIMESTAMP = "timestamp"
const val COL_ROLE = "role"
const val COL_TEXT = "text"
const val COL_TTFT = "ttft_ms"
const val COL_TOTAL = "total_ms"
const val COL_VOICE = "voice_used"
const val COL_MODEL = "model_used"
@Volatile private var INSTANCE: ConversationDb? = null
init {
// Charge la lib native SQLCipher au premier usage.
System.loadLibrary("sqlcipher")
}
fun get(context: Context): ConversationDb = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val pass = SecureKeyStore.get(context).conversationsDbPassphrase()
ConversationDb(context.applicationContext, pass).also { INSTANCE = it }
}
}
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE $TABLE (
$COL_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COL_PROFILE TEXT NOT NULL,
$COL_SESSION TEXT NOT NULL,
$COL_TIMESTAMP INTEGER NOT NULL,
$COL_ROLE TEXT NOT NULL,
$COL_TEXT TEXT NOT NULL,
$COL_TTFT INTEGER,
$COL_TOTAL INTEGER,
$COL_VOICE TEXT,
$COL_MODEL TEXT
)
""".trimIndent())
db.execSQL("CREATE INDEX idx_profile_session ON $TABLE ($COL_PROFILE, $COL_SESSION)")
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE ($COL_TIMESTAMP DESC)")
Log.i("ConversationDb", "created $DB_NAME v$DB_VERSION")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// Futur — pas de migration encore
}
}
/**
* DAO mince autour de [ConversationDb]. Toutes les méthodes sont
* thread-safe (SQLite synchronise en interne). Les inserts à haute
* fréquence (token streaming) ne sont PAS supportés ici on log un
* tour complet à la fois (texte final patient OU réponse Kazeia).
*/
class ConversationDao(private val db: ConversationDb) {
fun logTurn(turn: Turn): Long {
val cv = ContentValues().apply {
put(ConversationDb.COL_PROFILE, turn.profileId)
put(ConversationDb.COL_SESSION, turn.sessionId)
put(ConversationDb.COL_TIMESTAMP, turn.timestamp)
put(ConversationDb.COL_ROLE, turn.role.name)
put(ConversationDb.COL_TEXT, turn.text)
turn.ttftMs?.let { put(ConversationDb.COL_TTFT, it) }
turn.totalMs?.let { put(ConversationDb.COL_TOTAL, it) }
turn.voiceUsed?.let { put(ConversationDb.COL_VOICE, it) }
turn.modelUsed?.let { put(ConversationDb.COL_MODEL, it) }
}
return db.writableDatabase.insert(ConversationDb.TABLE, null, cv)
}
fun listTurns(profileId: String? = null, sessionId: String? = null, limit: Int = 500): List<Turn> {
val (where, args) = when {
profileId != null && sessionId != null ->
"${ConversationDb.COL_PROFILE}=? AND ${ConversationDb.COL_SESSION}=?" to
arrayOf(profileId, sessionId)
profileId != null ->
"${ConversationDb.COL_PROFILE}=?" to arrayOf(profileId)
sessionId != null ->
"${ConversationDb.COL_SESSION}=?" to arrayOf(sessionId)
else -> null to emptyArray()
}
val out = mutableListOf<Turn>()
db.readableDatabase.query(
ConversationDb.TABLE, null, where, args, null, null,
"${ConversationDb.COL_TIMESTAMP} DESC",
limit.toString()
).use { c ->
while (c.moveToNext()) out += readTurn(c)
}
return out.reversed() // ordre chronologique
}
fun listSessions(profileId: String? = null, limit: Int = 100): List<SessionSummary> {
val (where, args) = if (profileId != null)
"WHERE ${ConversationDb.COL_PROFILE}=?" to arrayOf(profileId)
else "" to emptyArray()
val sql = """
SELECT ${ConversationDb.COL_PROFILE},
${ConversationDb.COL_SESSION},
MIN(${ConversationDb.COL_TIMESTAMP}) AS started_at,
MAX(${ConversationDb.COL_TIMESTAMP}) AS ended_at,
COUNT(*) AS turn_count
FROM ${ConversationDb.TABLE}
$where
GROUP BY ${ConversationDb.COL_SESSION}
ORDER BY started_at DESC
LIMIT $limit
""".trimIndent()
val out = mutableListOf<SessionSummary>()
db.readableDatabase.rawQuery(sql, args).use { c ->
while (c.moveToNext()) {
out += SessionSummary(
profileId = c.getString(0),
sessionId = c.getString(1),
startedAt = c.getLong(2),
endedAt = c.getLong(3),
turnCount = c.getInt(4)
)
}
}
return out
}
fun deleteProfile(profileId: String): Int {
return db.writableDatabase.delete(
ConversationDb.TABLE,
"${ConversationDb.COL_PROFILE}=?",
arrayOf(profileId)
)
}
fun deleteSession(sessionId: String): Int {
return db.writableDatabase.delete(
ConversationDb.TABLE,
"${ConversationDb.COL_SESSION}=?",
arrayOf(sessionId)
)
}
fun countTurns(profileId: String): Int {
db.readableDatabase.rawQuery(
"SELECT COUNT(*) FROM ${ConversationDb.TABLE} WHERE ${ConversationDb.COL_PROFILE}=?",
arrayOf(profileId)
).use { c ->
return if (c.moveToFirst()) c.getInt(0) else 0
}
}
private fun readTurn(c: android.database.Cursor): Turn = Turn(
id = c.getLong(c.getColumnIndexOrThrow(ConversationDb.COL_ID)),
profileId = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_PROFILE)),
sessionId = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_SESSION)),
timestamp = c.getLong(c.getColumnIndexOrThrow(ConversationDb.COL_TIMESTAMP)),
role = Role.valueOf(c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_ROLE))),
text = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_TEXT)),
ttftMs = c.getColumnIndex(ConversationDb.COL_TTFT).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getLong(it) },
totalMs = c.getColumnIndex(ConversationDb.COL_TOTAL).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getLong(it) },
voiceUsed = c.getColumnIndex(ConversationDb.COL_VOICE).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getString(it) },
modelUsed = c.getColumnIndex(ConversationDb.COL_MODEL).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getString(it) }
)
}
enum class Role { PATIENT, KAZEIA }
data class Turn(
val id: Long = 0,
val profileId: String,
val sessionId: String,
val timestamp: Long,
val role: Role,
val text: String,
val ttftMs: Long? = null,
val totalMs: Long? = null,
val voiceUsed: String? = null,
val modelUsed: String? = null
) {
companion object {
fun newSessionId(): String = "s_" + UUID.randomUUID().toString().take(12)
}
}
data class SessionSummary(
val profileId: String,
val sessionId: String,
val startedAt: Long,
val endedAt: Long,
val turnCount: Int
)

View File

@ -0,0 +1,77 @@
package com.kazeia.profiles
import org.json.JSONObject
import java.security.MessageDigest
/**
* Profil patient Kazeia. Identité, voix associée, override prompt, PIN
* optionnel (hash SHA-256 seulement, jamais le PIN en clair).
*
* Stockage : `profiles.json` (liste) côté Kazeia patient. Lisible/modifiable
* par l'admin app via ContentProvider URIs `/profiles`.
*/
data class Profile(
val id: String,
val displayName: String,
val avatarColor: Int,
val voiceId: String? = null,
val systemPromptOverride: String? = null,
val pinHash: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val lastUsedAt: Long = createdAt,
val notes: String? = null,
val isDefault: Boolean = false
) {
fun hasPin(): Boolean = !pinHash.isNullOrBlank()
fun checkPin(pin: String): Boolean {
val expected = pinHash ?: return true
return hashPin(pin) == expected
}
fun toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("display_name", displayName)
put("avatar_color", avatarColor)
put("voice_id", voiceId ?: JSONObject.NULL)
put("system_prompt_override", systemPromptOverride ?: JSONObject.NULL)
put("pin_hash", pinHash ?: JSONObject.NULL)
put("created_at", createdAt)
put("last_used_at", lastUsedAt)
put("notes", notes ?: JSONObject.NULL)
put("is_default", isDefault)
}
companion object {
fun fromJson(js: JSONObject): Profile = Profile(
id = js.getString("id"),
displayName = js.optString("display_name", js.getString("id")),
avatarColor = js.optInt("avatar_color", defaultColorFor(js.getString("id"))),
voiceId = js.optString("voice_id").takeIf { it.isNotBlank() && it != "null" },
systemPromptOverride = js.optString("system_prompt_override")
.takeIf { it.isNotBlank() && it != "null" },
pinHash = js.optString("pin_hash").takeIf { it.isNotBlank() && it != "null" },
createdAt = js.optLong("created_at", System.currentTimeMillis()),
lastUsedAt = js.optLong("last_used_at", System.currentTimeMillis()),
notes = js.optString("notes").takeIf { it.isNotBlank() && it != "null" },
isDefault = js.optBoolean("is_default", false)
)
fun hashPin(pin: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(pin.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
/** Génère une couleur pastel stable depuis l'id du profil pas de
* collision avec les couleurs voix Kazeia. */
fun defaultColorFor(seed: String): Int {
val palette = intArrayOf(
0xFFBCA4E8.toInt(), 0xFFA4D8E8.toInt(), 0xFFE8C4A4.toInt(),
0xFFA4E8C4.toInt(), 0xFFE8A4C4.toInt(), 0xFFC4A4E8.toInt(),
0xFFA4E8D8.toInt(), 0xFFE8D8A4.toInt()
)
val h = seed.hashCode() and 0x7fffffff
return palette[h % palette.size]
}
}
}

Some files were not shown because too many files have changed in this diff Show More