//! 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::ToolRegistry; use asist_llm::chat::Conversation; use asist_llm::{Delta, LlmClient, Message}; 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}»" ); }