feat(admin): toggle "mode debug" — masque le bouton logs/métriques côté patient

Nouveau flag ConfigStore.debugEnabled (défaut false), propagé via le
ContentProvider (/config colonne debug_enabled) et piloté par un Switch dans
l'écran Système de l'app admin.

- ConfigStore : champ debugEnabled + (de)sérialisation JSON
- KazeiaTelemetryProvider : colonne debug_enabled (query + update)
- KazeiaService : applique debugEnabled → _debugMode au boot et au reload config
- ChatActivity : _debugMode gate désormais la VISIBILITÉ du bouton debug
  (applyDebugGate). Off → bouton masqué + panneau forcé fermé (écran patient
  épuré). On → bouton visible, ouvre logs + métriques CPU/GPU/NPU/RAM.
- activity_chat.xml : btnDebugToggle visibility=gone par défaut (pas de flash)
- app-admin : KazeiaConfigClient/ConfigRepository + DebugCard dans SystemScreen

Vérifié device : round-trip provider debug_enabled 0→1→0 OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-17 23:43:50 +02:00
parent d1baacd096
commit 93a3953614
8 changed files with 100 additions and 6 deletions

View File

@ -22,6 +22,9 @@ class ConfigRepository(context: Context) {
fun updateTts(enabled: Boolean): Boolean = fun updateTts(enabled: Boolean): Boolean =
client.updateConfig(ttsEnabled = enabled) client.updateConfig(ttsEnabled = enabled)
fun updateDebug(enabled: Boolean): Boolean =
client.updateConfig(debugEnabled = enabled)
fun updateSpeakerModel(modelId: String): Boolean = fun updateSpeakerModel(modelId: String): Boolean =
client.updateConfig(speakerModelId = modelId) client.updateConfig(speakerModelId = modelId)

View File

@ -28,7 +28,8 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
val thinkerTemperature: Float, val thinkerTemperature: Float,
val ragEnabled: Boolean = false, val ragEnabled: Boolean = false,
val ragThreshold: Float = 0.82f, val ragThreshold: Float = 0.82f,
val ragTopK: Int = 3 val ragTopK: Int = 3,
val debugEnabled: Boolean = false
) )
data class ModelInfo( data class ModelInfo(
@ -57,7 +58,8 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")), thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")),
ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false }, 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 }, 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 } ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 },
debugEnabled = c.getColumnIndex("debug_enabled").let { if (it >= 0) c.getInt(it) == 1 else false }
) )
} }
}.getOrNull() }.getOrNull()
@ -95,7 +97,8 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
thinkerTemperature: Float? = null, thinkerTemperature: Float? = null,
ragEnabled: Boolean? = null, ragEnabled: Boolean? = null,
ragThreshold: Float? = null, ragThreshold: Float? = null,
ragTopK: Int? = null ragTopK: Int? = null,
debugEnabled: Boolean? = null
): Boolean { ): Boolean {
val v = ContentValues() val v = ContentValues()
cascadeEnabled?.let { v.put("cascade_enabled", it) } cascadeEnabled?.let { v.put("cascade_enabled", it) }
@ -109,6 +112,7 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
ragEnabled?.let { v.put("rag_enabled", it) } ragEnabled?.let { v.put("rag_enabled", it) }
ragThreshold?.let { v.put("rag_threshold", it) } ragThreshold?.let { v.put("rag_threshold", it) }
ragTopK?.let { v.put("rag_top_k", it) } ragTopK?.let { v.put("rag_top_k", it) }
debugEnabled?.let { v.put("debug_enabled", it) }
if (v.size() == 0) return true if (v.size() == 0) return true
return runCatching { return runCatching {
resolver.update(CONFIG_URI, v, null, null) > 0 resolver.update(CONFIG_URI, v, null, null) > 0

View File

@ -138,6 +138,21 @@ fun SystemScreen() {
} }
) )
DebugCard(
enabled = cfg.debugEnabled,
onToggle = { newValue ->
val ok = repo.updateDebug(newValue)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Mode debug ${if (newValue) "activé" else "désactivé"} — appliqué immédiatement côté patient."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
}
)
ModelSection( ModelSection(
title = "Speaker", title = "Speaker",
subtitle = "LLM qui répond au patient", subtitle = "LLM qui répond au patient",
@ -373,6 +388,45 @@ private fun TtsCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
} }
} }
@Composable
private fun DebugCard(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(
"Mode debug",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
Text(
if (enabled)
"ACTIVÉ — le bouton debug (logs + métriques CPU/GPU/NPU/RAM) est visible dans l'app patient."
else
"DÉSACTIVÉ — écran patient épuré, bouton debug masqué.",
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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ModelSection( private fun ModelSection(

View File

@ -64,7 +64,12 @@ class ConfigStore private constructor(private val file: File) {
* pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */ * pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */
val ragThreshold: Float = 0.82f, val ragThreshold: Float = 0.82f,
/** Nombre max de chunks récupérés/injectés par tour. */ /** Nombre max de chunks récupérés/injectés par tour. */
val ragTopK: Int = 3 val ragTopK: Int = 3,
/** Mode debug : quand true, l'app patient affiche le bouton qui ouvre le
* panneau de logs + métriques système (CPU/GPU/NPU/RAM). Quand false
* (défaut prod), le bouton est masqué l'écran reste épuré pour le
* patient. Pilotable depuis l'app admin. */
val debugEnabled: Boolean = false
) )
companion object { companion object {
@ -172,6 +177,7 @@ class ConfigStore private constructor(private val file: File) {
root.put("rag_enabled", cfg.ragEnabled) root.put("rag_enabled", cfg.ragEnabled)
root.put("rag_threshold", cfg.ragThreshold.toDouble()) root.put("rag_threshold", cfg.ragThreshold.toDouble())
root.put("rag_top_k", cfg.ragTopK) root.put("rag_top_k", cfg.ragTopK)
root.put("debug_enabled", cfg.debugEnabled)
root.put("speaker", JSONObject().apply { root.put("speaker", JSONObject().apply {
put("model_id", cfg.speaker.modelId) put("model_id", cfg.speaker.modelId)
put("system_prompt", cfg.speaker.systemPrompt) put("system_prompt", cfg.speaker.systemPrompt)
@ -197,6 +203,7 @@ class ConfigStore private constructor(private val file: File) {
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled), ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(), ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
ragTopK = root.optInt("rag_top_k", defaults.ragTopK), ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
debugEnabled = root.optBoolean("debug_enabled", defaults.debugEnabled),
speaker = root.optJSONObject("speaker")?.let { js -> speaker = root.optJSONObject("speaker")?.let { js ->
ModelConfig( ModelConfig(
modelId = js.optString("model_id", defaults.speaker.modelId), modelId = js.optString("model_id", defaults.speaker.modelId),

View File

@ -703,6 +703,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// (admin app → ContentProvider.update → ConfigStore.save → listener) // (admin app → ContentProvider.update → ConfigStore.save → listener)
val store = com.kazeia.config.ConfigStore.get(this) val store = com.kazeia.config.ConfigStore.get(this)
runtimeConfig = store.current() runtimeConfig = store.current()
_debugMode.value = runtimeConfig.debugEnabled // gate UI debug (bouton logs/métriques)
store.addListener { new -> handleConfigChange(new) } store.addListener { new -> handleConfigChange(new) }
registerConfigReloadReceiver() registerConfigReloadReceiver()
registerRagReloadReceiver() registerRagReloadReceiver()
@ -2173,6 +2174,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) { private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) {
val old = runtimeConfig val old = runtimeConfig
runtimeConfig = new runtimeConfig = new
if (new.debugEnabled != old.debugEnabled) {
_debugMode.value = new.debugEnabled // affiche/masque le bouton debug côté patient
log("[CONFIG] debug ${old.debugEnabled}${new.debugEnabled}")
}
log("[CONFIG] applied : cascade ${old.cascadeEnabled}${new.cascadeEnabled}, " + log("[CONFIG] applied : cascade ${old.cascadeEnabled}${new.cascadeEnabled}, " +
"speaker ${old.speaker.modelId}${new.speaker.modelId}, " + "speaker ${old.speaker.modelId}${new.speaker.modelId}, " +
"thinker ${old.thinker.modelId}${new.thinker.modelId}") "thinker ${old.thinker.modelId}${new.thinker.modelId}")

View File

@ -256,7 +256,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
val cols = arrayOf( val cols = arrayOf(
"cascade_enabled", "tts_enabled", "cascade_enabled", "tts_enabled",
"stt_engine", "llm_engine", "tts_engine", "rag_enabled", "stt_engine", "llm_engine", "tts_engine", "rag_enabled",
"rag_threshold", "rag_top_k", "rag_threshold", "rag_top_k", "debug_enabled",
"speaker_model_id", "speaker_system_prompt", "speaker_temperature", "speaker_model_id", "speaker_system_prompt", "speaker_temperature",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature" "thinker_model_id", "thinker_system_prompt", "thinker_temperature"
) )
@ -267,6 +267,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine, cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
if (cfg.ragEnabled) 1 else 0, if (cfg.ragEnabled) 1 else 0,
cfg.ragThreshold, cfg.ragTopK, cfg.ragThreshold, cfg.ragTopK,
if (cfg.debugEnabled) 1 else 0,
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
)) ))
@ -361,6 +362,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
values.getAsBoolean("rag_enabled") else cur.ragEnabled, values.getAsBoolean("rag_enabled") else cur.ragEnabled,
ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold, ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold,
ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK, ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK,
debugEnabled = if (values.containsKey("debug_enabled"))
values.getAsBoolean("debug_enabled") else cur.debugEnabled,
speaker = com.kazeia.config.ConfigStore.ModelConfig( speaker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
systemPrompt = values.getAsString("speaker_system_prompt") systemPrompt = values.getAsString("speaker_system_prompt")

View File

@ -319,8 +319,12 @@ class ChatActivity : AppCompatActivity() {
} }
} }
launch { launch {
// debugMode (piloté par l'admin via ConfigStore.debugEnabled) gate le
// BOUTON debug. Si off : bouton masqué + panneau forcé fermé (écran
// patient épuré). Si on : bouton visible, le patient/soignant ouvre le
// panneau logs+métriques via le bouton.
service.debugMode.collect { debug -> service.debugMode.collect { debug ->
setDebugPanelVisible(debug) applyDebugGate(debug)
} }
} }
launch { launch {
@ -404,6 +408,19 @@ class ChatActivity : AppCompatActivity() {
} }
} }
/** Mode debug (ConfigStore.debugEnabled, piloté admin) : affiche/masque le bouton
* qui ouvre le panneau logs+métriques. Off bouton caché + panneau forcé fermé. */
private fun applyDebugGate(enabled: Boolean) {
runOnUiThread {
findViewById<View>(R.id.btnDebugToggle)?.visibility =
if (enabled) View.VISIBLE else View.GONE
if (!enabled) {
debugPanelVisible = false
setDebugPanelVisible(false)
}
}
}
private var logFile: java.io.FileWriter? = null private var logFile: java.io.FileWriter? = null
private fun appendLog(msg: String) { private fun appendLog(msg: String) {

View File

@ -39,6 +39,7 @@
android:src="@android:drawable/ic_menu_manage" android:src="@android:drawable/ic_menu_manage"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Debug" android:contentDescription="Debug"
android:visibility="gone"
android:tint="@android:color/white" /> android:tint="@android:color/white" />
<ImageButton <ImageButton