From 471fb5d5fb4a3bef202668494488ef47a6f1c349 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 18 Jun 2026 05:59:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20presets=20d'=C3=A9chantillonnage?= =?UTF-8?q?=20LLM=20(Speaker/Thinker)=20=E2=80=94=20app-side=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Système de presets de sampling réglables depuis l'admin, applicables au Speaker et au Thinker. Inspiré du tuning Kaz (temp/top-p/top-k/pénalités). Core/config : - SamplingParams (core) : +presencePenalty, +frequencyPenalty - ConfigStore.ModelConfig : +topP/topK/repeatPenalty/presence/frequency/maxTokens +presetName ; SamplingPreset + RuntimeConfig.presets (bibliothèque) ; JSON ; 5 presets d'usine (Équilibré/Kaz chaleureux/Factuel/Analyse/Créatif). Défauts maxTokens : Speaker 450, Thinker 128 (préserve la longueur prod). - ContentProvider : colonnes speaker_*/thinker_* sampling + presets_json (R/W). - KazeiaService : samplingFrom(ModelConfig) → SamplingParams pour Speaker+Thinker (au lieu du hardcodé). maxTokens honoré par le moteur ; le reste prêt. Admin : - KazeiaConfigClient : Sampling + Preset + parse/serialize presets_json + updateSampling/updatePresets - ConfigRepository : updateSampling/updatePresets - ParametersScreen (nav "Paramètres") : éditeurs sliders Speaker/Thinker + apply preset + bibliothèque CRUD ; bandeau honnête « seul Tokens max agit aujourd'hui ». ⚠ Le moteur natif n'honore que maxTokens à ce jour. Spec d'extension JNI sampling pour le dev engine : docs/SAMPLING_ENGINE_SPEC.md (~10 lignes app à brancher après). Vérifié device : colonnes sampling R/W (temp/max/preset_name), presets_json seedés + lus. Build app+admin verts, admin lancé sans crash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/kazeia/admin/MainActivity.kt | 3 + .../admin/data/repository/ConfigRepository.kt | 9 + .../admin/data/source/KazeiaConfigClient.kt | 131 ++++++- .../admin/ui/navigation/AdminDestination.kt | 2 + .../admin/ui/parameters/ParametersScreen.kt | 356 ++++++++++++++++++ .../app-admin/src/main/res/values/strings.xml | 1 + .../java/com/kazeia/config/ConfigStore.kt | 129 +++++-- .../main/java/com/kazeia/core/LlmEngine.kt | 7 +- .../java/com/kazeia/service/KazeiaService.kt | 25 +- .../telemetry/KazeiaTelemetryProvider.kt | 86 ++++- kazeia-android/docs/SAMPLING_ENGINE_SPEC.md | 93 +++++ 11 files changed, 774 insertions(+), 68 deletions(-) create mode 100644 kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/parameters/ParametersScreen.kt create mode 100644 kazeia-android/docs/SAMPLING_ENGINE_SPEC.md diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/MainActivity.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/MainActivity.kt index 535ca18..a9c7b4d 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/MainActivity.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/MainActivity.kt @@ -93,6 +93,9 @@ private fun MainShell() { composable(AdminDestination.Prompts.route) { PromptsScreen() } + composable(AdminDestination.Parameters.route) { + com.kazeia.admin.ui.parameters.ParametersScreen() + } composable(AdminDestination.Rag.route) { com.kazeia.admin.ui.rag.RagScreen() } diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/ConfigRepository.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/ConfigRepository.kt index a93f863..45d53e9 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/ConfigRepository.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/repository/ConfigRepository.kt @@ -25,6 +25,15 @@ class ConfigRepository(context: Context) { fun updateDebug(enabled: Boolean): Boolean = client.updateConfig(debugEnabled = enabled) + /** Applique un jeu de sampling à un rôle ("speaker"|"thinker"), avec le nom + * du preset source ("" = réglages custom). */ + fun updateSampling(role: String, s: KazeiaConfigClient.Sampling, presetName: String): Boolean = + client.updateSampling(role, s, presetName) + + /** Remplace toute la bibliothèque de presets (création / édition / suppression). */ + fun updatePresets(presets: List): Boolean = + client.updatePresets(presets) + fun updateSpeakerModel(modelId: String): Boolean = client.updateConfig(speakerModelId = modelId) diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt index 4570aec..5694137 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/data/source/KazeiaConfigClient.kt @@ -2,7 +2,10 @@ package com.kazeia.admin.data.source import android.content.ContentResolver import android.content.ContentValues +import android.database.Cursor import android.net.Uri +import org.json.JSONArray +import org.json.JSONObject /** * Client du ContentProvider Kazeia pour les URI `/config` et `/models`. @@ -17,20 +20,41 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models") } + /** Jeu de paramètres d'échantillonnage d'un modèle (ou d'un preset). */ + data class Sampling( + val temperature: Float = 0.7f, + val topP: Float = 0.85f, + val topK: Int = 40, + val repeatPenalty: Float = 1.1f, + val presencePenalty: Float = 0.0f, + val frequencyPenalty: Float = 0.0f, + val maxTokens: Int = 120 + ) + + /** Preset nommé réutilisable, applicable au Speaker ou au Thinker. */ + data class Preset(val name: String, val sampling: Sampling) + data class Config( val cascadeEnabled: Boolean, val ttsEnabled: Boolean, val speakerModelId: String, val speakerSystemPrompt: String, - val speakerTemperature: Float, + val speakerSampling: Sampling, + val speakerPresetName: String, val thinkerModelId: String, val thinkerSystemPrompt: String, - val thinkerTemperature: Float, + val thinkerSampling: Sampling, + val thinkerPresetName: String, + val presets: List = emptyList(), val ragEnabled: Boolean = false, val ragThreshold: Float = 0.82f, val ragTopK: Int = 3, val debugEnabled: Boolean = false - ) + ) { + // Compat : anciens écrans qui lisaient juste la température. + val speakerTemperature: Float get() = speakerSampling.temperature + val thinkerTemperature: Float get() = thinkerSampling.temperature + } data class ModelInfo( val id: String, @@ -44,6 +68,23 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { val notes: String ) + private fun Cursor.floatOr(col: String, d: Float) = + getColumnIndex(col).let { if (it >= 0) getFloat(it) else d } + private fun Cursor.intOr(col: String, d: Int) = + getColumnIndex(col).let { if (it >= 0) getInt(it) else d } + private fun Cursor.strOr(col: String, d: String) = + getColumnIndex(col).let { if (it >= 0) getString(it) ?: d else d } + + private fun Cursor.sampling(p: String) = Sampling( + temperature = floatOr("${p}temperature", 0.7f), + topP = floatOr("${p}top_p", 0.85f), + topK = intOr("${p}top_k", 40), + repeatPenalty = floatOr("${p}repeat_penalty", 1.1f), + presencePenalty = floatOr("${p}presence_penalty", 0.0f), + frequencyPenalty = floatOr("${p}frequency_penalty", 0.0f), + maxTokens = intOr("${p}max_tokens", 120) + ) + fun queryConfig(): Config? = runCatching { resolver.query(CONFIG_URI, null, null, null, null)?.use { c -> if (!c.moveToFirst()) return@use null @@ -52,18 +93,58 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { 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")), + speakerSampling = c.sampling("speaker_"), + speakerPresetName = c.strOr("speaker_preset_name", ""), 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 }, - debugEnabled = c.getColumnIndex("debug_enabled").let { if (it >= 0) c.getInt(it) == 1 else false } + thinkerSampling = c.sampling("thinker_"), + thinkerPresetName = c.strOr("thinker_preset_name", ""), + presets = parsePresets(c.strOr("presets_json", "[]")), + ragEnabled = c.intOr("rag_enabled", 0) == 1, + ragThreshold = c.floatOr("rag_threshold", 0.82f), + ragTopK = c.intOr("rag_top_k", 3), + debugEnabled = c.intOr("debug_enabled", 0) == 1 ) } }.getOrNull() + private fun parsePresets(json: String): List = runCatching { + val arr = JSONArray(json) + (0 until arr.length()).mapNotNull { i -> + arr.optJSONObject(i)?.let { o -> + Preset( + o.optString("name", ""), + Sampling( + o.optDouble("temperature", 0.7).toFloat(), + o.optDouble("top_p", 0.85).toFloat(), + o.optInt("top_k", 40), + o.optDouble("repeat_penalty", 1.1).toFloat(), + o.optDouble("presence_penalty", 0.0).toFloat(), + o.optDouble("frequency_penalty", 0.0).toFloat(), + o.optInt("max_tokens", 120) + ) + ) + } + }.filter { it.name.isNotBlank() } + }.getOrDefault(emptyList()) + + private fun presetsToJson(presets: List): String { + val arr = JSONArray() + presets.forEach { p -> + arr.put(JSONObject().apply { + put("name", p.name) + put("temperature", p.sampling.temperature) + put("top_p", p.sampling.topP) + put("top_k", p.sampling.topK) + put("repeat_penalty", p.sampling.repeatPenalty) + put("presence_penalty", p.sampling.presencePenalty) + put("frequency_penalty", p.sampling.frequencyPenalty) + put("max_tokens", p.sampling.maxTokens) + }) + } + return arr.toString() + } + fun queryModels(): List = runCatching { resolver.query(MODELS_URI, null, null, null, null)?.use { c -> val out = mutableListOf() @@ -91,10 +172,8 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { 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, @@ -105,17 +184,37 @@ class KazeiaConfigClient(private val resolver: ContentResolver) { 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) } debugEnabled?.let { v.put("debug_enabled", it) } + return push(v) + } + + /** Applique un jeu de sampling (+ nom de preset, "" = custom) à un rôle. */ + fun updateSampling(role: String, s: Sampling, presetName: String): Boolean { + val p = "${role}_" // "speaker" | "thinker" + val v = ContentValues().apply { + put("${p}temperature", s.temperature) + put("${p}top_p", s.topP) + put("${p}top_k", s.topK) + put("${p}repeat_penalty", s.repeatPenalty) + put("${p}presence_penalty", s.presencePenalty) + put("${p}frequency_penalty", s.frequencyPenalty) + put("${p}max_tokens", s.maxTokens) + put("${p}preset_name", presetName) + } + return push(v) + } + + /** Remplace toute la bibliothèque de presets. */ + fun updatePresets(presets: List): Boolean = + push(ContentValues().apply { put("presets_json", presetsToJson(presets)) }) + + private fun push(v: ContentValues): Boolean { if (v.size() == 0) return true - return runCatching { - resolver.update(CONFIG_URI, v, null, null) > 0 - }.getOrDefault(false) + return runCatching { resolver.update(CONFIG_URI, v, null, null) > 0 }.getOrDefault(false) } } diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/navigation/AdminDestination.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/navigation/AdminDestination.kt index be86f96..0c30bd8 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/navigation/AdminDestination.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/navigation/AdminDestination.kt @@ -8,6 +8,7 @@ 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.Tune import androidx.compose.material.icons.outlined.RecordVoiceOver import androidx.compose.ui.graphics.vector.ImageVector @@ -23,6 +24,7 @@ enum class AdminDestination( 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), + Parameters("parameters", com.kazeia.admin.R.string.nav_parameters, Icons.Outlined.Tune), 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), diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/parameters/ParametersScreen.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/parameters/ParametersScreen.kt new file mode 100644 index 0000000..ec7dfb4 --- /dev/null +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/parameters/ParametersScreen.kt @@ -0,0 +1,356 @@ +package com.kazeia.admin.ui.parameters + +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.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.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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.Slider +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.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.kazeia.admin.data.repository.ConfigRepository +import com.kazeia.admin.data.source.KazeiaConfigClient.Preset +import com.kazeia.admin.data.source.KazeiaConfigClient.Sampling +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ParametersScreen() { + val context = LocalContext.current + val repo = remember { ConfigRepository(context) } + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + + var loaded by remember { mutableStateOf(false) } + var reachable by remember { mutableStateOf(true) } + var presets by remember { mutableStateOf>(emptyList()) } + var speaker by remember { mutableStateOf(Sampling()) } + var thinker by remember { mutableStateOf(Sampling()) } + var speakerPreset by remember { mutableStateOf("") } + var thinkerPreset by remember { mutableStateOf("") } + + fun reload() { + val cfg = repo.config() + if (cfg == null) { reachable = false; loaded = true; return } + reachable = true + presets = cfg.presets + speaker = cfg.speakerSampling; speakerPreset = cfg.speakerPresetName + thinker = cfg.thinkerSampling; thinkerPreset = cfg.thinkerPresetName + loaded = true + } + LaunchedEffect(Unit) { reload() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Paramètres d'échantillonnage") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + }, + snackbarHost = { SnackbarHost(snackbar) } + ) { padding -> + if (!loaded) return@Scaffold + if (!reachable) { + Column(Modifier.fillMaxSize().padding(32.dp), verticalArrangement = Arrangement.Center) { + Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleLarge) + Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return@Scaffold + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.height(8.dp)) + EngineNoticeCard() + + RoleCard( + title = "Speaker", subtitle = "LLM qui répond au patient", + sampling = speaker, presetName = speakerPreset, presets = presets, + onChange = { speaker = it; speakerPreset = "" }, + onApplyPreset = { p -> speaker = p.sampling; speakerPreset = p.name }, + onSave = { + val ok = repo.updateSampling("speaker", speaker, speakerPreset) + scope.launch { snackbar.showSnackbar(if (ok) "Speaker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) } + } + ) + + RoleCard( + title = "Thinker", subtitle = "LLM d'analyse (cascade)", + sampling = thinker, presetName = thinkerPreset, presets = presets, + onChange = { thinker = it; thinkerPreset = "" }, + onApplyPreset = { p -> thinker = p.sampling; thinkerPreset = p.name }, + onSave = { + val ok = repo.updateSampling("thinker", thinker, thinkerPreset) + scope.launch { snackbar.showSnackbar(if (ok) "Thinker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) } + } + ) + + PresetLibraryCard( + presets = presets, + onSave = { updated -> + val ok = repo.updatePresets(updated) + if (ok) reload() + scope.launch { snackbar.showSnackbar(if (ok) "Bibliothèque de presets enregistrée." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) } + } + ) + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun EngineNoticeCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)), + shape = RoundedCornerShape(12.dp) + ) { + Column(Modifier.padding(16.dp)) { + Text("ℹ️ Effet sur la génération", fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Aujourd'hui le moteur n'applique que « Tokens max ». Température, top-p/k et " + + "pénalités sont enregistrés et prendront effet dès que le moteur expose " + + "l'échantillonnage (mise à jour à venir).", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoleCard( + title: String, + subtitle: String, + sampling: Sampling, + presetName: String, + presets: List, + onChange: (Sampling) -> Unit, + onApplyPreset: (Preset) -> Unit, + onSave: () -> 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) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + + // Appliquer un preset + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { + OutlinedTextField( + value = if (presetName.isNotBlank()) presetName else "Personnalisé", + onValueChange = {}, readOnly = true, + label = { Text("Preset appliqué") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (presets.isEmpty()) { + DropdownMenuItem(text = { Text("Aucun preset") }, onClick = { expanded = false }, enabled = false) + } + presets.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { expanded = false; onApplyPreset(p) } + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + SamplingEditor(sampling, onChange) + Spacer(Modifier.height(8.dp)) + Row { + Spacer(Modifier.weight(1f)) + Button(onClick = onSave) { Text("Appliquer au $title") } + } + } + } +} + +@Composable +private fun SamplingEditor(s: Sampling, onChange: (Sampling) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SliderRow("Température", s.temperature, 0f, 2f) { onChange(s.copy(temperature = it)) } + SliderRow("Top-p", s.topP, 0f, 1f) { onChange(s.copy(topP = it)) } + IntSliderRow("Top-k", s.topK, 0, 100) { onChange(s.copy(topK = it)) } + SliderRow("Pénalité répétition", s.repeatPenalty, 1f, 2f) { onChange(s.copy(repeatPenalty = it)) } + SliderRow("Pénalité présence", s.presencePenalty, 0f, 2f) { onChange(s.copy(presencePenalty = it)) } + SliderRow("Pénalité fréquence", s.frequencyPenalty, 0f, 2f) { onChange(s.copy(frequencyPenalty = it)) } + IntSliderRow("Tokens max ✓", s.maxTokens, 16, 512) { onChange(s.copy(maxTokens = it)) } + } +} + +@Composable +private fun SliderRow(label: String, value: Float, min: Float, max: Float, onChange: (Float) -> Unit) { + Column { + Row { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text( + String.format("%.2f", value), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace + ) + } + Slider( + value = value.coerceIn(min, max), + onValueChange = { onChange((it * 20).roundToInt() / 20f) }, // pas 0.05 + valueRange = min..max + ) + } +} + +@Composable +private fun IntSliderRow(label: String, value: Int, min: Int, max: Int, onChange: (Int) -> Unit) { + Column { + Row { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text("$value", style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace) + } + Slider( + value = value.coerceIn(min, max).toFloat(), + onValueChange = { onChange(it.roundToInt()) }, + valueRange = min.toFloat()..max.toFloat() + ) + } +} + +@Composable +private fun PresetLibraryCard(presets: List, onSave: (List) -> 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("Bibliothèque de presets", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("Modèles réutilisables, applicables au Speaker ou au Thinker.", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + + var editing by remember { mutableStateOf(null) } // index en édition + + presets.forEachIndexed { i, p -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text(p.name, fontWeight = FontWeight.Medium) + Text( + "t=${"%.2f".format(p.sampling.temperature)} · top-p=${"%.2f".format(p.sampling.topP)} · " + + "rep=${"%.2f".format(p.sampling.repeatPenalty)} · max=${p.sampling.maxTokens}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace + ) + } + TextButton(onClick = { editing = if (editing == i) null else i }) { + Text(if (editing == i) "Fermer" else "Éditer") + } + TextButton(onClick = { onSave(presets.filterIndexed { j, _ -> j != i }) }) { + Text("Suppr.", color = MaterialTheme.colorScheme.error) + } + } + if (editing == i) { + var draft by remember(i) { mutableStateOf(p.sampling) } + var name by remember(i) { mutableStateOf(p.name) } + OutlinedTextField( + value = name, onValueChange = { name = it }, + label = { Text("Nom du preset") }, singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(4.dp)) + SamplingEditor(draft) { draft = it } + Row { + Spacer(Modifier.weight(1f)) + Button( + onClick = { + editing = null + onSave(presets.toMutableList().also { it[i] = Preset(name.ifBlank { p.name }, draft) }) + } + ) { Text("Enregistrer") } + } + } + Spacer(Modifier.height(8.dp)) + } + + // Nouveau preset + var creating by remember { mutableStateOf(false) } + if (!creating) { + TextButton(onClick = { creating = true }) { Text("+ Nouveau preset") } + } else { + var newName by remember { mutableStateOf("") } + var newSampling by remember { mutableStateOf(Sampling()) } + OutlinedTextField( + value = newName, onValueChange = { newName = it }, + label = { Text("Nom du nouveau preset") }, singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(4.dp)) + SamplingEditor(newSampling) { newSampling = it } + Row { + TextButton(onClick = { creating = false }) { Text("Annuler") } + Spacer(Modifier.weight(1f)) + Button( + enabled = newName.isNotBlank(), + onClick = { + creating = false + onSave(presets + Preset(newName.trim(), newSampling)) + } + ) { Text("Créer") } + } + } + } + } +} diff --git a/kazeia-android/app-admin/src/main/res/values/strings.xml b/kazeia-android/app-admin/src/main/res/values/strings.xml index 69be285..5cfe72e 100644 --- a/kazeia-android/app-admin/src/main/res/values/strings.xml +++ b/kazeia-android/app-admin/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Voix Profils patients Prompts + Paramètres Base documentaire Historique Telemetry diff --git a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt index 47923aa..9a5e361 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/config/ConfigStore.kt @@ -26,7 +26,32 @@ class ConfigStore private constructor(private val file: File) { data class ModelConfig( val modelId: String, val systemPrompt: String, - val temperature: Float + // Paramètres d'échantillonnage. `temperature` historique conservé ; les + // autres ajoutés par la refonte presets 2026-06-18. ⚠ seul maxTokens est + // honoré par le moteur natif actuel — le reste prend effet quand le JNI + // expose le sampling (cf. docs/SAMPLING_ENGINE_SPEC.md). presetName = nom + // du preset appliqué (vide = réglages custom). + val temperature: Float, + val topP: Float = 0.85f, + val topK: Int = 40, + val repeatPenalty: Float = 1.1f, + val presencePenalty: Float = 0.0f, + val frequencyPenalty: Float = 0.0f, + val maxTokens: Int = 120, + val presetName: String = "" + ) + + /** Modèle d'échantillonnage nommé, réutilisable, applicable au Speaker ou au + * Thinker. Géré (CRUD) depuis l'app admin, persisté dans la config. */ + data class SamplingPreset( + val name: String, + val temperature: Float, + val topP: Float, + val topK: Int, + val repeatPenalty: Float, + val presencePenalty: Float, + val frequencyPenalty: Float, + val maxTokens: Int ) data class RuntimeConfig( @@ -69,7 +94,9 @@ class ConfigStore private constructor(private val file: File) { * 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 + val debugEnabled: Boolean = false, + /** Bibliothèque de presets de sampling réutilisables (gérée depuis l'admin). */ + val presets: List = defaultPresets() ) companion object { @@ -111,17 +138,29 @@ class ConfigStore private constructor(private val file: File) { return store } + /** Presets de sampling d'usine — inspirés du tuning Kaz (binôme) + profils + * thérapeutiques. Repère pour l'admin ; éditables/supprimables. */ + fun defaultPresets(): List = listOf( + SamplingPreset("Équilibré (défaut)", 0.7f, 0.85f, 40, 1.1f, 0.0f, 0.0f, 120), + SamplingPreset("Kaz chaleureux", 0.4f, 0.85f, 40, 1.05f, 0.2f, 0.2f, 256), + SamplingPreset("Factuel / froid", 0.3f, 0.70f, 40, 1.2f, 0.1f, 0.1f, 200), + SamplingPreset("Analyse (Thinker)", 0.2f, 0.70f, 40, 1.15f, 0.0f, 0.0f, 128), + SamplingPreset("Créatif", 0.9f, 0.95f, 60, 1.05f, 0.3f, 0.3f, 200) + ) + fun defaultConfig(): RuntimeConfig = RuntimeConfig( cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel speaker = ModelConfig( modelId = ModelRegistry.defaultSpeaker().id, systemPrompt = DEFAULT_SPEAKER_PROMPT, - temperature = 0.7f + temperature = 0.7f, + maxTokens = 450 // réponse Speaker longue (valeur prod historique) ), thinker = ModelConfig( modelId = ModelRegistry.defaultThinker().id, systemPrompt = DEFAULT_THINKER_PROMPT, - temperature = 0.0f + temperature = 0.0f, + maxTokens = 128 // bloc clinique court ), ttsEnabled = true, sttEngine = "prod", @@ -178,19 +217,51 @@ class ConfigStore private constructor(private val file: File) { root.put("rag_threshold", cfg.ragThreshold.toDouble()) root.put("rag_top_k", cfg.ragTopK) root.put("debug_enabled", cfg.debugEnabled) - 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) + root.put("speaker", modelToJson(cfg.speaker)) + root.put("thinker", modelToJson(cfg.thinker)) + root.put("presets", org.json.JSONArray().apply { + cfg.presets.forEach { p -> + put(JSONObject().apply { + put("name", p.name) + put("temperature", p.temperature) + put("top_p", p.topP) + put("top_k", p.topK) + put("repeat_penalty", p.repeatPenalty) + put("presence_penalty", p.presencePenalty) + put("frequency_penalty", p.frequencyPenalty) + put("max_tokens", p.maxTokens) + }) + } }) return root.toString(2) } + private fun modelToJson(m: ModelConfig) = JSONObject().apply { + put("model_id", m.modelId) + put("system_prompt", m.systemPrompt) + put("temperature", m.temperature) + put("top_p", m.topP) + put("top_k", m.topK) + put("repeat_penalty", m.repeatPenalty) + put("presence_penalty", m.presencePenalty) + put("frequency_penalty", m.frequencyPenalty) + put("max_tokens", m.maxTokens) + put("preset_name", m.presetName) + } + + private fun modelFromJson(js: JSONObject, d: ModelConfig) = ModelConfig( + modelId = js.optString("model_id", d.modelId), + systemPrompt = js.optString("system_prompt", d.systemPrompt), + temperature = js.optDouble("temperature", d.temperature.toDouble()).toFloat(), + topP = js.optDouble("top_p", d.topP.toDouble()).toFloat(), + topK = js.optInt("top_k", d.topK), + repeatPenalty = js.optDouble("repeat_penalty", d.repeatPenalty.toDouble()).toFloat(), + presencePenalty = js.optDouble("presence_penalty", d.presencePenalty.toDouble()).toFloat(), + frequencyPenalty = js.optDouble("frequency_penalty", d.frequencyPenalty.toDouble()).toFloat(), + maxTokens = js.optInt("max_tokens", d.maxTokens), + presetName = js.optString("preset_name", d.presetName) + ) + private fun fromJson(text: String): RuntimeConfig { val root = JSONObject(text) val defaults = defaultConfig() @@ -204,20 +275,24 @@ class ConfigStore private constructor(private val file: File) { ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(), ragTopK = root.optInt("rag_top_k", defaults.ragTopK), debugEnabled = root.optBoolean("debug_enabled", defaults.debugEnabled), - 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 + speaker = root.optJSONObject("speaker")?.let { modelFromJson(it, defaults.speaker) } ?: defaults.speaker, + thinker = root.optJSONObject("thinker")?.let { modelFromJson(it, defaults.thinker) } ?: defaults.thinker, + presets = root.optJSONArray("presets")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + arr.optJSONObject(i)?.let { js -> + SamplingPreset( + name = js.optString("name", ""), + temperature = js.optDouble("temperature", 0.7).toFloat(), + topP = js.optDouble("top_p", 0.85).toFloat(), + topK = js.optInt("top_k", 40), + repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(), + presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(), + frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(), + maxTokens = js.optInt("max_tokens", 120) + ) + } + }.filter { it.name.isNotBlank() } + } ?: defaults.presets ) } } diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt index 19136fe..6e31964 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt @@ -22,7 +22,12 @@ data class SamplingParams( val temperature: Float = 0.7f, val topP: Float = 0.85f, val topK: Int = 40, - val repetitionPenalty: Float = 1.2f + val repetitionPenalty: Float = 1.2f, + // Pénalités OpenAI-style (présence/fréquence). Réglables via les presets admin. + // ⚠ effectives seulement quand le moteur natif expose le sampling (cf. docs + // SAMPLING_ENGINE_SPEC) ; aujourd'hui le JNI n'honore que maxNewTokens. + val presencePenalty: Float = 0.0f, + val frequencyPenalty: Float = 0.0f ) data class GenerationResult( diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt index f69be9d..3160d3d 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -1742,9 +1742,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think val result = tE.generateWithSystem( prompt = "Message du patient : $patientMessage", systemPromptOverride = cfg.thinker.systemPrompt, - params = com.kazeia.core.SamplingParams( - maxNewTokens = 128, temperature = cfg.thinker.temperature - ), + params = samplingFrom(cfg.thinker), onToken = null, tag = "THINKER" ) @@ -1947,10 +1945,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think } !stoppingCriteria.shouldStop(responseBuilder.toString()) } - val sParams = SamplingParams( - maxNewTokens = 450, - temperature = conversationManager.currentTemperature() - ) + // Sampling Speaker depuis la config (preset admin). maxTokens honoré par + // le moteur ; les autres knobs prendront effet avec l'API sampling natif. + val sParams = samplingFrom(cfg.speaker) // generateWithSystem propage le prompt système Speaker par tour (bullets // cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF // (mono-tour si override) comme pour .pte. @@ -2171,6 +2168,20 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think * libération immédiate. * - Thinker model_id changé en cascade ON → release, sera lazy-loadé. */ + /** ModelConfig (preset admin) → SamplingParams pour la génération. ⚠ seul + * maxNewTokens est honoré par le moteur natif actuel ; le reste est prêt et + * prendra effet dès que le JNI expose le sampling (docs/SAMPLING_ENGINE_SPEC). */ + private fun samplingFrom(m: com.kazeia.config.ConfigStore.ModelConfig) = + com.kazeia.core.SamplingParams( + maxNewTokens = m.maxTokens, + temperature = m.temperature, + topP = m.topP, + topK = m.topK, + repetitionPenalty = m.repeatPenalty, + presencePenalty = m.presencePenalty, + frequencyPenalty = m.frequencyPenalty + ) + private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) { val old = runtimeConfig runtimeConfig = new diff --git a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt index 3a6aa3a..929674b 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt @@ -258,9 +258,17 @@ class KazeiaTelemetryProvider : ContentProvider() { "stt_engine", "llm_engine", "tts_engine", "rag_enabled", "rag_threshold", "rag_top_k", "debug_enabled", "speaker_model_id", "speaker_system_prompt", "speaker_temperature", - "thinker_model_id", "thinker_system_prompt", "thinker_temperature" + "speaker_top_p", "speaker_top_k", "speaker_repeat_penalty", + "speaker_presence_penalty", "speaker_frequency_penalty", + "speaker_max_tokens", "speaker_preset_name", + "thinker_model_id", "thinker_system_prompt", "thinker_temperature", + "thinker_top_p", "thinker_top_k", "thinker_repeat_penalty", + "thinker_presence_penalty", "thinker_frequency_penalty", + "thinker_max_tokens", "thinker_preset_name", + "presets_json" ) val cursor = MatrixCursor(cols) + val s = cfg.speaker; val t = cfg.thinker cursor.addRow(arrayOf( if (cfg.cascadeEnabled) 1 else 0, if (cfg.ttsEnabled) 1 else 0, @@ -268,12 +276,31 @@ class KazeiaTelemetryProvider : ContentProvider() { if (cfg.ragEnabled) 1 else 0, cfg.ragThreshold, cfg.ragTopK, if (cfg.debugEnabled) 1 else 0, - cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, - cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature + s.modelId, s.systemPrompt, s.temperature, + s.topP, s.topK, s.repeatPenalty, s.presencePenalty, s.frequencyPenalty, s.maxTokens, s.presetName, + t.modelId, t.systemPrompt, t.temperature, + t.topP, t.topK, t.repeatPenalty, t.presencePenalty, t.frequencyPenalty, t.maxTokens, t.presetName, + presetsToJson(cfg.presets) )) return cursor } + /** Sérialise la bibliothèque de presets en JSON (1 colonne `presets_json`). */ + private fun presetsToJson(presets: List): String { + val arr = org.json.JSONArray() + presets.forEach { p -> + arr.put(org.json.JSONObject().apply { + put("name", p.name); put("temperature", p.temperature) + put("top_p", p.topP); put("top_k", p.topK) + put("repeat_penalty", p.repeatPenalty) + put("presence_penalty", p.presencePenalty) + put("frequency_penalty", p.frequencyPenalty) + put("max_tokens", p.maxTokens) + }) + } + return arr.toString() + } + private fun modelsCursor(): Cursor { val cols = arrayOf( "id", "display_name", "pte_path", "tokenizer_path", @@ -364,20 +391,9 @@ class KazeiaTelemetryProvider : ContentProvider() { 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( - modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, - systemPrompt = values.getAsString("speaker_system_prompt") - ?: cur.speaker.systemPrompt, - temperature = values.getAsFloat("speaker_temperature") - ?: cur.speaker.temperature - ), - thinker = com.kazeia.config.ConfigStore.ModelConfig( - modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId, - systemPrompt = values.getAsString("thinker_system_prompt") - ?: cur.thinker.systemPrompt, - temperature = values.getAsFloat("thinker_temperature") - ?: cur.thinker.temperature - ) + speaker = mergeModel(values, "speaker_", cur.speaker), + thinker = mergeModel(values, "thinker_", cur.thinker), + presets = values.getAsString("presets_json")?.let { presetsFromJson(it) } ?: cur.presets ) store.save(newCfg) ctx.contentResolver.notifyChange(uri, null) @@ -385,6 +401,42 @@ class KazeiaTelemetryProvider : ContentProvider() { return 1 } + /** Fusionne les colonnes `*` d'un ContentValues dans un ModelConfig + * (merge partiel : un champ absent reste inchangé). */ + private fun mergeModel( + v: ContentValues, p: String, cur: com.kazeia.config.ConfigStore.ModelConfig + ) = com.kazeia.config.ConfigStore.ModelConfig( + modelId = v.getAsString("${p}model_id") ?: cur.modelId, + systemPrompt = v.getAsString("${p}system_prompt") ?: cur.systemPrompt, + temperature = v.getAsFloat("${p}temperature") ?: cur.temperature, + topP = v.getAsFloat("${p}top_p") ?: cur.topP, + topK = v.getAsInteger("${p}top_k") ?: cur.topK, + repeatPenalty = v.getAsFloat("${p}repeat_penalty") ?: cur.repeatPenalty, + presencePenalty = v.getAsFloat("${p}presence_penalty") ?: cur.presencePenalty, + frequencyPenalty = v.getAsFloat("${p}frequency_penalty") ?: cur.frequencyPenalty, + maxTokens = v.getAsInteger("${p}max_tokens") ?: cur.maxTokens, + presetName = v.getAsString("${p}preset_name") ?: cur.presetName + ) + + private fun presetsFromJson(text: String): List = + runCatching { + val arr = org.json.JSONArray(text) + (0 until arr.length()).mapNotNull { i -> + arr.optJSONObject(i)?.let { js -> + com.kazeia.config.ConfigStore.SamplingPreset( + name = js.optString("name", ""), + temperature = js.optDouble("temperature", 0.7).toFloat(), + topP = js.optDouble("top_p", 0.85).toFloat(), + topK = js.optInt("top_k", 40), + repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(), + presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(), + frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(), + maxTokens = js.optInt("max_tokens", 120) + ) + } + }.filter { it.name.isNotBlank() } + }.getOrDefault(emptyList()) + private fun upsertProfile(values: ContentValues): Int { val ctx = context ?: return 0 val store = com.kazeia.profiles.ProfileStore.get(ctx) diff --git a/kazeia-android/docs/SAMPLING_ENGINE_SPEC.md b/kazeia-android/docs/SAMPLING_ENGINE_SPEC.md new file mode 100644 index 0000000..f3f0b20 --- /dev/null +++ b/kazeia-android/docs/SAMPLING_ENGINE_SPEC.md @@ -0,0 +1,93 @@ +# Spec — API d'échantillonnage natif (kazeia-engine) pour les presets Kazeia + +**Date** : 2026-06-18 +**Demandeur** : app Kazeia (refonte presets admin) +**Cible** : `kazeia-engine` (lib `libkazeia_engine`, JNI `EngineJni`) + +## Contexte + +L'app admin permet désormais de régler, par modèle (Speaker / Thinker) et via des +**presets** nommés, les paramètres d'échantillonnage : `temperature`, `top_p`, +`top_k`, `repeat_penalty`, `presence_penalty`, `frequency_penalty`, `max_tokens`. + +Côté app, ces valeurs sont **persistées et transmises** jusqu'au pont JNI +(`UnifiedLlmAdapter` → `EngineLlmEngine`/`LlmSession`). **MAIS le JNI actuel +n'expose que `maxTok`** : + +```kotlin +external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String +external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback) +external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback) +``` + +Donc aujourd'hui **seul `max_tokens` agit** ; température/top-p/k/pénalités sont +inertes (l'app l'indique honnêtement dans l'UI : « effectif après MAJ moteur »). + +## Demande — exposer le sampling au JNI + +Ajouter une **variante échantillonnée** des entrées de génération (sans casser les +signatures existantes), qui prend une struct de sampling et la câble sur la chaîne +de samplers llama.cpp. Le GGUF Qwen3.5-4B (Speaker par défaut) est la cible +prioritaire. + +### Signatures proposées + +```kotlin +// Struct portée côté natif (ou 7 args primitifs si plus simple en JNI) : +// temperature: Float, topP: Float, topK: Int, +// repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float, maxTokens: Int +external fun generateSampled( + h: Long, sys: String, usr: String, + maxTokens: Int, temperature: Float, topP: Float, topK: Int, + repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float, + cb: TokenCallback +) +external fun sessionAskSampled( + h: Long, usr: String, + maxTokens: Int, temperature: Float, topP: Float, topK: Int, + repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float, + cb: TokenCallback +) +``` + +### Mapping llama.cpp (sampler chain) + +Construire la chaîne par requête (ou réutiliser un `llama_sampler` reconfiguré) : + +```c +auto * chain = llama_sampler_chain_init(llama_sampler_chain_default_params()); +llama_sampler_chain_add(chain, llama_sampler_init_top_k(top_k)); +llama_sampler_chain_add(chain, llama_sampler_init_top_p(top_p, 1)); +llama_sampler_chain_add(chain, llama_sampler_init_penalties( + /*penalty_last_n*/ 64, repeat_penalty, frequency_penalty, presence_penalty)); +llama_sampler_chain_add(chain, llama_sampler_init_temp(temperature)); +llama_sampler_chain_add(chain, llama_sampler_init_dist(/*seed*/ LLAMA_DEFAULT_SEED)); +``` + +- `temperature <= 0` ⇒ greedy (`llama_sampler_init_greedy`), ignorer les autres. +- Conserver le **thinking-off** et le template ChatML actuels inchangés. +- `presence/frequency_penalty = 0` ⇒ no-op (comportement neutre). + +### Comportement attendu + +- Valeurs de référence (preset « Kaz chaleureux ») : temp 0.4, top_p 0.85, top_k 40, + repeat 1.05, presence 0.2, frequency 0.2, max 256 — doit produire des sorties + visiblement moins répétitives / plus chaleureuses que le greedy actuel. +- Déterminisme : exposer un `seed` optionnel plus tard si besoin (pas requis v1). + +### `.pte` (NPU) — hors scope v1 + +Le runner ExecuTorch `.pte` décode **greedy**. Le sampling y est une évolution +distincte (échantillonnage post-logits côté runner). Pour l'instant les presets +n'affectent que le chemin GGUF ; documenter la limite suffit. + +## Côté app — déjà prêt + +- `SamplingParams` (com.kazeia.core) porte les 7 knobs. +- `UnifiedLlmAdapter.generateWithSystem` reçoit les params ; il suffira de router + vers `*Sampled` au lieu de `ask(max)` / `generateStream(max)` quand le JNI existe. +- `ConfigStore.ModelConfig` + presets persistent les valeurs ; l'admin les édite. + +Quand le JNI échantillonné est livré : ~10 lignes à changer côté `UnifiedLlmAdapter` +(brancher `sessionAskSampled` / `generateSampled`) et retirer la mention +« effectif après MAJ moteur » de l'UI admin.