diff options
Diffstat (limited to 'crates/asist-core/src/config.rs')
| -rw-r--r-- | crates/asist-core/src/config.rs | 508 |
1 files changed, 508 insertions, 0 deletions
diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs new file mode 100644 index 0000000..4ca8d8c --- /dev/null +++ b/crates/asist-core/src/config.rs @@ -0,0 +1,508 @@ +//! Configuración del asistente: un TOML con valores por defecto que ya +//! incorporan lo aprendido midiendo el pipeline (ver `docs/RENDIMIENTO.md`). + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub general: General, + pub audio: AudioConfig, + pub vad: VadConfig, + pub asr: AsrConfig, + pub llm: LlmConfig, + pub tts: TtsConfig, + pub tools: ToolsConfig, + pub supervisor: SupervisorConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct General { + /// Idioma de la conversación; se propaga a ASR y TTS. + pub language: String, + /// Instrucción de sistema. Pide frases cortas y sin markdown a propósito: + /// el TTS lee literalmente los asteriscos y las viñetas. + pub system_prompt: String, + /// Instrucción de sistema de la pasada en que el modelo decide si usar + /// una herramienta. + /// + /// Va aparte, y **sustituye** a `system_prompt` en esa pasada, por una + /// razón medida y no por gusto: con Qwen3.5-2B, añadir cualquier + /// indicación de estilo a esta guía —en cualquier posición, incluso dos + /// palabras— hace que el modelo deje de llamar a las herramientas y se + /// invente el dato. Medido sobre 8 intentos: la guía sola acierta 8/8; + /// con «Responde breve.» detrás, 1/8; con la persona de asistente de voz, + /// 0/8. Ver docs/RENDIMIENTO.md. + pub tools_prompt: String, + /// Turnos de historial que se envían al modelo (0 = sin memoria). + pub history_turns: usize, + /// Imprime un resumen de latencias por turno al terminar cada respuesta. + pub report_latency: bool, +} + +impl Default for General { + fn default() -> Self { + Self { + language: "es".into(), + system_prompt: concat!( + "Eres un asistente de voz en español. Tus respuestas se leen en voz alta, ", + "así que responde en una o dos frases cortas, en texto plano corrido. ", + "No uses markdown, ni listas, ni asteriscos, ni emojis, ni encabezados. ", + "No escribas URLs ni código salvo que te lo pidan explícitamente. ", + "Si no sabes algo, dilo en una frase." + ) + .into(), + tools_prompt: concat!( + "Antes de responder, comprueba si alguna de tus herramientas te da el dato. ", + "Si es así, llámala primero y espera su resultado; no contestes de memoria. ", + "Sólo cuando tengas el resultado, resúmelo en una frase." + ) + .into(), + history_turns: 8, + report_latency: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AudioConfig { + /// Nombre (o subcadena) del dispositivo de entrada; vacío = el de por defecto. + pub input_device: String, + /// Ídem para la salida. + pub output_device: String, + /// Segundos de audio que la reproducción mantiene en cola antes de + /// arrancar. Amortigua los baches del TTS sin añadir latencia perceptible. + pub playback_prebuffer: f32, + /// Ganancia aplicada a la reproducción. + pub output_gain: f32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + input_device: String::new(), + output_device: String::new(), + playback_prebuffer: 0.20, + output_gain: 1.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct VadConfig { + /// Ventana de audio por decisión del detector. + pub frame_seconds: f32, + /// Audio conservado antes del inicio de voz para no cortar la primera sílaba. + pub preroll_seconds: f32, + /// Silencio que cierra una intervención. + pub silence_hold: f32, + /// Intervención más corta que merezca una transcripción final. + pub min_utterance: f32, + /// Corte forzado, que acota el coste de la decodificación final. + pub max_utterance: f32, + /// Múltiplo del suelo de ruido a partir del cual se considera voz. + pub threshold_factor: f32, + pub min_threshold: f32, + pub max_threshold: f32, + /// Permite hablar encima del asistente para cortarlo. + /// + /// Desactivado por defecto: con altavoces abiertos el micrófono se oye a sí + /// mismo y el asistente se interrumpe solo. Actívalo con auriculares o con + /// cancelación de eco del sistema. + pub barge_in: bool, + /// Con barge-in activo, cuánto más fuerte que el umbral normal debe sonar + /// la voz para cortar. Sube el listón frente al eco del altavoz. + pub barge_in_factor: f32, +} + +impl Default for VadConfig { + fn default() -> Self { + Self { + frame_seconds: 0.1, + preroll_seconds: 0.3, + silence_hold: 0.8, + min_utterance: 0.3, + max_utterance: 20.0, + threshold_factor: 3.0, + min_threshold: 0.0008, + max_threshold: 0.02, + barge_in: false, + barge_in_factor: 4.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AsrConfig { + /// Carpeta del modelo Canary en ONNX. + pub model_dir: PathBuf, + pub source_lang: String, + pub target_lang: String, + /// Ventana deslizante de las transcripciones provisionales. + pub window: f32, + /// Avance de la ventana entre decodificaciones. + pub step: f32, + /// Ventanas que deben coincidir para dar una palabra por estable. + pub stability: usize, + /// Emitir transcripciones provisionales. Cuestan CPU y sólo sirven para + /// verlas en pantalla: el turno se dispara con la final. + pub partials: bool, + /// Carga una segunda instancia del modelo para que las decodificaciones + /// finales no bloqueen a las provisionales. Duplica la memoria. + pub dedicated_final_model: bool, + /// Proveedor de ejecución de ONNX Runtime: `cpu`, `cuda`, ... + pub execution_provider: String, + pub inter_threads: usize, + pub intra_threads: usize, +} + +impl Default for AsrConfig { + fn default() -> Self { + Self { + model_dir: PathBuf::from("vendor/canary-rs/models/canary-180m-flash-onnx"), + source_lang: "es".into(), + target_lang: "es".into(), + window: 6.0, + step: 0.4, + stability: 2, + partials: true, + dedicated_final_model: false, + // CPU a propósito: la GPU de 4 GB está ocupada por el hablante del + // TTS, y disputársela sale más caro que decodificar en CPU. + execution_provider: "cpu".into(), + inter_threads: 2, + intra_threads: 4, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LlmConfig { + pub host: String, + pub port: u16, + pub model: String, + pub temperature: f32, + pub top_p: f32, + pub top_k: i32, + pub min_p: f32, + pub repeat_penalty: f32, + pub max_tokens: u32, + /// Vueltas máximas del bucle de herramientas antes de rendirse. + pub max_tool_rounds: usize, + pub request_timeout_secs: u64, +} + +impl Default for LlmConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".into(), + port: 8012, + model: String::new(), + temperature: 0.7, + top_p: 0.9, + top_k: 40, + min_p: 0.1, + repeat_penalty: 1.1, + // Una respuesta hablada larga cansa; el recorte también acota el + // coste de la síntesis, que es la etapa lenta. + max_tokens: 300, + max_tool_rounds: 4, + request_timeout_secs: 120, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TtsConfig { + pub host: String, + pub port: u16, + /// Voz registrada en el servidor, o una del modelo. + pub voice: String, + /// Voz clonada que se registra al arrancar, si se define. + pub reference: Option<ReferenceVoice>, + pub language: String, + pub temperature: f32, + pub top_k: i32, + pub top_p: f32, + pub repetition_penalty: f32, + pub max_new_tokens: u32, + /// Sintetiza una frase corta al arrancar. La primera petición paga la + /// construcción de los grafos: ~3,5 s que conviene no gastar en el + /// primer turno real. + pub warmup: bool, + pub request_timeout_secs: u64, +} + +impl Default for TtsConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".into(), + port: 8013, + voice: "asistente".into(), + reference: None, + language: "spanish".into(), + temperature: 0.9, + top_k: 50, + top_p: 1.0, + repetition_penalty: 1.05, + max_new_tokens: 2048, + warmup: true, + request_timeout_secs: 180, + } + } +} + +/// Latentes de una voz clonada, tal y como los produce `qwen-codec --talker`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReferenceVoice { + /// Nombre con el que se registra en el servidor. + pub name: String, + /// Embedding del hablante (`.spk`). + pub speaker: PathBuf, + /// Códigos de referencia (`.rvq`), que activan el clonado ICL. + pub codes: PathBuf, + /// Transcripción de la referencia (`.txt`). + pub transcript: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ToolsConfig { + /// Deja que el modelo llame a herramientas. + pub enabled: bool, + /// Usa `general.tools_prompt` en solitario para la pasada en que el + /// modelo decide si llamar a una herramienta, y `general.system_prompt` + /// sólo para redactar la respuesta hablada. + /// + /// Es lo único que hace que las herramientas funcionen de verdad con este + /// modelo (ver `tools_prompt`), a cambio de que las respuestas que no + /// usan herramienta pierdan la guía de estilo. Se compensa en parte + /// porque el limpiador de texto quita el markdown antes de hablar. + /// Ponlo a `false` para priorizar el estilo sobre las herramientas. + pub dedicated_prompt: bool, + /// Habilita la herramienta de ejecución de órdenes del sistema. + /// + /// Apagada por defecto a conciencia: darle una shell a un modelo que + /// obedece a lo que oye por el micrófono es un cambio de postura de + /// seguridad, no una opción de comodidad. + pub shell: bool, + /// Órdenes admitidas, comparadas contra el ejecutable (argv[0]). + /// Una lista vacía deniega todo aunque `shell` esté activo. + pub shell_allowlist: Vec<String>, + /// Segundos que puede durar una orden antes de que se la mate. + pub shell_timeout_secs: u64, + /// Registra la orden pero no la ejecuta. Útil para estrenar la lista blanca. + pub shell_dry_run: bool, + /// Directorio de trabajo de las órdenes; vacío = el del proceso. + pub shell_working_dir: String, +} + +impl Default for ToolsConfig { + fn default() -> Self { + Self { + enabled: true, + dedicated_prompt: true, + shell: false, + shell_allowlist: vec![ + "date".into(), + "uptime".into(), + "free".into(), + "df".into(), + "ls".into(), + ], + shell_timeout_secs: 10, + shell_dry_run: false, + shell_working_dir: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SupervisorConfig { + /// Lanza los servidores en lugar de suponer que ya están arriba. + pub manage: bool, + /// Segundos de espera a que un servidor conteste a `/health`. + pub startup_timeout_secs: u64, + pub llama: LlamaProcess, + pub tts: TtsProcess, +} + +impl Default for SupervisorConfig { + fn default() -> Self { + Self { + manage: true, + startup_timeout_secs: 180, + llama: LlamaProcess::default(), + tts: TtsProcess::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LlamaProcess { + pub binary: PathBuf, + pub model: PathBuf, + pub mmproj: PathBuf, + /// Plantilla de chat que se pasa con `--chat-template-file`. + /// + /// La del modelo abre `<think>` sin cerrarlo nunca, y el asistente se pasa + /// entonces varios segundos razonando antes de decir la primera palabra. + /// Esta copia deja el bloque cerrado de entrada. + pub chat_template: PathBuf, + pub extra_args: Vec<String>, +} + +impl Default for LlamaProcess { + fn default() -> Self { + Self { + binary: PathBuf::from("vendor/llama.cpp/build/bin/llama-server"), + model: PathBuf::from("models/Qwen3.5-2B.Q5_K_M.gguf"), + mmproj: PathBuf::from("models/mmproj-BF16.gguf"), + chat_template: PathBuf::from("config/qwen35-no-think.jinja"), + extra_args: [ + "--threads", + "10", + "--threads-batch", + "10", + "--batch-size", + "512", + "--ubatch-size", + "256", + "--gpu-layers", + "10", + "--split-mode", + "layer", + "--tensor-split", + "1", + "--main-gpu", + "0", + "--no-mmap", + "--ctx-size", + "8192", + "--parallel", + "2", + "--cache-ram", + "6144", + "--rope-freq-base", + "1000000", + "--rope-freq-scale", + "0.25", + "--jinja", + ] + .iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TtsProcess { + pub binary: PathBuf, + pub model: PathBuf, + pub codec: PathBuf, + /// Segundos de audio que el codec acumula antes de decodificar un bloque. + /// + /// El valor de fábrica son 24 s, que en la práctica significa «no + /// devuelvas nada hasta terminar la frase entera»: medido, baja el primer + /// audio de 4,9 s a 0,6 s. Es el ajuste con más efecto de todo el sistema. + pub codec_chunk_dur: f32, + pub extra_args: Vec<String>, +} + +impl Default for TtsProcess { + fn default() -> Self { + Self { + binary: PathBuf::from("vendor/qwentts.cpp/build/tts-server"), + model: PathBuf::from("models/qwen-talker-1.7b-base-Q8_0.gguf"), + codec: PathBuf::from("models/qwen-tokenizer-12hz-Q8_0.gguf"), + codec_chunk_dur: 1.0, + extra_args: Vec::new(), + } + } +} + +impl Config { + pub fn load(path: impl AsRef<Path>) -> Result<Self> { + let path = path.as_ref(); + let raw = std::fs::read_to_string(path) + .map_err(|e| Error::Config(format!("no se pudo leer {}: {e}", path.display())))?; + let mut config: Config = + toml::from_str(&raw).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; + // Las rutas relativas se resuelven contra la carpeta del TOML, no + // contra el directorio desde el que se lanza el binario. + if let Some(base) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + config.rebase(base); + } + config.validate()?; + Ok(config) + } + + /// Reinterpreta las rutas relativas respecto de `base`. + pub fn rebase(&mut self, base: &Path) { + let fix = |p: &mut PathBuf| { + if p.is_relative() { + *p = base.join(&*p); + } + }; + fix(&mut self.asr.model_dir); + fix(&mut self.supervisor.llama.binary); + fix(&mut self.supervisor.llama.model); + fix(&mut self.supervisor.llama.mmproj); + fix(&mut self.supervisor.llama.chat_template); + fix(&mut self.supervisor.tts.binary); + fix(&mut self.supervisor.tts.model); + fix(&mut self.supervisor.tts.codec); + if let Some(reference) = self.tts.reference.as_mut() { + fix(&mut reference.speaker); + fix(&mut reference.codes); + fix(&mut reference.transcript); + } + } + + fn validate(&self) -> Result<()> { + if self.vad.silence_hold <= 0.0 { + return Err(Error::Config("vad.silence_hold debe ser > 0".into())); + } + if self.vad.min_utterance >= self.vad.max_utterance { + return Err(Error::Config( + "vad.min_utterance debe ser menor que vad.max_utterance".into(), + )); + } + if self.asr.step <= 0.0 || self.asr.window <= self.asr.step { + return Err(Error::Config( + "asr.window debe ser mayor que asr.step, y ambos > 0".into(), + )); + } + if self.tools.shell && self.tools.shell_allowlist.is_empty() { + return Err(Error::Config( + "tools.shell está activo pero tools.shell_allowlist está vacía: \ + declara las órdenes permitidas o desactiva tools.shell" + .into(), + )); + } + Ok(()) + } + + pub fn llm_authority(&self) -> String { + format!("{}:{}", self.llm.host, self.llm.port) + } + + pub fn tts_authority(&self) -> String { + format!("{}:{}", self.tts.host, self.tts.port) + } +} |