From 8518a63f55153e7f45fd49ad6caff5555f4e374f Mon Sep 17 00:00:00 2001 From: elvis Date: Sat, 26 Sep 2026 20:20:19 -0300 Subject: Translate code, comments, logs and terminal UI to English; add English README; rename scripts --- crates/asist-app/tests/integracion.rs | 557 ---------------------------------- 1 file changed, 557 deletions(-) delete mode 100644 crates/asist-app/tests/integracion.rs (limited to 'crates/asist-app/tests/integracion.rs') 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> = 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}"), - } - } - } -} -- cgit v1.2.3