aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app/tests/integracion.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
committerelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
commit8518a63f55153e7f45fd49ad6caff5555f4e374f (patch)
tree636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-app/tests/integracion.rs
parent69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff)
downloadasist-p-main.tar.gz
asist-p-main.zip
Translate code, comments, logs and terminal UI to English; add English README; rename scriptsHEADmain
Diffstat (limited to 'crates/asist-app/tests/integracion.rs')
-rw-r--r--crates/asist-app/tests/integracion.rs557
1 files changed, 0 insertions, 557 deletions
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs
deleted file mode 100644
index d0fca38..0000000
--- a/crates/asist-app/tests/integracion.rs
+++ /dev/null
@@ -1,557 +0,0 @@
-//! Pruebas contra los servidores de verdad.
-//!
-//! Se saltan solas cuando no hay nada escuchando, para que `cargo test` siga
-//! siendo útil sin 5 GB de modelos cargados. Con los servidores arriba
-//! comprueban lo que las pruebas unitarias no pueden: que el protocolo que se
-//! ha escrito es el que los servidores hablan, y que las latencias siguen
-//! donde se midieron.
-//!
-//! Para ejecutarlas: scripts/servidores.sh arrancar && cargo test -- --ignored
-
-use std::path::PathBuf;
-use std::sync::{Mutex, MutexGuard, OnceLock};
-use std::time::{Duration, Instant};
-
-use asist_core::config::Config;
-use asist_core::http::Cancel;
-use asist_core::tools::{Tool, ToolRegistry};
-use asist_llm::chat::Conversation;
-use asist_llm::{Delta, LlmClient, Message};
-use asist_tools::FrameSource;
-use asist_tts::TtsClient;
-
-/// Las pruebas se turnan la GPU.
-///
-/// Los dos servidores comparten una tarjeta de 4 GB, y medido: con la prueba
-/// de síntesis corriendo a la vez, el primer token del LLM pasa de ~0,5 s a
-/// ~5 s. En paralelo esto no mide latencia, mide quién llegó primero. El
-/// pipeline de verdad no tiene el problema porque la síntesis no arranca
-/// hasta que el modelo cierra su primera frase.
-fn en_exclusiva() -> MutexGuard<'static, ()> {
- static GPU: OnceLock<Mutex<()>> = OnceLock::new();
- GPU.get_or_init(|| Mutex::new(()))
- .lock()
- .unwrap_or_else(|e| e.into_inner())
-}
-
-fn config() -> Config {
- let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/asistente.toml");
- Config::load(path).expect("no se pudo cargar la configuración")
-}
-
-/// Devuelve los clientes, o `None` si los servidores no están arriba.
-fn clientes() -> Option<(Config, LlmClient, TtsClient)> {
- let config = config();
- let llm = LlmClient::new(config.llm_authority(), &config.llm);
- let tts = TtsClient::new(config.tts_authority(), &config.tts);
- if !llm.healthy() || !tts.healthy() {
- eprintln!("servidores no disponibles; prueba omitida");
- return None;
- }
- Some((config, llm, tts))
-}
-
-#[test]
-fn el_llm_responde_en_streaming_sin_razonar_en_voz_alta() {
- let _gpu = en_exclusiva();
- let Some((config, llm, _)) = clientes() else {
- return;
- };
-
- let mut chat = Conversation::new(&config.general.system_prompt, 4);
- chat.push(Message::user("Saluda en una frase corta."));
-
- let started = Instant::now();
- let mut ttft = None;
- let outcome = llm
- .stream(&chat, None, &Cancel::new(), |delta| {
- if let Delta::Text(_) = delta {
- ttft.get_or_insert_with(|| started.elapsed());
- }
- true
- })
- .expect("la petición al LLM falló");
-
- assert!(!outcome.text.trim().is_empty(), "respuesta vacía");
- // La plantilla con <think> sin cerrar deja esto en 7-9 s. Si esta prueba
- // se cae, casi seguro que llama-server arrancó sin --chat-template-file.
- let ttft = ttft.expect("no llegó ningún fragmento con texto");
- assert!(
- ttft < Duration::from_secs(3),
- "el primer token tardó {ttft:?}: ¿arrancó llama-server con la plantilla sin razonamiento?"
- );
- assert!(
- !outcome.text.contains("<think>"),
- "el razonamiento se está colando en la respuesta: {}",
- outcome.text
- );
-}
-
-#[test]
-fn el_llm_puede_pedir_una_herramienta() {
- let _gpu = en_exclusiva();
- let Some((config, llm, _)) = clientes() else {
- return;
- };
-
- let tools = ToolRegistry::from_config(&config.tools);
- let mut chat = Conversation::new(
- "Eres un asistente. Usa las herramientas disponibles cuando hagan falta.",
- 4,
- );
- chat.push(Message::user(
- "¿Qué hora es exactamente? Usa la herramienta.",
- ));
-
- let outcome = llm
- .stream(&chat, Some(&tools), &Cancel::new(), |_| true)
- .expect("la petición al LLM falló");
-
- if outcome.tool_calls.is_empty() {
- // Un modelo de 2B no siempre acierta a llamar; lo que se comprueba es
- // que el ida y vuelta funciona, no que el modelo sea listo.
- eprintln!("el modelo no pidió herramienta: {}", outcome.text);
- return;
- }
- let call = &outcome.tool_calls[0];
- assert_eq!(call.name, "hora_actual");
- let result = tools.dispatch(call);
- assert!(result.ok, "la herramienta falló: {}", result.output);
- assert!(!result.output.trim().is_empty());
-}
-
-#[test]
-fn la_sintesis_empieza_a_sonar_antes_de_terminar() {
- let _gpu = en_exclusiva();
- let Some((config, _, tts)) = clientes() else {
- return;
- };
-
- if let Some(reference) = &config.tts.reference {
- tts.register_voice(reference)
- .expect("no se registró la voz");
- }
- // Sin precalentar, la primera síntesis carga los grafos y mide el arranque
- // del servidor en vez del régimen normal.
- tts.warmup().expect("falló el precalentado");
-
- let mut bloques = 0usize;
- let outcome = tts
- .speak(
- "Hola, esto es una prueba de latencia del sintetizador de voz.",
- &Cancel::new(),
- |samples| {
- if !samples.is_empty() {
- bloques += 1;
- }
- true
- },
- )
- .expect("la síntesis falló");
-
- assert!(outcome.samples > 0, "no se recibió audio");
- assert!(
- bloques > 1,
- "el audio llegó de una sola vez ({bloques} bloque): revisa --codec-chunk-dur, \
- que de fábrica son 24 s y bloquean el streaming"
- );
- let ttfb = outcome.ttfb.expect("sin marca de primer bloque");
- assert!(
- ttfb < Duration::from_secs(2),
- "el primer audio tardó {ttfb:?}; con --codec-chunk-dur 1.0 se midió 585 ms"
- );
- eprintln!(
- "ttfb={:?} audio={:.2}s rtf={:.2}",
- ttfb,
- outcome.audio_secs(),
- outcome.rtf()
- );
-}
-
-#[test]
-fn una_sintesis_se_puede_cortar_a_media_frase() {
- let _gpu = en_exclusiva();
- let Some((config, _, tts)) = clientes() else {
- return;
- };
- if let Some(reference) = &config.tts.reference {
- let _ = tts.register_voice(reference);
- }
-
- // Es la operación que sostiene el barge-in: si no se pudiera cortar, el
- // asistente seguiría hablando encima del usuario hasta acabar la frase.
- let cancel = Cancel::new();
- let mut recibidos = 0usize;
- let outcome = tts
- .speak(
- "Esta es una frase larga que no debería llegar a escucharse entera \
- porque se va a cortar en cuanto empiece a sonar el primer bloque.",
- &cancel,
- |samples| {
- recibidos += samples.len();
- // Cortar en el primer bloque.
- false
- },
- )
- .expect("la síntesis falló");
-
- assert!(outcome.cancelled, "debió quedar marcada como cancelada");
- assert!(
- outcome.audio_secs() < 3.0,
- "se recibieron {:.1} s de audio: el corte no surtió efecto",
- outcome.audio_secs()
- );
-}
-
-#[test]
-fn la_voz_clonada_queda_registrada() {
- let _gpu = en_exclusiva();
- let Some((config, _, tts)) = clientes() else {
- return;
- };
- let Some(reference) = &config.tts.reference else {
- eprintln!("sin voz de referencia configurada; prueba omitida");
- return;
- };
- tts.register_voice(reference)
- .expect("no se registró la voz");
- let voces = tts.voices().expect("no se pudieron listar las voces");
- assert!(
- voces.contains(&reference.name),
- "«{}» no aparece entre {voces:?}",
- reference.name
- );
-}
-
-#[test]
-#[ignore = "carga el modelo de ASR (~200 MB) y decodifica; lento"]
-fn el_asr_transcribe_lo_que_sintetiza_el_tts() {
- let _gpu = en_exclusiva();
- // El bucle completo sin micrófono: se sintetiza una frase conocida y se
- // comprueba que el reconocedor la recupera. Es la única prueba que ejerce
- // ASR y TTS contra el mismo audio.
- let Some((config, _, tts)) = clientes() else {
- return;
- };
- if let Some(reference) = &config.tts.reference {
- let _ = tts.register_voice(reference);
- }
-
- let frase = "hola qué tal estás hoy";
- let mut audio: Vec<f32> = Vec::new();
- tts.speak(frase, &Cancel::new(), |samples| {
- audio.extend_from_slice(samples);
- true
- })
- .expect("la síntesis falló");
- assert!(!audio.is_empty(), "no se generó audio");
-
- let recognizer =
- asist_asr::Recognizer::load(&config.asr).expect("no se pudo cargar el modelo de ASR");
- let texto = recognizer
- .transcribe(&audio, asist_tts::SAMPLE_RATE)
- .expect("la transcripción falló")
- .to_lowercase();
-
- eprintln!("dicho: «{frase}» / oído: «{texto}»");
- // No se exige una coincidencia exacta —24 kHz remuestreados a 16 kHz y un
- // modelo de 180M dan para lo que dan—, sino que reconozca algo.
- let acertadas = frase
- .split_whitespace()
- .filter(|palabra| texto.contains(palabra))
- .count();
- assert!(
- acertadas >= 2,
- "sólo se reconocieron {acertadas} palabras de «{frase}» en «{texto}»"
- );
-}
-
-// ---------------------------------------------------------------------------
-// Buscar en internet y mirar por la cámara
-// ---------------------------------------------------------------------------
-
-#[test]
-fn la_busqueda_devuelve_algo_pronunciable() {
- let config = config();
- if !config.search.enabled {
- eprintln!("búsqueda desactivada; prueba omitida");
- return;
- }
- let Ok(key) = std::env::var(&config.search.api_key_env) else {
- eprintln!("sin ${}; prueba omitida", config.search.api_key_env);
- return;
- };
- if key.trim().is_empty() {
- eprintln!("${} vacía; prueba omitida", config.search.api_key_env);
- return;
- }
-
- let tool = asist_tools::WebSearch::new(
- asist_tools::SearchBackend::Tavily { api_key: key },
- config.search.max_results,
- Duration::from_secs(config.search.timeout_secs),
- );
- let started = Instant::now();
- let out = tool
- .call(&serde_json::json!({ "consulta": "capital de Australia" }))
- .expect("la búsqueda falló");
-
- eprintln!(
- "búsqueda en {:?}: {}",
- started.elapsed(),
- &out[..out.len().min(200)]
- );
- assert!(!out.trim().is_empty());
- assert!(
- out.to_lowercase().contains("canberra"),
- "esperaba la respuesta en el resumen, salió: {out}"
- );
- // Lo que devuelve se lee en voz alta; una parrafada de miles de caracteres
- // ahoga al modelo que tiene que resumirla.
- assert!(
- out.len() < 6000,
- "el resultado es demasiado largo: {} bytes",
- out.len()
- );
-}
-
-#[test]
-fn una_consulta_inventada_no_revienta_el_turno() {
- let config = config();
- let Ok(key) = std::env::var(&config.search.api_key_env) else {
- return;
- };
- if key.trim().is_empty() {
- return;
- }
- // Vía el registro, que es como llega de verdad: un fallo tiene que volver
- // como texto para el modelo, nunca como un error que corte la respuesta.
- let mut registry = ToolRegistry::new();
- registry.register(std::sync::Arc::new(asist_tools::WebSearch::new(
- asist_tools::SearchBackend::Tavily {
- api_key: "clave-invalida".into(),
- },
- 3,
- Duration::from_secs(10),
- )));
- let outcome = registry.dispatch(&asist_core::tools::ToolCall {
- id: "1".into(),
- name: "buscar_en_internet".into(),
- arguments: r#"{"consulta":"algo"}"#.into(),
- });
- assert!(!outcome.ok);
- assert!(
- !outcome.output.contains("clave-invalida"),
- "el mensaje de error no debe llevar la clave dentro: {}",
- outcome.output
- );
-}
-
-fn camera_source(config: &Config) -> Option<asist_tools::Camera> {
- if !config.camera.enabled || !config.camera.device.exists() {
- eprintln!("sin cámara; prueba omitida");
- return None;
- }
- Some(asist_tools::Camera::new(asist_tools::CameraConfig {
- device: config.camera.device.clone(),
- width: config.camera.width,
- height: config.camera.height,
- warmup_frames: config.camera.warmup_frames,
- timeout: Duration::from_secs(config.camera.timeout_secs),
- save_dir: None,
- }))
-}
-
-fn screen_source(config: &Config) -> Option<asist_tools::Screen> {
- let screen = asist_tools::Screen::new(asist_tools::ScreenConfig {
- command: config.screen.command.clone(),
- width: config.screen.width,
- output: config.screen.output.clone(),
- timeout: Duration::from_secs(config.screen.timeout_secs),
- save_dir: None,
- });
- if !config.screen.enabled || screen.available().is_err() {
- eprintln!("sin pantalla capturable; prueba omitida");
- return None;
- }
- Some(screen)
-}
-
-/// Ancho de un JPEG, leído de su cabecera SOF.
-fn jpeg_width(bytes: &[u8]) -> Option<u32> {
- let mut i = 2;
- while i + 9 < bytes.len() {
- if bytes[i] != 0xFF {
- return None;
- }
- let marker = bytes[i + 1];
- let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
- if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
- return Some(u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32);
- }
- i += 2 + len;
- }
- None
-}
-
-#[test]
-fn la_camara_captura_un_jpeg() {
- let config = config();
- let Some(source) = camera_source(&config) else {
- return;
- };
-
- let started = Instant::now();
- let frame = source.capture().expect("la captura falló");
- eprintln!(
- "fotograma de {} KB en {:?}",
- frame.len() / 1024,
- started.elapsed()
- );
-
- assert!(
- frame.len() > 1000,
- "el fotograma es sospechosamente pequeño"
- );
- // Cabecera JPEG: si no está, ffmpeg devolvió otra cosa y el modelo la
- // rechazaría sin decir por qué.
- assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG");
-}
-
-#[test]
-fn la_pantalla_se_captura_al_ancho_configurado() {
- let config = config();
- let Some(source) = screen_source(&config) else {
- return;
- };
-
- let started = Instant::now();
- let frame = source.capture().expect("la captura falló");
- eprintln!(
- "captura de {} KB en {:?}",
- frame.len() / 1024,
- started.elapsed()
- );
- assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG");
-
- // El ancho es lo que separa leer de inventar, así que se comprueba que el
- // guion de verdad redujo la imagen y no la mandó a tamaño completo.
- let width = jpeg_width(&frame).expect("no se pudo leer el ancho del JPEG");
- assert_eq!(
- width, config.screen.width,
- "la captura salió a {width} px y se pedían {}",
- config.screen.width
- );
-}
-
-#[test]
-#[ignore = "enciende la cámara y hace una pasada de visión; lento"]
-fn el_modelo_describe_lo_que_ve_la_camara() {
- let _gpu = en_exclusiva();
- let Some((config, llm, _)) = clientes() else {
- return;
- };
- let Some(source) = camera_source(&config) else {
- return;
- };
-
- let tool = asist_tools::VisionTool::camera(Box::new(source), std::sync::Arc::new(llm));
- let started = Instant::now();
- let out = tool
- .call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" }))
- .expect("la descripción falló");
-
- eprintln!("visión en {:?}: {out}", started.elapsed());
- assert!(
- out.split_whitespace().count() >= 3,
- "descripción vacía o mínima: {out}"
- );
- assert!(
- started.elapsed() < Duration::from_secs(20),
- "tardó {:?}; revisa camera.width/height",
- started.elapsed()
- );
-}
-
-#[test]
-#[ignore = "captura la pantalla y hace una pasada de visión; lento"]
-fn el_modelo_describe_la_pantalla() {
- let _gpu = en_exclusiva();
- let Some((config, llm, _)) = clientes() else {
- return;
- };
- let Some(source) = screen_source(&config) else {
- return;
- };
-
- let tool = asist_tools::VisionTool::screen(Box::new(source), std::sync::Arc::new(llm));
- let started = Instant::now();
- let out = tool
- .call(&serde_json::json!({ "pregunta": "¿Qué hay en la pantalla?" }))
- .expect("la descripción falló");
-
- eprintln!("pantalla en {:?}: {out}", started.elapsed());
- assert!(
- out.split_whitespace().count() >= 3,
- "descripción vacía o mínima: {out}"
- );
- // A 1280 px se midieron 7,6 s. Si esto se dispara, o la GPU está ocupada o
- // alguien subió screen.width sin mirar el coste.
- assert!(
- started.elapsed() < Duration::from_secs(30),
- "tardó {:?}; revisa screen.width",
- started.elapsed()
- );
-}
-
-#[test]
-#[ignore = "sale a internet con los dos buscadores; lento"]
-fn los_dos_buscadores_responden_a_lo_mismo() {
- // Compara lo que devuelve cada uno para las mismas preguntas. No afirma
- // cuál es mejor —eso depende de la pregunta— sino que ambos funcionan y
- // deja las cifras a la vista para elegir con datos.
- let config = config();
- let preguntas = [
- "capital de Australia",
- "qué tiempo hace hoy en Buenos Aires",
- ];
-
- let mut backends: Vec<(&str, asist_tools::SearchBackend)> = Vec::new();
- if let Ok(key) = std::env::var(&config.search.api_key_env) {
- if !key.trim().is_empty() {
- backends.push((
- "tavily",
- asist_tools::SearchBackend::Tavily { api_key: key },
- ));
- }
- }
- if let Some(program) = config.search.command.first() {
- if PathBuf::from(program).exists() {
- backends.push(("ddgs", asist_tools::SearchBackend::ddgs(program)));
- }
- }
- if backends.is_empty() {
- eprintln!("ningún buscador configurado; prueba omitida");
- return;
- }
-
- for (nombre, backend) in backends {
- let tool = asist_tools::WebSearch::new(backend, 3, Duration::from_secs(30));
- for pregunta in preguntas {
- let started = Instant::now();
- match tool.call(&serde_json::json!({ "consulta": pregunta })) {
- Ok(out) => {
- let primera = out.lines().next().unwrap_or("").to_string();
- eprintln!(
- "{nombre:7} {:>6.2}s {:>5} bytes «{pregunta}»\n {}",
- started.elapsed().as_secs_f32(),
- out.len(),
- &primera[..primera.len().min(150)]
- );
- assert!(!out.trim().is_empty());
- }
- Err(err) => panic!("{nombre} falló en «{pregunta}»: {err}"),
- }
- }
- }
-}