diff options
| author | elvis <elvis@claros.ar> | 2026-09-06 22:05:14 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-06 22:05:14 -0300 |
| commit | 71764752f28d31028b9c2ca486c0fc1bcea1cf95 (patch) | |
| tree | ffd463969826157819f5a3319e1895c3f51deb22 /crates/asist-app/tests | |
| parent | f8f98e83481e7376a235fb305a095552433f79ab (diff) | |
| download | asist-p-71764752f28d31028b9c2ca486c0fc1bcea1cf95.tar.gz asist-p-71764752f28d31028b9c2ca486c0fc1bcea1cf95.zip | |
Web search and camera vision
Dos capacidades nuevas en un crate aparte, asist-tools, registradas desde
asist-app: son el ejemplo de que el punto de extensión documentado funciona
sin tocar el orquestador.
Buscar admite tres buscadores intercambiables. Tavily devuelve una respuesta ya
redactada, que es lo que se puede leer en voz alta sin gastar otra vuelta del
modelo en resumir (0,54 s para «capital de Australia»); ddgs no necesita clave
y devuelve fragmentos que el modelo sintetiza (2,24 s). El tercero es un
backend de comando genérico: ejecuta un programa y lee JSON de su salida, así
que añadir otro buscador —o un puente a un servidor MCP— es escribir un guion.
Sobre DuckDuckGo, comprobado y no supuesto: curl contra html.duckduckgo.com
devuelve HTTP 202 con una página anti-bot y ni un resultado, y la API sin clave
api.duckduckgo.com devuelve vacío para casi todo lo que no sea una entidad de
enciclopedia. La librería ddgs —la misma que hay bajo duckduckgo-mcp— sí
funciona porque rota buscadores y cabeceras, y es la que usa
scripts/buscar-ddgs.sh.
Mirar aprovecha que el servidor ya carga el proyector multimodal: el mismo
modelo que conversa describe lo que capta la cámara. La pregunta del usuario
viaja hasta ahí, porque «¿de qué color es mi camiseta?» y «¿cuánta gente hay?»
necesitan la misma imagen y descripciones distintas. La imagen no entra en el
historial —cientos de tokens por turno para algo ya descrito— ni toca el disco.
Tres cosas más que salieron de medir, en docs/RENDIMIENTO.md:
- La resolución de captura manda en la latencia: 7,8 s a 1280x720 frente a
2,9 s a 640x480, con la misma descripción útil. 640x480 pasa a ser el valor
por defecto.
- Una herramienta ejecutándose en silencio deja el turno seis segundos mudo y
parece un cuelgue. Tool::acknowledgement pronuncia una frase antes de
ejecutar y baja el primer audio de ~8,4 s a 4,1 s.
- Con la instrucción de voz a secas en la pasada de redacción, el modelo
anunciaba lo que acababa de hacer en vez de contar lo que averiguó, ignorando
el resultado que tenía delante. general.tool_result_prompt lo corrige; ahí sí
se puede añadir estilo sin riesgo, porque la llamada ya ocurrió.
Con tres herramientas declaradas el modelo elige bien entre ellas (15 de 16
medido), pero le cuesta abstenerse y busca cosas que ya sabe. Intentar
corregirlo con instrucciones empeora los aciertos sin reducir los falsos
positivos, coherente con la fragilidad ya documentada.
Corregido además un fallo que dejaba mudo al asistente tras la primera
respuesta: el anillo de reproducción no bajaba nunca su bandera de actividad,
así que el segmentador mantenía el micrófono cerrado creyendo que seguía
hablando. El segmentador se guía ahora por la cola, que no puede desfasarse, y
hay pruebas del anillo con un mando sin dispositivo detrás.
Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'crates/asist-app/tests')
| -rw-r--r-- | crates/asist-app/tests/integracion.rs | 206 |
1 files changed, 205 insertions, 1 deletions
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs index 91a074a..a7f7e15 100644 --- a/crates/asist-app/tests/integracion.rs +++ b/crates/asist-app/tests/integracion.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use asist_core::config::Config; use asist_core::http::Cancel; -use asist_core::tools::ToolRegistry; +use asist_core::tools::{Tool, ToolRegistry}; use asist_llm::chat::Conversation; use asist_llm::{Delta, LlmClient, Message}; use asist_tts::TtsClient; @@ -264,3 +264,207 @@ fn el_asr_transcribe_lo_que_sintetiza_el_tts() { "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 + ); +} + +#[test] +fn la_camara_captura_un_jpeg() { + let config = config(); + let capture = asist_tools::camera::CaptureConfig { + 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, + }; + if !config.camera.enabled || !capture.device.exists() { + eprintln!("sin cámara; prueba omitida"); + return; + } + + let started = Instant::now(); + let frame = asist_tools::camera::capture(&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] +#[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 capture = asist_tools::camera::CaptureConfig { + 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, + }; + if !config.camera.enabled || !capture.device.exists() { + eprintln!("sin cámara; prueba omitida"); + return; + } + + let tool = asist_tools::Camera::new(capture, 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 = "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}"), + } + } + } +} |