aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/error.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-06 19:21:16 -0300
committerelvis <elvis@claros.ar>2026-09-06 19:23:37 -0300
commitf8f98e83481e7376a235fb305a095552433f79ab (patch)
tree6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-core/src/error.rs
downloadasist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz
asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip
Local voice assistant on top of Canary, llama.cpp and qwentts
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 <think> 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
Diffstat (limited to 'crates/asist-core/src/error.rs')
-rw-r--r--crates/asist-core/src/error.rs82
1 files changed, 82 insertions, 0 deletions
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<T> = std::result::Result<T, Error>;
+
+/// 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<T> {
+ fn ctx(self, f: impl FnOnce() -> String) -> Result<T>;
+}
+
+impl<T, E: fmt::Display> Context<T> for std::result::Result<T, E> {
+ fn ctx(self, f: impl FnOnce() -> String) -> Result<T> {
+ self.map_err(|e| Error::Config(format!("{}: {}", f(), e)))
+ }
+}