aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools')
-rw-r--r--crates/asist-tools/Cargo.toml6
-rw-r--r--crates/asist-tools/src/camera.rs56
-rw-r--r--crates/asist-tools/src/lib.rs14
-rw-r--r--crates/asist-tools/src/screen.rs88
-rw-r--r--crates/asist-tools/src/search.rs122
-rw-r--r--crates/asist-tools/src/vision.rs66
6 files changed, 175 insertions, 177 deletions
diff --git a/crates/asist-tools/Cargo.toml b/crates/asist-tools/Cargo.toml
index b0cc51a..803c537 100644
--- a/crates/asist-tools/Cargo.toml
+++ b/crates/asist-tools/Cargo.toml
@@ -10,7 +10,7 @@ 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.
+# TLS client only for requests that go out to the internet. The asist-core
+# one is plain HTTP on purpose (it is enough for localhost and allows cutting a
+# download mid-sentence), but a search engine lives behind 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
index bdc2aa0..9f17c88 100644
--- a/crates/asist-tools/src/camera.rs
+++ b/crates/asist-tools/src/camera.rs
@@ -1,4 +1,4 @@
-//! La cámara como fuente de imágenes.
+//! The camera as an image source.
use std::path::PathBuf;
use std::time::Duration;
@@ -13,19 +13,19 @@ const TOOL: &str = "mirar_por_la_camara";
pub struct CameraConfig {
/// Dispositivo V4L2.
pub device: PathBuf,
- /// Resolución de captura. Medido con este modelo: 1,3 s a 320x240, 2,9 s a
- /// 640x480 y 7,8 s a 1280x720, con la misma descripción útil a partir de
- /// 640. Para una escena basta; para leer texto no (eso es la pantalla).
+ /// Capture resolution. Measured with this model: 1.3 s at 320x240, 2.9 s
+ /// at 640x480 and 7.8 s at 1280x720, with the same useful description from
+ /// 640 up. Enough for a scene; not for reading text (that is the screen).
pub width: u32,
pub height: u32,
- /// Fotogramas que se descartan antes de quedarse con uno.
+ /// Frames discarded before keeping one.
///
- /// 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.
+ /// The camera starts with auto-exposure not settled yet and the first frame
+ /// is usually blown out. Discarding a few is practically free: measured,
+ /// 0.45 s versus 0.53 s.
pub warmup_frames: u32,
pub timeout: Duration,
- /// Carpeta donde dejar los fotogramas. `None` = no se guarda ninguno.
+ /// Directory to leave frames in. `None` = none is saved.
pub save_dir: Option<PathBuf>,
}
@@ -62,7 +62,7 @@ impl FrameSource for Camera {
return Err(Error::Tool {
tool: TOOL.into(),
message: format!(
- "no existe {}; comprueba con «v4l2-ctl --list-devices»",
+ "{} does not exist; check with «v4l2-ctl --list-devices»",
self.config.device.display()
),
});
@@ -73,9 +73,9 @@ impl FrameSource for Camera {
fn capture(&self) -> Result<Vec<u8>> {
self.available()?;
- // ffmpeg y no V4L2 a pelo: 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.
+ // ffmpeg rather than raw V4L2: a USB camera delivers MJPEG, YUYV or
+ // whatever it likes, and reimplementing that negotiation to save one
+ // process is not worth it.
let mut args = vec![
"-hide_banner".into(),
"-loglevel".into(),
@@ -89,8 +89,8 @@ impl FrameSource for Camera {
self.config.device.to_string_lossy().into_owned(),
];
if self.config.warmup_frames > 0 {
- // Se leen N fotogramas y se conserva el último: así la exposición
- // se asienta sin abrir el dispositivo dos veces.
+ // N frames are read and the last one is kept: that way exposure
+ // settles without opening the device twice.
args.push("-vf".into());
args.push(format!("select=eq(n\\,{})", self.config.warmup_frames));
}
@@ -108,9 +108,9 @@ impl FrameSource for Camera {
}
}
-/// Guarda una copia sólo si se ha pedido expresamente. Por defecto no se
-/// escribe nada: un asistente que deja fotogramas por ahí es un problema de
-/// privacidad, no una comodidad de depuración.
+/// Saves a copy only if explicitly requested. By default nothing is
+/// written: an assistant that leaves frames lying around is a privacy
+/// problem, not a debugging convenience.
pub(crate) fn save(dir: &std::path::Path, bytes: &[u8], prefix: &str) {
let name = format!(
"{prefix}-{}.jpg",
@@ -122,7 +122,7 @@ pub(crate) fn save(dir: &std::path::Path, bytes: &[u8], prefix: &str) {
if let Err(err) =
std::fs::create_dir_all(dir).and_then(|()| std::fs::write(dir.join(name), bytes))
{
- tracing::warn!(target: "vision", %err, "no se pudo guardar la captura");
+ tracing::warn!(target: "vision", %err, "could not save the capture");
}
}
@@ -131,38 +131,38 @@ mod tests {
use super::*;
#[test]
- fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() {
+ fn missing_device_is_detected_before_registering_the_tool() {
let camera = Camera::new(CameraConfig {
- device: PathBuf::from("/dev/video-que-no-existe"),
+ device: PathBuf::from("/dev/video-does-not-exist"),
..Default::default()
});
let err = camera.available().unwrap_err().to_string();
- assert!(err.contains("no existe"), "{err}");
+ assert!(err.contains("does not exist"), "{err}");
assert!(
err.contains("v4l2-ctl"),
- "el error debe decir cómo comprobarlo: {err}"
+ "the error must say how to check it: {err}"
);
}
#[test]
- fn capturar_sin_dispositivo_falla_sin_llegar_a_ffmpeg() {
+ fn capturing_without_a_device_fails_before_ffmpeg() {
let camera = Camera::new(CameraConfig {
- device: PathBuf::from("/dev/video-que-no-existe"),
+ device: PathBuf::from("/dev/video-does-not-exist"),
..Default::default()
});
assert!(camera.capture().is_err());
}
#[test]
- fn por_defecto_no_se_guarda_ningun_fotograma() {
+ fn no_frame_is_saved_by_default() {
assert!(
CameraConfig::default().save_dir.is_none(),
- "guardar imágenes por defecto sería una fuga de privacidad"
+ "saving images by default would be a privacy leak"
);
}
#[test]
- fn la_resolucion_por_defecto_es_la_medida_como_equilibrada() {
+ fn default_resolution_is_the_measured_balance() {
let config = CameraConfig::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
index a1c2466..3815373 100644
--- a/crates/asist-tools/src/lib.rs
+++ b/crates/asist-tools/src/lib.rs
@@ -1,11 +1,11 @@
-//! Herramientas que asoman el asistente al mundo: buscar en internet, mirar
-//! por la cámara y mirar la pantalla.
+//! Tools that open the assistant to the world: searching the web, looking
+//! through the camera and looking at the screen.
//!
-//! 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, el compositor—, y porque son el ejemplo de que el
-//! punto de extensión funciona: se registran con `ToolRegistry::register` sin
-//! tocar el orquestador.
+//! They live in a crate separate from `asist-core` because they need things
+//! the core must not pull in (a TLS client, the multimodal model, the video
+//! device, the compositor), and because they prove the extension point
+//! works: they are registered with `ToolRegistry::register` without touching
+//! the orchestrator.
pub mod camera;
pub mod screen;
diff --git a/crates/asist-tools/src/screen.rs b/crates/asist-tools/src/screen.rs
index dfed923..6fab402 100644
--- a/crates/asist-tools/src/screen.rs
+++ b/crates/asist-tools/src/screen.rs
@@ -1,21 +1,20 @@
-//! La pantalla como fuente de imágenes.
+//! The screen as an image source.
//!
-//! Se diferencia de la cámara en lo único que de verdad importa aquí: **una
-//! pantalla es texto**. Y con texto este modelo tiene un modo de fallo feo.
-//! Medido sobre una captura con tipografía de interfaz de 13 px, preguntando
-//! por datos concretos:
+//! It differs from the camera in the only thing that really matters here: **a
+//! screen is text**. And with text this model has an ugly failure mode.
+//! Measured on a capture with 13 px UI type, asking for specific details:
//!
-//! | Ancho | Tiempo | Aciertos | Qué pasa cuando falla |
-//! |------|--------|----------|-----------------------|
-//! | 640 | 2,4 s | 1 de 3 | se inventa el contenido |
-//! | 960 | 4,5 s | 2 de 3 | mezcla lo leído con lo supuesto |
-//! | 1280 | 7,6 s | 3 de 3 | — |
+//! | Width | Time | Hits | What happens when it fails |
+//! |-------|-------|--------|----------------------------|
+//! | 640 | 2.4 s | 1 of 3 | it makes the content up |
+//! | 960 | 4.5 s | 2 of 3 | it mixes what it read with what it assumed |
+//! | 1280 | 7.6 s | 3 of 3 | — |
//!
-//! A 640 px no dijo «no lo leo»: dijo que el error era «no se pudo abrir el
-//! archivo involution» y que la reunión era «a las 10:00». Ninguna de las dos
-//! cosas estaba en la imagen. De ahí que el valor por defecto sean 1280 px
-//! aunque cueste el triple que la cámara: para un asistente de voz, decir una
-//! hora equivocada con aplomo es peor que tardar cuatro segundos más.
+//! At 640 px it did not say «I cannot read it»: it said the error was «no se
+//! pudo abrir el archivo involution» and the meeting was «at 10:00». Neither
+//! was in the image. Hence the 1280 px default even though it costs three
+//! times the camera: for a voice assistant, confidently stating a wrong time
+//! is worse than taking four more seconds.
use std::path::PathBuf;
use std::time::Duration;
@@ -28,19 +27,18 @@ const TOOL: &str = "mirar_la_pantalla";
#[derive(Debug, Clone)]
pub struct ScreenConfig {
- /// Programa de captura y sus argumentos. `{ancho}` y `{salida}` se
- /// sustituyen antes de ejecutar. Tiene que escribir un JPEG por la salida
- /// estándar.
+ /// Capture program and its arguments. `{width}` and `{output}` are
+ /// substituted before running. It must write a JPEG to standard output.
pub command: Vec<String>,
- /// Ancho al que se reduce la captura antes de mandarla al modelo.
+ /// Width the capture is scaled down to before sending it to the model.
pub width: u32,
- /// Monitor concreto; vacío = todo lo que haya.
+ /// A specific monitor; empty = everything there is.
pub output: String,
pub timeout: Duration,
- /// Carpeta donde dejar las capturas. `None` = no se guarda ninguna.
+ /// Directory to leave captures in. `None` = none is saved.
///
- /// Aquí pesa más que en la cámara: en una captura de pantalla caben
- /// contraseñas, mensajes privados y correo abierto.
+ /// It weighs more here than for the camera: a screenshot can hold
+ /// passwords, private messages and open email.
pub save_dir: Option<PathBuf>,
}
@@ -48,9 +46,9 @@ impl Default for ScreenConfig {
fn default() -> Self {
Self {
command: vec![
- "scripts/capturar-pantalla.sh".into(),
- "{ancho}".into(),
- "{salida}".into(),
+ "scripts/capture-screen.sh".into(),
+ "{width}".into(),
+ "{output}".into(),
],
width: 1280,
output: String::new(),
@@ -72,7 +70,7 @@ impl Screen {
fn program(&self) -> Result<&String> {
self.config.command.first().ok_or_else(|| Error::Tool {
tool: TOOL.into(),
- message: "screen.command está vacío".into(),
+ message: "screen.command is empty".into(),
})
}
}
@@ -84,19 +82,19 @@ impl FrameSource for Screen {
fn available(&self) -> Result<()> {
let program = self.program()?;
- // Un nombre suelto se resuelve por el PATH; una ruta tiene que existir.
+ // A bare name is resolved through PATH; a path must exist.
if program.contains('/') && !std::path::Path::new(program).exists() {
return Err(Error::Tool {
tool: TOOL.into(),
message: format!("no existe {program}"),
});
}
- // Sin entorno gráfico no hay nada que capturar, y más vale decirlo al
- // arrancar que a mitad de una pregunta.
+ // Without a graphical environment there is nothing to capture, and
+ // better to say so at startup than in the middle of a question.
if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() {
return Err(Error::Tool {
tool: TOOL.into(),
- message: "no hay sesión gráfica (ni WAYLAND_DISPLAY ni DISPLAY)".into(),
+ message: "no graphical session (neither WAYLAND_DISPLAY nor DISPLAY)".into(),
});
}
Ok(())
@@ -108,8 +106,8 @@ impl FrameSource for Screen {
let args: Vec<String> = self.config.command[1..]
.iter()
.map(|arg| {
- arg.replace("{ancho}", &self.config.width.to_string())
- .replace("{salida}", &self.config.output)
+ arg.replace("{width}", &self.config.width.to_string())
+ .replace("{output}", &self.config.output)
})
.collect();
@@ -127,30 +125,30 @@ mod tests {
fn config() -> ScreenConfig {
ScreenConfig {
- command: vec!["/bin/echo".into(), "{ancho}".into(), "{salida}".into()],
+ command: vec!["/bin/echo".into(), "{width}".into(), "{output}".into()],
..Default::default()
}
}
#[test]
- fn el_ancho_por_defecto_es_el_minimo_medido_para_leer_texto() {
+ fn default_width_is_the_measured_minimum_to_read_text() {
assert_eq!(
ScreenConfig::default().width,
1280,
- "por debajo de 1280 el modelo se inventa lo que pone en pantalla"
+ "below 1280 the model makes up what the screen says"
);
}
#[test]
- fn por_defecto_no_se_guarda_ninguna_captura() {
+ fn no_capture_is_saved_by_default() {
assert!(
ScreenConfig::default().save_dir.is_none(),
- "en una captura de pantalla caben contraseñas y mensajes privados"
+ "a screenshot can hold passwords and private messages"
);
}
#[test]
- fn un_guion_inexistente_se_detecta_antes_de_registrar_la_herramienta() {
+ fn missing_script_is_detected_before_registering_the_tool() {
let screen = Screen::new(ScreenConfig {
command: vec!["/no/existe/captura.sh".into()],
..Default::default()
@@ -163,7 +161,7 @@ mod tests {
}
#[test]
- fn una_orden_vacia_se_rechaza() {
+ fn empty_command_is_rejected() {
let screen = Screen::new(ScreenConfig {
command: vec![],
..Default::default()
@@ -172,16 +170,16 @@ mod tests {
}
#[test]
- fn los_marcadores_se_sustituyen_antes_de_ejecutar() {
- // /bin/echo devuelve los argumentos, así que la captura falla por no
- // ser un JPEG; lo que se comprueba es que el mensaje trae el ancho ya
- // sustituido, no el marcador.
+ fn placeholders_are_substituted_before_running() {
+ // /bin/echo returns the arguments, so the capture fails for not being
+ // a JPEG; what is checked is that the message carries the width already
+ // substituted, not the placeholder.
let mut config = config();
config.width = 1280;
config.output = "eDP-1".into();
let screen = Screen::new(config);
if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() {
- return; // sin sesión gráfica no hay nada que probar aquí
+ return; // without a graphical session there is nothing to test here
}
let err = screen.capture().unwrap_err().to_string();
assert!(err.contains("no devolvió un JPEG"), "{err}");
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(),
diff --git a/crates/asist-tools/src/vision.rs b/crates/asist-tools/src/vision.rs
index 2b91ed9..5db4aee 100644
--- a/crates/asist-tools/src/vision.rs
+++ b/crates/asist-tools/src/vision.rs
@@ -1,13 +1,13 @@
-//! Lo que comparten mirar por la cámara y mirar la pantalla.
+//! What looking through the camera and looking at the screen have in common.
//!
-//! Las dos herramientas hacen lo mismo en tres pasos —conseguir un JPEG,
-//! preguntarle al modelo por él, devolver texto— y sólo se diferencian en de
-//! dónde salen los píxeles. Eso es lo que abstrae `FrameSource`.
+//! Both tools do the same three steps (get a JPEG, ask the model about it,
+//! return text) and only differ in where the pixels come from. That is what
+//! `FrameSource` abstracts.
//!
-//! Lo que **no** comparten es la resolución, y no por descuido: una escena de
-//! cámara se entiende a 640 px, pero el texto de una interfaz a esa escala el
-//! modelo no lo lee mal, se lo **inventa** (ver docs/RENDIMIENTO.md). Por eso
-//! cada fuente trae la suya.
+//! What they do **not** share is the resolution, and not by oversight: a
+//! camera scene is understood at 640 px, but at that scale the model does not
+//! misread UI text, it **makes it up** (see docs/RENDIMIENTO.md). That is why
+//! each source brings its own.
use std::sync::Arc;
use std::time::Instant;
@@ -17,27 +17,27 @@ use asist_core::http::Cancel;
use asist_core::proc;
use asist_llm::LlmClient;
-/// De dónde salen los píxeles.
+/// Where the pixels come from.
pub trait FrameSource: Send + Sync {
- /// Para los mensajes de error y el registro: «cámara», «pantalla».
+ /// For error messages and logs: «cámara», «pantalla».
fn label(&self) -> &str;
- /// Captura un fotograma en JPEG.
+ /// Captures one frame as JPEG.
fn capture(&self) -> Result<Vec<u8>>;
- /// `false` si falta el dispositivo o la herramienta de captura.
+ /// `false` if the device or the capture tool is missing.
///
- /// Se consulta antes de registrar: declarar una herramienta que va a
- /// fallar siempre es peor que no tenerla, porque el modelo la llama, se
- /// come el error y gasta el turno.
+ /// Checked before registering: declaring a tool that will always fail is
+ /// worse than not having it, because the model calls it, swallows the error
+ /// and wastes the turn.
fn available(&self) -> Result<()>;
}
-/// Ejecuta un programa que escribe un JPEG por la salida estándar.
+/// Runs a program that writes a JPEG to standard output.
///
-/// Es el mecanismo de captura de las dos fuentes: la cámara llama a ffmpeg y
-/// la pantalla a un guion que sabe de compositores. Tenerlo aquí evita que
-/// cada una repita el control del plazo y la comprobación de la cabecera.
+/// It is the capture mechanism of both sources: the camera calls ffmpeg and
+/// the screen a script that knows about compositors. Having it here keeps
+/// each one from repeating the deadline control and the header check.
pub fn capture_jpeg(
tool: &str,
program: &std::path::Path,
@@ -53,7 +53,7 @@ pub fn capture_jpeg(
if !output.success() || output.stdout.is_empty() {
let stderr = output.stderr.to_lowercase();
- // Los dos fallos que más se dan, traducidos a algo accionable.
+ // The two most common failures, translated into something actionable.
let hint = if stderr.contains("permission denied") {
". Comprueba los permisos del dispositivo"
} else if stderr.contains("busy") {
@@ -67,8 +67,8 @@ pub fn capture_jpeg(
)));
}
- // Cabecera JPEG. Sin esto, una captura corrupta llega hasta el modelo y
- // vuelve como un error genérico que no dice dónde mirar.
+ // JPEG header. Without this, a corrupt capture reaches the model and
+ // comes back as a generic error that does not say where to look.
if output.stdout.len() < 4 || output.stdout[..2] != [0xFF, 0xD8] {
return Err(fail(format!(
"{} no devolvió un JPEG ({} bytes)",
@@ -79,7 +79,7 @@ pub fn capture_jpeg(
tracing::debug!(
target: "vision",
- programa = %program.display(),
+ program = %program.display(),
kb = output.stdout.len() / 1024,
ms = output.took.as_millis(),
"captura"
@@ -87,7 +87,7 @@ pub fn capture_jpeg(
Ok(output.stdout)
}
-/// La herramienta que ve: captura y le pregunta al modelo.
+/// The tool that sees: it captures and asks the model.
pub struct VisionTool {
source: Box<dyn FrameSource>,
llm: Arc<LlmClient>,
@@ -96,10 +96,10 @@ pub struct VisionTool {
parameter_hint: &'static str,
default_question: &'static str,
acknowledgement: &'static str,
- /// Se añade a la pregunta del usuario antes de mandarla con la imagen.
+ /// Appended to the user's question before sending it with the image.
///
- /// Hace falta porque esta petición va fuera del historial y no le llega la
- /// instrucción de voz del asistente.
+ /// It is needed because this request goes outside the history and does not
+ /// get the assistant's voice prompt.
style: &'static str,
}
@@ -135,9 +135,9 @@ impl VisionTool {
en pantalla, pon: ¿Qué se ve en la pantalla?",
default_question: "¿Qué se ve en esta captura de pantalla?",
acknowledgement: "Miro la pantalla.",
- // El aviso de no inventar es lo más importante de toda la
- // instrucción: cuando el texto queda pequeño, este modelo no dice
- // que no lo lee, se saca un contenido plausible de la manga.
+ // The warning about not making things up is the most important part of
+ // the whole instruction: when the text is small, this model does not say
+ // it cannot read it, it pulls plausible content out of thin air.
style: "Responde en una o dos frases cortas en español, en texto plano. Lee sólo \
lo que de verdad pone en la imagen y no completes lo que no se distinga: \
si el texto está borroso o no se lee, dilo en vez de suponerlo.",
@@ -168,8 +168,8 @@ impl asist_core::tools::Tool for VisionTool {
})
}
- /// Enciende la cámara o fotografía lo que haya en pantalla; en ambos casos
- /// conviene que se anuncie.
+ /// It turns the camera on or photographs whatever is on screen; in both
+ /// cases it should be announced.
fn is_side_effecting(&self) -> bool {
true
}
@@ -196,7 +196,7 @@ impl asist_core::tools::Tool for VisionTool {
tracing::info!(
target: "vision",
- fuente = self.source.label(),
+ source = self.source.label(),
kb = frame.len() / 1024,
ms = started.elapsed().as_millis(),
"descrito"