aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools')
-rw-r--r--crates/asist-tools/Cargo.toml16
-rw-r--r--crates/asist-tools/src/camera.rs290
-rw-r--r--crates/asist-tools/src/lib.rs14
-rw-r--r--crates/asist-tools/src/search.rs489
4 files changed, 809 insertions, 0 deletions
diff --git a/crates/asist-tools/Cargo.toml b/crates/asist-tools/Cargo.toml
new file mode 100644
index 0000000..b0cc51a
--- /dev/null
+++ b/crates/asist-tools/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "asist-tools"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+
+[dependencies]
+asist-core.workspace = true
+asist-llm.workspace = true
+serde_json.workspace = true
+tracing.workspace = true
+# Cliente con TLS sólo para las peticiones que salen a internet. El de
+# asist-core es HTTP plano a propósito —vale para localhost y permite cortar
+# una descarga a media frase—, pero un buscador está detrás de HTTPS.
+ureq = { version = "3", default-features = false, features = ["rustls", "json", "gzip"] }
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);
+ }
+}