aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools/src/search.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
committerelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
commit8518a63f55153e7f45fd49ad6caff5555f4e374f (patch)
tree636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-tools/src/search.rs
parent69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff)
downloadasist-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.rs122
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(),