diff options
Diffstat (limited to 'crates/asist-tools/src')
| -rw-r--r-- | crates/asist-tools/src/camera.rs | 290 | ||||
| -rw-r--r-- | crates/asist-tools/src/lib.rs | 14 | ||||
| -rw-r--r-- | crates/asist-tools/src/search.rs | 489 |
3 files changed, 793 insertions, 0 deletions
diff --git a/crates/asist-tools/src/camera.rs b/crates/asist-tools/src/camera.rs new file mode 100644 index 0000000..fe41319 --- /dev/null +++ b/crates/asist-tools/src/camera.rs @@ -0,0 +1,290 @@ +//! Mirar por la cámara. +//! +//! El servidor ya tiene cargado el proyector multimodal, así que el mismo +//! modelo que conversa puede describir una imagen. La herramienta hace tres +//! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la +//! respuesta como texto. +//! +//! Que la pregunta viaje hasta la cámara importa: «¿de qué color es mi +//! camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen pero +//! descripciones muy distintas, y pedir una descripción genérica para luego +//! interrogarla pierde justo el detalle que se buscaba. +//! +//! La captura no se guarda en disco salvo que se pida expresamente: un +//! asistente que deja fotogramas por ahí es un problema de privacidad, no una +//! comodidad de depuración. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use asist_core::error::{Error, Result}; +use asist_core::http::Cancel; +use asist_core::tools::Tool; +use asist_llm::LlmClient; +use serde_json::{json, Value}; + +/// Cómo se captura el fotograma. +#[derive(Debug, Clone)] +pub struct CaptureConfig { + /// Dispositivo V4L2. + pub device: PathBuf, + pub width: u32, + pub height: u32, + /// Fotogramas que se descartan antes de quedarse con uno. + /// + /// La cámara arranca con la exposición automática sin asentar y el primer + /// fotograma suele salir quemado. Descartar unos pocos es prácticamente + /// gratis —medido, 0,45 s frente a 0,53 s— y en penumbra se nota. + pub warmup_frames: u32, + /// Plazo máximo de la captura. + pub timeout: Duration, + /// Carpeta donde dejar los fotogramas. Vacío = no se guarda ninguno. + pub save_dir: Option<PathBuf>, +} + +impl Default for CaptureConfig { + fn default() -> Self { + Self { + device: PathBuf::from("/dev/video0"), + // 640x480 es el punto de equilibrio medido: el modelo tarda 2,9 s + // y sigue describiendo bien. A 1280x720 tarda 7,8 s, y a 320x240 + // baja a 1,3 s pero deja de distinguir detalles. + width: 640, + height: 480, + warmup_frames: 5, + timeout: Duration::from_secs(15), + save_dir: None, + } + } +} + +/// Captura un fotograma en JPEG. +/// +/// Se apoya en ffmpeg en vez de hablar con V4L2 directamente: una cámara USB +/// entrega MJPEG, YUYV o lo que le parezca, y reimplementar esa negociación +/// para ahorrarse un proceso no sale a cuenta. +pub fn capture(config: &CaptureConfig) -> Result<Vec<u8>> { + let fail = |message: String| Error::Tool { + tool: "mirar_por_la_camara".into(), + message, + }; + + if !config.device.exists() { + return Err(fail(format!( + "no existe el dispositivo {}. Comprueba con «v4l2-ctl --list-devices»", + config.device.display() + ))); + } + + let started = Instant::now(); + let mut command = Command::new("ffmpeg"); + command + .args(["-hide_banner", "-loglevel", "error", "-nostdin"]) + .args(["-f", "v4l2"]) + .args([ + "-video_size", + &format!("{}x{}", config.width, config.height), + ]) + .arg("-i") + .arg(&config.device); + + if config.warmup_frames > 0 { + // Se leen N fotogramas y se conserva el último: es la forma de dejar + // que la exposición se asiente sin abrir el dispositivo dos veces. + command.args(["-vf", &format!("select=eq(n\\,{})", config.warmup_frames)]); + } + command + .args(["-frames:v", "1"]) + .args(["-f", "image2", "-c:v", "mjpeg"]) + .arg("-") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|e| { + fail(format!( + "no se pudo ejecutar ffmpeg ({e}); hace falta para leer la cámara" + )) + })?; + + let deadline = Instant::now() + config.timeout; + loop { + match child.try_wait().map_err(|e| fail(e.to_string()))? { + Some(_) => break, + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(fail(format!( + "la cámara no respondió en {} s", + config.timeout.as_secs() + ))); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } + + let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; + if !output.status.success() || output.stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + let hint = if stderr.contains("Permission denied") { + ". Tu usuario necesita estar en el grupo «video»" + } else if stderr.contains("Device or resource busy") { + ". Otra aplicación está usando la cámara" + } else { + "" + }; + return Err(fail(format!( + "ffmpeg no capturó nada{hint}: {}", + stderr.trim().lines().next_back().unwrap_or("sin detalles") + ))); + } + + if let Some(dir) = &config.save_dir { + // Sólo si se ha pedido: por defecto la imagen no toca el disco. + if let Err(err) = std::fs::create_dir_all(dir).and_then(|()| { + let name = format!( + "frame-{}.jpg", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + std::fs::write(dir.join(name), &output.stdout) + }) { + tracing::warn!(target: "camara", %err, "no se pudo guardar el fotograma"); + } + } + + tracing::info!( + target: "camara", + dispositivo = %config.device.display(), + resolucion = format!("{}x{}", config.width, config.height), + kb = output.stdout.len() / 1024, + ms = started.elapsed().as_millis(), + "fotograma capturado" + ); + Ok(output.stdout) +} + +pub struct Camera { + config: CaptureConfig, + llm: Arc<LlmClient>, +} + +impl Camera { + pub fn new(config: CaptureConfig, llm: Arc<LlmClient>) -> Self { + Self { config, llm } + } + + /// `true` si el dispositivo está presente. Sin esto no tiene sentido + /// declarar la herramienta: el modelo la llamaría y fallaría siempre. + pub fn available(config: &CaptureConfig) -> bool { + config.device.exists() + } +} + +impl Tool for Camera { + fn name(&self) -> &str { + "mirar_por_la_camara" + } + + fn description(&self) -> &str { + "Toma una foto con la cámara del equipo y responde a una pregunta sobre \ + lo que se ve. Úsala cuando te pregunten qué ves, qué hay delante, de qué \ + color es algo o cuántas cosas hay." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pregunta": { + "type": "string", + "description": "La pregunta del usuario tal cual, sin concretarla más de lo que él dijo. Si sólo quiere saber qué hay delante, pon: ¿Qué se ve?" + } + }, + "required": ["pregunta"] + }) + } + + /// Enciende la cámara, así que se anuncia como tal. + fn is_side_effecting(&self) -> bool { + true + } + + fn acknowledgement(&self) -> Option<&str> { + Some("Voy a mirar.") + } + + fn call(&self, args: &Value) -> Result<String> { + let question = args + .get("pregunta") + .and_then(Value::as_str) + .map(str::trim) + .filter(|q| !q.is_empty()) + .unwrap_or("¿Qué se ve en esta imagen?"); + + let frame = capture(&self.config)?; + + // Se le pide al modelo el estilo hablado aquí y no en la conversación: + // esta petición va fuera del historial, así que la instrucción de voz + // del asistente no le llega. + let prompt = format!( + "{question}\n\nResponde en una o dos frases cortas en español, en texto \ + plano, describiendo sólo lo que se ve de verdad en la imagen. Si no se \ + distingue, dilo." + ); + let started = Instant::now(); + let answer = self.llm.look(&frame, &prompt, &Cancel::new())?; + tracing::info!( + target: "camara", + ms = started.elapsed().as_millis(), + "el modelo describió el fotograma" + ); + Ok(answer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() { + let config = CaptureConfig { + device: PathBuf::from("/dev/video-que-no-existe"), + ..Default::default() + }; + assert!(!Camera::available(&config)); + } + + #[test] + fn capturar_de_un_dispositivo_inexistente_da_un_error_util() { + let config = CaptureConfig { + device: PathBuf::from("/dev/video-que-no-existe"), + ..Default::default() + }; + let err = capture(&config).unwrap_err().to_string(); + assert!(err.contains("no existe el dispositivo"), "{err}"); + assert!( + err.contains("v4l2-ctl"), + "el error debe decir cómo comprobarlo: {err}" + ); + } + + #[test] + fn por_defecto_no_se_guarda_ningun_fotograma() { + assert!( + CaptureConfig::default().save_dir.is_none(), + "guardar imágenes por defecto sería una fuga de privacidad" + ); + } + + #[test] + fn la_resolucion_por_defecto_es_la_medida_como_equilibrada() { + let config = CaptureConfig::default(); + assert_eq!((config.width, config.height), (640, 480)); + } +} diff --git a/crates/asist-tools/src/lib.rs b/crates/asist-tools/src/lib.rs new file mode 100644 index 0000000..796b3d8 --- /dev/null +++ b/crates/asist-tools/src/lib.rs @@ -0,0 +1,14 @@ +//! Herramientas que asoman el asistente al mundo: buscar en internet y mirar +//! por la cámara. +//! +//! Viven en un crate aparte de `asist-core` porque necesitan cosas que el +//! núcleo no debe arrastrar —un cliente con TLS, el modelo multimodal, el +//! dispositivo de vídeo—, y porque son el ejemplo de que el punto de extensión +//! funciona: se registran con `ToolRegistry::register` sin tocar el +//! orquestador. + +pub mod camera; +pub mod search; + +pub use camera::Camera; +pub use search::{SearchBackend, WebSearch}; diff --git a/crates/asist-tools/src/search.rs b/crates/asist-tools/src/search.rs new file mode 100644 index 0000000..b69c17d --- /dev/null +++ b/crates/asist-tools/src/search.rs @@ -0,0 +1,489 @@ +//! Búsqueda en internet. +//! +//! El resultado se va a leer en voz alta, así que lo que interesa no es una +//! lista de enlaces sino una respuesta. Por eso se prefiere un buscador que +//! sintetice —Tavily devuelve un párrafo ya redactado— y los titulares sólo +//! acompañan como respaldo cuando no hay síntesis. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use asist_core::error::{Error, Result}; +use asist_core::tools::Tool; +use serde_json::{json, Value}; + +/// De dónde salen los resultados. +#[derive(Debug, Clone)] +pub enum SearchBackend { + /// API de Tavily. Devuelve una respuesta ya redactada además de los + /// resultados, que es justo lo que hace falta para hablarla. + Tavily { api_key: String }, + /// Instancia de SearXNG, propia o de confianza. Sin clave, pero devuelve + /// sólo resultados: la síntesis la tiene que hacer el modelo. + SearxNG { base_url: String }, + /// Un programa externo que imprime los resultados en JSON. + /// + /// Es la vía sin clave: `scripts/buscar-ddgs.sh` consulta DuckDuckGo y + /// compañía a través de la librería `ddgs`. Vale para cualquier otra cosa + /// que escriba JSON por la salida estándar —un puente a un servidor MCP, + /// un buscador interno—, así que también es el punto de extensión del + /// apartado de búsqueda. + /// + /// En `args`, `{consulta}` y `{max}` se sustituyen antes de ejecutar. + Command { program: PathBuf, args: Vec<String> }, +} + +impl SearchBackend { + /// Backend sin clave por omisión: el guion que envuelve a ddgs. + pub fn ddgs(script: impl Into<PathBuf>) -> Self { + SearchBackend::Command { + program: script.into(), + args: vec!["{consulta}".into(), "{max}".into()], + } + } +} + +impl SearchBackend { + pub fn label(&self) -> &'static str { + match self { + SearchBackend::Tavily { .. } => "Tavily", + SearchBackend::SearxNG { .. } => "SearXNG", + SearchBackend::Command { .. } => "comando", + } + } +} + +pub struct WebSearch { + backend: SearchBackend, + max_results: usize, + timeout: Duration, +} + +impl WebSearch { + pub fn new(backend: SearchBackend, max_results: usize, timeout: Duration) -> Self { + Self { + backend, + max_results: max_results.clamp(1, 10), + timeout, + } + } + + pub fn backend(&self) -> &SearchBackend { + &self.backend + } + + fn agent(&self) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(self.timeout)) + .build() + .into() + } + + fn fail(message: impl Into<String>) -> Error { + Error::Tool { + tool: "buscar_en_internet".into(), + message: message.into(), + } + } + + fn tavily(&self, key: &str, query: &str) -> Result<String> { + let body = json!({ + "query": query, + "max_results": self.max_results, + "search_depth": "basic", + // La respuesta redactada es la razón de usar este buscador: sin + // ella habría que gastar otra vuelta del modelo en resumir. + "include_answer": true, + }); + let mut response = self + .agent() + .post("https://api.tavily.com/search") + .header("Authorization", &format!("Bearer {key}")) + .send_json(&body) + .map_err(|e| Self::fail(describe_ureq(&e)))?; + + let parsed: Value = response + .body_mut() + .read_json() + .map_err(|e| Self::fail(format!("respuesta ilegible: {e}")))?; + + let (answer, results) = extract(&parsed, self.max_results); + compose(&answer, &results) + } + + /// Ejecuta el programa configurado y traduce lo que imprima. + fn command(&self, program: &std::path::Path, args: &[String], query: &str) -> Result<String> { + let rendered: Vec<String> = args + .iter() + .map(|arg| { + arg.replace("{consulta}", query) + .replace("{max}", &self.max_results.to_string()) + }) + .collect(); + + // Los argumentos van al `execve` tal cual: la consulta sale de lo que + // se ha oído por el micrófono, y no puede acabar interpretada por una + // shell. + let mut child = Command::new(program) + .args(&rendered) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Self::fail(format!("no se pudo ejecutar {}: {e}", program.display())))?; + + let deadline = Instant::now() + self.timeout; + loop { + match child.try_wait().map_err(|e| Self::fail(e.to_string()))? { + Some(_) => break, + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(Self::fail(format!( + "el buscador tardó más de {} s", + self.timeout.as_secs() + ))); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } + + let output = child + .wait_with_output() + .map_err(|e| Self::fail(e.to_string()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Self::fail(format!( + "el buscador falló: {}", + stderr.trim().lines().next_back().unwrap_or("sin detalles") + ))); + } + + let parsed: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| Self::fail(format!("el buscador no devolvió JSON válido: {e}")))?; + let (answer, results) = extract(&parsed, self.max_results); + compose(&answer, &results) + } + + fn searxng(&self, base_url: &str, query: &str) -> Result<String> { + let url = format!( + "{}/search?q={}&format=json&language=es", + base_url.trim_end_matches('/'), + urlencode(query) + ); + let mut response = self + .agent() + .get(&url) + .call() + .map_err(|e| Self::fail(describe_ureq(&e)))?; + + let parsed: Value = response + .body_mut() + .read_json() + .map_err(|e| Self::fail(format!("respuesta ilegible: {e}")))?; + + let (answer, results) = extract(&parsed, self.max_results); + compose(&answer, &results) + } +} + +impl Tool for WebSearch { + fn name(&self) -> &str { + "buscar_en_internet" + } + + fn description(&self) -> &str { + "Busca información actual en internet y devuelve un resumen. Úsala para \ + noticias, precios, resultados, el tiempo o cualquier cosa posterior a tu \ + entrenamiento, en vez de responder de memoria." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "consulta": { + "type": "string", + "description": "Qué buscar, en lenguaje natural. Por ejemplo: tiempo en Buenos Aires mañana" + } + }, + "required": ["consulta"] + }) + } + + fn acknowledgement(&self) -> Option<&str> { + Some("Déjame que lo busque.") + } + + fn call(&self, args: &Value) -> Result<String> { + let query = args + .get("consulta") + .and_then(Value::as_str) + .map(str::trim) + .filter(|q| !q.is_empty()) + .ok_or_else(|| Self::fail("falta «consulta»"))?; + + let started = Instant::now(); + let result = match &self.backend { + SearchBackend::Tavily { api_key } => self.tavily(api_key, query), + SearchBackend::SearxNG { base_url } => self.searxng(base_url, query), + SearchBackend::Command { program, args } => self.command(program, args, query), + }; + tracing::info!( + target: "herramientas", + buscador = self.backend.label(), + consulta = query, + ms = started.elapsed().as_millis(), + ok = result.is_ok(), + "búsqueda" + ); + result + } +} + +/// Saca respuesta y resultados de un JSON sin exigir una forma concreta. +/// +/// Cada buscador nombra los campos a su manera —`href` o `url`, `body` o +/// `content`, la lista suelta o dentro de `results`— y aquí lo que interesa es +/// que un guion nuevo funcione sin tener que tocar Rust. +fn extract(parsed: &Value, max: usize) -> (String, Vec<String>) { + let answer = parsed + .get("answer") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + + let rows = parsed + .as_array() + .or_else(|| parsed.get("results").and_then(Value::as_array)); + + let results = rows + .map(|items| { + items + .iter() + .take(max) + .filter_map(|item| { + let pick = |keys: &[&str]| { + keys.iter() + .find_map(|k| item.get(*k).and_then(Value::as_str)) + .unwrap_or("") + .trim() + .to_string() + }; + let title = pick(&["title", "titulo", "name"]); + let snippet = pick(&["body", "content", "snippet", "description"]); + if title.is_empty() && snippet.is_empty() { + return None; + } + Some(if title.is_empty() { + clip(&snippet, 200) + } else { + format!("{title}: {}", clip(&snippet, 200)) + }) + }) + .collect() + }) + .unwrap_or_default(); + + (answer, results) +} + +/// Junta la respuesta sintetizada con los titulares. +/// +/// La síntesis va primero porque es lo que probablemente se pronuncie; los +/// titulares quedan detrás para que el modelo tenga de dónde tirar si la +/// pregunta pedía un detalle que el resumen no cubre. +fn compose(answer: &str, results: &[String]) -> Result<String> { + if answer.is_empty() && results.is_empty() { + return Err(Error::Tool { + tool: "buscar_en_internet".into(), + message: "la búsqueda no devolvió nada".into(), + }); + } + let mut out = String::new(); + if !answer.is_empty() { + out.push_str(answer); + } + if !results.is_empty() { + if !out.is_empty() { + out.push_str("\n\nFuentes:\n"); + } + for (i, result) in results.iter().enumerate() { + out.push_str(&format!("{}. {result}\n", i + 1)); + } + } + Ok(out.trim().to_string()) +} + +fn clip(text: &str, max: usize) -> String { + let flat = text.split_whitespace().collect::<Vec<_>>().join(" "); + if flat.chars().count() <= max { + return flat; + } + flat.chars().take(max).collect::<String>() + "…" +} + +/// Un error de ureq trae la cadena completa con la URL dentro, y en Tavily esa +/// URL no lleva la clave —va en la cabecera—, pero más vale no acostumbrarse: +/// se resume el error en vez de volcarlo entero. +fn describe_ureq(err: &ureq::Error) -> String { + match err { + ureq::Error::StatusCode(code) => match code { + 401 | 403 => format!("el buscador rechazó la clave (HTTP {code})"), + 429 => "se ha superado el límite de consultas del buscador".into(), + other => format!("el buscador respondió HTTP {other}"), + }, + ureq::Error::Timeout(_) => "el buscador tardó demasiado".into(), + other => format!("no se pudo consultar el buscador: {other}"), + } +} + +fn urlencode(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + b' ' => out.push('+'), + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn la_respuesta_sintetizada_va_delante_de_las_fuentes() { + let out = compose("Hace 17 grados.", &["Meteored: parcialmente nuboso".into()]).unwrap(); + assert!(out.starts_with("Hace 17 grados.")); + assert!(out.contains("Fuentes:")); + } + + #[test] + fn sin_sintesis_valen_los_titulares() { + let out = compose("", &["Uno: algo".into(), "Dos: otra cosa".into()]).unwrap(); + assert!(out.starts_with("1. Uno")); + assert!(out.contains("2. Dos")); + } + + #[test] + fn una_busqueda_sin_resultados_es_un_error_y_no_una_cadena_vacia() { + // Si devolviera "" el modelo se inventaría la respuesta creyendo que + // la herramienta funcionó. + assert!(compose("", &[]).is_err()); + } + + #[test] + fn los_fragmentos_largos_se_recortan() { + let largo = "palabra ".repeat(80); + assert!(clip(&largo, 100).chars().count() <= 101); + assert_eq!(clip("corto", 100), "corto"); + } + + #[test] + fn los_saltos_de_linea_de_los_fragmentos_se_aplanan() { + assert_eq!(clip("uno\n\n dos", 100), "uno dos"); + } + + #[test] + fn la_consulta_se_codifica_para_la_url() { + assert_eq!(urlencode("qué tiempo hace"), "qu%C3%A9+tiempo+hace"); + assert_eq!(urlencode("a&b=c"), "a%26b%3Dc"); + } + + #[test] + fn una_consulta_vacia_se_rechaza_sin_salir_a_la_red() { + let tool = WebSearch::new( + SearchBackend::Tavily { + api_key: "x".into(), + }, + 3, + Duration::from_secs(1), + ); + assert!(tool.call(&json!({ "consulta": " " })).is_err()); + assert!(tool.call(&json!({})).is_err()); + } + + #[test] + fn se_entiende_la_lista_suelta_que_devuelve_ddgs() { + let raw = serde_json::json!([ + { "title": "Canberra", "href": "https://x", "body": "es la capital" }, + { "title": "Sídney", "href": "https://y", "body": "no lo es" } + ]); + let (answer, results) = extract(&raw, 5); + assert!(answer.is_empty(), "ddgs no sintetiza"); + assert_eq!(results.len(), 2); + assert!(results[0].starts_with("Canberra: es la capital")); + } + + #[test] + fn se_entiende_tambien_la_forma_con_results_y_answer() { + let raw = serde_json::json!({ + "answer": "Canberra.", + "results": [{ "title": "T", "url": "https://x", "content": "C" }] + }); + let (answer, results) = extract(&raw, 5); + assert_eq!(answer, "Canberra."); + assert_eq!(results, vec!["T: C"]); + } + + #[test] + fn se_respeta_el_maximo_de_resultados() { + let raw = serde_json::json!([ + { "title": "1", "body": "a" }, { "title": "2", "body": "b" }, + { "title": "3", "body": "c" } + ]); + assert_eq!(extract(&raw, 2).1.len(), 2); + } + + #[test] + fn una_fila_sin_titulo_ni_texto_se_ignora() { + let raw = serde_json::json!([{ "href": "https://x" }, { "title": "T", "body": "C" }]); + assert_eq!(extract(&raw, 5).1, vec!["T: C"]); + } + + #[test] + fn los_marcadores_del_comando_se_sustituyen() { + let tool = WebSearch::new(SearchBackend::ddgs("/bin/echo"), 4, Duration::from_secs(5)); + let SearchBackend::Command { args, .. } = tool.backend() else { + panic!("esperaba un backend de comando"); + }; + let rendered: Vec<String> = args + .iter() + .map(|a| a.replace("{consulta}", "hola").replace("{max}", "4")) + .collect(); + assert_eq!(rendered, vec!["hola", "4"]); + } + + #[test] + fn un_comando_que_no_existe_da_un_error_util() { + let tool = WebSearch::new( + SearchBackend::ddgs("/no/existe/buscador.sh"), + 3, + Duration::from_secs(5), + ); + let err = tool + .call(&json!({ "consulta": "algo" })) + .unwrap_err() + .to_string(); + assert!(err.contains("no se pudo ejecutar"), "{err}"); + } + + #[test] + fn el_numero_de_resultados_se_mantiene_en_un_rango_sensato() { + let tool = WebSearch::new( + SearchBackend::Tavily { + api_key: "x".into(), + }, + 99, + Duration::from_secs(1), + ); + assert_eq!(tool.max_results, 10); + } +} |