From f8f98e83481e7376a235fb305a095552433f79ab Mon Sep 17 00:00:00 2001 From: elvis Date: Sun, 6 Sep 2026 19:21:16 -0300 Subject: Local voice assistant on top of Canary, llama.cpp and qwentts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline en Rust de hilos y canales que une los tres motores: el micrófono alimenta un segmentador con VAD, las intervenciones cerradas van al reconocedor, la transcripción al modelo y cada frase que este cierra sale hacia el sintetizador sin esperar al resto de la respuesta. Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por los canales lleva el turno al que pertenece, así que interrumpir es subir el contador y levantar dos banderas de cancelación. Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas), audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de procesos y orquestador). Los motores van como submódulos fijados a un commit, con los cambios locales en vendor/patches. Midiendo el pipeline aparecieron tres cuellos de botella de configuración que valieron más que cualquier cambio de código, todos documentados en docs/RENDIMIENTO.md: - tts-server decodificaba el audio en bloques de 24 s, de modo que el modo «streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio. - La plantilla de chat del modelo abre y no lo cierra nunca, sin variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una copia de la plantilla que deja el bloque cerrado de entrada. - Cualquier indicación de estilo junto a la guía de herramientas hace que este modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora entre dos instrucciones de sistema. La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que interprete metacaracteres y plazo máximo. 68 pruebas unitarias sin modelos, más seis de integración que se saltan solas si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no caben en 4 GB y miden contención en vez de latencia. Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU --- crates/asist-core/src/error.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 crates/asist-core/src/error.rs (limited to 'crates/asist-core/src/error.rs') diff --git a/crates/asist-core/src/error.rs b/crates/asist-core/src/error.rs new file mode 100644 index 0000000..b559bee --- /dev/null +++ b/crates/asist-core/src/error.rs @@ -0,0 +1,82 @@ +use std::fmt; + +pub type Result = std::result::Result; + +/// Un fallo de una etapa del pipeline. El orquestador decide si es fatal o si +/// basta con abortar el turno en curso, así que el error lleva esa distinción +/// en lugar de dejarla al criterio de quien lo recibe. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("configuración inválida: {0}")] + Config(String), + + #[error("audio: {0}")] + Audio(String), + + #[error("ASR: {0}")] + Asr(String), + + #[error("LLM: {0}")] + Llm(String), + + #[error("TTS: {0}")] + Tts(String), + + #[error("HTTP {status} en {url}: {body}")] + Http { + status: u16, + url: String, + body: String, + }, + + #[error("transporte hacia {url}: {source}")] + Transport { + url: String, + #[source] + source: std::io::Error, + }, + + #[error("herramienta «{tool}»: {message}")] + Tool { tool: String, message: String }, + + #[error("operación cancelada")] + Cancelled, + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +impl Error { + /// Un error fatal tumba el asistente; el resto sólo aborta el turno. + /// + /// Los servidores locales se caen y se reinician, y el usuario prefiere + /// oír «no te he entendido» a que el proceso muera, así que sólo la + /// configuración y el audio (que no se pueden reintentar) son fatales. + pub fn is_fatal(&self) -> bool { + matches!(self, Error::Config(_) | Error::Audio(_)) + } + + /// `true` cuando reintentar tiene sentido: el servidor aún está cargando + /// el modelo, o se ha caído la conexión a mitad de una petición. + pub fn is_retryable(&self) -> bool { + match self { + Error::Transport { .. } => true, + Error::Http { status, .. } => *status == 503 || *status == 429 || *status >= 500, + _ => false, + } + } +} + +/// Contexto legible para los `Result` que cruzan una frontera de crate. +pub trait Context { + fn ctx(self, f: impl FnOnce() -> String) -> Result; +} + +impl Context for std::result::Result { + fn ctx(self, f: impl FnOnce() -> String) -> Result { + self.map_err(|e| Error::Config(format!("{}: {}", f(), e))) + } +} -- cgit v1.2.3