diff options
| author | elvis <elvis@claros.ar> | 2026-09-06 19:21:16 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-06 19:23:37 -0300 |
| commit | f8f98e83481e7376a235fb305a095552433f79ab (patch) | |
| tree | 6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-core/src/error.rs | |
| download | asist-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.rs | 82 |
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))) + } +} |