aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app/tests/integracion.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-app/tests/integracion.rs')
-rw-r--r--crates/asist-app/tests/integracion.rs266
1 files changed, 266 insertions, 0 deletions
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs
new file mode 100644
index 0000000..91a074a
--- /dev/null
+++ b/crates/asist-app/tests/integracion.rs
@@ -0,0 +1,266 @@
+//! 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<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}»"
+ );
+}