diff options
| author | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
| commit | 8518a63f55153e7f45fd49ad6caff5555f4e374f (patch) | |
| tree | 636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-tools/src/search.rs | |
| parent | 69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff) | |
| download | asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.tar.gz asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.zip | |
Translate code, comments, logs and terminal UI to English; add English README; rename scriptsHEADmain
Diffstat (limited to 'crates/asist-tools/src/search.rs')
| -rw-r--r-- | crates/asist-tools/src/search.rs | 122 |
1 files changed, 61 insertions, 61 deletions
diff --git a/crates/asist-tools/src/search.rs b/crates/asist-tools/src/search.rs index 2d26020..c5b7e83 100644 --- a/crates/asist-tools/src/search.rs +++ b/crates/asist-tools/src/search.rs @@ -1,9 +1,9 @@ -//! Búsqueda en internet. +//! Web search. //! -//! 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. +//! The result will be read aloud, so what matters is not a list of links but +//! an answer. That is why a search engine that summarizes is preferred +//! (Tavily returns an already written paragraph), and the headlines only come +//! along as a fallback when there is no summary. use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -13,33 +13,32 @@ use asist_core::proc; use asist_core::tools::Tool; use serde_json::{json, Value}; -/// De dónde salen los resultados. +/// Where the results come from. #[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. It returns an already written answer besides the + /// results, which is exactly what is needed to speak it. 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 instance, your own or a trusted one. No key, but it only + /// returns results: the model has to do the summary. SearxNG { base_url: String }, - /// Un programa externo que imprime los resultados en JSON. + /// An external program that prints the results as 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. + /// It is the keyless path: `scripts/search-ddgs.sh` queries DuckDuckGo and + /// friends through the `ddgs` library. It works for anything else that writes + /// JSON to standard output (a bridge to an MCP server, an internal search + /// engine), so it is also the extension point of the search feature. /// - /// En `args`, `{consulta}` y `{max}` se sustituyen antes de ejecutar. + /// In `args`, `{query}` and `{max}` are substituted before running. Command { program: PathBuf, args: Vec<String> }, } impl SearchBackend { - /// Backend sin clave por omisión: el guion que envuelve a ddgs. + /// Default keyless backend: the script that wraps ddgs. pub fn ddgs(script: impl Into<PathBuf>) -> Self { SearchBackend::Command { program: script.into(), - args: vec!["{consulta}".into(), "{max}".into()], + args: vec!["{query}".into(), "{max}".into()], } } } @@ -49,7 +48,7 @@ impl SearchBackend { match self { SearchBackend::Tavily { .. } => "Tavily", SearchBackend::SearxNG { .. } => "SearXNG", - SearchBackend::Command { .. } => "comando", + SearchBackend::Command { .. } => "command", } } } @@ -92,8 +91,8 @@ impl WebSearch { "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. + // The written answer is the reason to use this engine: without it + // another model round would be spent summarizing. "include_answer": true, }); let mut response = self @@ -112,18 +111,18 @@ impl WebSearch { compose(&answer, &results) } - /// Ejecuta el programa configurado y traduce lo que imprima. + /// Runs the configured program and translates whatever it prints. 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) + arg.replace("{query}", 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 + // The arguments go to `execve` as they are: the query comes from what + // the microphone heard, and it must never end up interpreted by a // shell. let output = proc::run(program, &rendered, self.timeout, None) .map_err(|e| Self::fail(e.to_string()))?; @@ -205,22 +204,23 @@ impl Tool for WebSearch { SearchBackend::Command { program, args } => self.command(program, args, query), }; tracing::info!( - target: "herramientas", - buscador = self.backend.label(), - consulta = query, + target: "tools", + backend = self.backend.label(), + query = query, ms = started.elapsed().as_millis(), ok = result.is_ok(), - "búsqueda" + "search" ); result } } -/// Saca respuesta y resultados de un JSON sin exigir una forma concreta. +/// Pulls the answer and results out of a JSON without requiring a specific +/// shape. /// -/// 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. +/// Each engine names the fields its own way (`href` or `url`, `body` or +/// `content`, a bare list or inside `results`), and what matters here is that +/// a new script works without touching Rust. fn extract(parsed: &Value, max: usize) -> (String, Vec<String>) { let answer = parsed .get("answer") @@ -264,11 +264,11 @@ fn extract(parsed: &Value, max: usize) -> (String, Vec<String>) { (answer, results) } -/// Junta la respuesta sintetizada con los titulares. +/// Joins the summarized answer with the headlines. /// -/// 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. +/// The summary goes first because it is what will probably be spoken; the +/// headlines stay behind so the model has something to draw on if the +/// question asked for a detail the summary does not cover. fn compose(answer: &str, results: &[String]) -> Result<String> { if answer.is_empty() && results.is_empty() { return Err(Error::Tool { @@ -299,9 +299,9 @@ fn clip(text: &str, max: usize) -> String { 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. +/// A ureq error carries the whole chain with the URL inside, and for Tavily +/// that URL does not hold the key (it goes in a header), but better not to get +/// used to it: the error is summarized instead of dumped whole. fn describe_ureq(err: &ureq::Error) -> String { match err { ureq::Error::StatusCode(code) => match code { @@ -333,46 +333,46 @@ mod tests { use super::*; #[test] - fn la_respuesta_sintetizada_va_delante_de_las_fuentes() { + fn synthesized_answer_goes_before_the_sources() { 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() { + fn without_an_answer_the_headlines_are_used() { 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ó. + fn search_without_results_is_an_error_not_an_empty_string() { + // If it returned "" the model would make the answer up believing the + // tool had worked. assert!(compose("", &[]).is_err()); } #[test] - fn los_fragmentos_largos_se_recortan() { - let largo = "palabra ".repeat(80); - assert!(clip(&largo, 100).chars().count() <= 101); + fn long_snippets_are_truncated() { + let long_text = "palabra ".repeat(80); + assert!(clip(&long_text, 100).chars().count() <= 101); assert_eq!(clip("corto", 100), "corto"); } #[test] - fn los_saltos_de_linea_de_los_fragmentos_se_aplanan() { + fn snippet_newlines_are_flattened() { assert_eq!(clip("uno\n\n dos", 100), "uno dos"); } #[test] - fn la_consulta_se_codifica_para_la_url() { + fn query_is_url_encoded() { 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() { + fn empty_query_is_rejected_without_network() { let tool = WebSearch::new( SearchBackend::Tavily { api_key: "x".into(), @@ -385,7 +385,7 @@ mod tests { } #[test] - fn se_entiende_la_lista_suelta_que_devuelve_ddgs() { + fn bare_ddgs_list_is_understood() { let raw = serde_json::json!([ { "title": "Canberra", "href": "https://x", "body": "es la capital" }, { "title": "Sídney", "href": "https://y", "body": "no lo es" } @@ -397,7 +397,7 @@ mod tests { } #[test] - fn se_entiende_tambien_la_forma_con_results_y_answer() { + fn results_and_answer_shape_is_understood_too() { let raw = serde_json::json!({ "answer": "Canberra.", "results": [{ "title": "T", "url": "https://x", "content": "C" }] @@ -408,7 +408,7 @@ mod tests { } #[test] - fn se_respeta_el_maximo_de_resultados() { + fn maximum_results_is_honoured() { let raw = serde_json::json!([ { "title": "1", "body": "a" }, { "title": "2", "body": "b" }, { "title": "3", "body": "c" } @@ -417,26 +417,26 @@ mod tests { } #[test] - fn una_fila_sin_titulo_ni_texto_se_ignora() { + fn row_without_title_or_text_is_ignored() { 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() { + fn command_placeholders_are_substituted() { 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"); + panic!("expected a command backend"); }; let rendered: Vec<String> = args .iter() - .map(|a| a.replace("{consulta}", "hola").replace("{max}", "4")) + .map(|a| a.replace("{query}", "hola").replace("{max}", "4")) .collect(); assert_eq!(rendered, vec!["hola", "4"]); } #[test] - fn un_comando_que_no_existe_da_un_error_util() { + fn missing_command_gives_a_useful_error() { let tool = WebSearch::new( SearchBackend::ddgs("/no/existe/buscador.sh"), 3, @@ -450,7 +450,7 @@ mod tests { } #[test] - fn el_numero_de_resultados_se_mantiene_en_un_rango_sensato() { + fn result_count_stays_in_a_sensible_range() { let tool = WebSearch::new( SearchBackend::Tavily { api_key: "x".into(), |