feat(admin): presets d'échantillonnage LLM (Speaker/Thinker) — app-side complet

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) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-18 05:59:18 +02:00
parent b11d8a96d4
commit 471fb5d5fb
11 changed files with 774 additions and 68 deletions

View File

@ -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()
}

View File

@ -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<KazeiaConfigClient.Preset>): Boolean =
client.updatePresets(presets)
fun updateSpeakerModel(modelId: String): Boolean =
client.updateConfig(speakerModelId = modelId)

View File

@ -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<Preset> = 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<Preset> = 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<Preset>): 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<ModelInfo> = runCatching {
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<ModelInfo>()
@ -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<Preset>): 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)
}
}

View File

@ -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),

View File

@ -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<List<Preset>>(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<Preset>,
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<Preset>, onSave: (List<Preset>) -> 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<Int?>(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") }
}
}
}
}
}

View File

@ -6,6 +6,7 @@
<string name="nav_voices">Voix</string>
<string name="nav_profiles">Profils patients</string>
<string name="nav_prompts">Prompts</string>
<string name="nav_parameters">Paramètres</string>
<string name="nav_rag">Base documentaire</string>
<string name="nav_history">Historique</string>
<string name="nav_telemetry">Telemetry</string>

View File

@ -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<SamplingPreset> = 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<SamplingPreset> = 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("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)
})
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 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()
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)
)
} ?: 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
}
}.filter { it.name.isNotBlank() }
} ?: defaults.presets
)
}
}

View File

@ -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(

View File

@ -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

View File

@ -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<com.kazeia.config.ConfigStore.SamplingPreset>): 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 `<prefix>*` 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<com.kazeia.config.ConfigStore.SamplingPreset> =
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)

View File

@ -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.