//! 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 }, } impl SearchBackend { /// Backend sin clave por omisión: el guion que envuelve a ddgs. pub fn ddgs(script: impl Into) -> 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) -> Error { Error::Tool { tool: "buscar_en_internet".into(), message: message.into(), } } fn tavily(&self, key: &str, query: &str) -> Result { 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 { let rendered: Vec = 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 { 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 { 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) { 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 { 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::>().join(" "); if flat.chars().count() <= max { return flat; } flat.chars().take(max).collect::() + "…" } /// 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 = 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); } }