//! 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> = 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 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(""), "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 = 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 { 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 { 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 { 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}"), } } } }