diff options
Diffstat (limited to 'crates/asist-tools/src/vision.rs')
| -rw-r--r-- | crates/asist-tools/src/vision.rs | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/crates/asist-tools/src/vision.rs b/crates/asist-tools/src/vision.rs new file mode 100644 index 0000000..2b91ed9 --- /dev/null +++ b/crates/asist-tools/src/vision.rs @@ -0,0 +1,206 @@ +//! Lo que comparten mirar por la cámara y mirar la pantalla. +//! +//! 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`. +//! +//! 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. + +use std::sync::Arc; +use std::time::Instant; + +use asist_core::error::{Error, Result}; +use asist_core::http::Cancel; +use asist_core::proc; +use asist_llm::LlmClient; + +/// De dónde salen los píxeles. +pub trait FrameSource: Send + Sync { + /// Para los mensajes de error y el registro: «cámara», «pantalla». + fn label(&self) -> &str; + + /// Captura un fotograma en JPEG. + fn capture(&self) -> Result<Vec<u8>>; + + /// `false` si falta el dispositivo o la herramienta de captura. + /// + /// 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. + fn available(&self) -> Result<()>; +} + +/// Ejecuta un programa que escribe un JPEG por la salida estándar. +/// +/// 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. +pub fn capture_jpeg( + tool: &str, + program: &std::path::Path, + args: &[String], + timeout: std::time::Duration, +) -> Result<Vec<u8>> { + let fail = |message: String| Error::Tool { + tool: tool.to_string(), + message, + }; + + let output = proc::run(program, args, timeout, None).map_err(|e| fail(e.to_string()))?; + + 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. + let hint = if stderr.contains("permission denied") { + ". Comprueba los permisos del dispositivo" + } else if stderr.contains("busy") { + ". Otra aplicación lo está usando" + } else { + "" + }; + return Err(fail(format!( + "no se capturó nada{hint}: {}", + output.last_error_line() + ))); + } + + // Cabecera JPEG. Sin esto, una captura corrupta llega hasta el modelo y + // vuelve como un error genérico que no dice dónde mirar. + if output.stdout.len() < 4 || output.stdout[..2] != [0xFF, 0xD8] { + return Err(fail(format!( + "{} no devolvió un JPEG ({} bytes)", + program.display(), + output.stdout.len() + ))); + } + + tracing::debug!( + target: "vision", + programa = %program.display(), + kb = output.stdout.len() / 1024, + ms = output.took.as_millis(), + "captura" + ); + Ok(output.stdout) +} + +/// La herramienta que ve: captura y le pregunta al modelo. +pub struct VisionTool { + source: Box<dyn FrameSource>, + llm: Arc<LlmClient>, + name: &'static str, + description: &'static str, + 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. + /// + /// Hace falta porque esta petición va fuera del historial y no le llega la + /// instrucción de voz del asistente. + style: &'static str, +} + +impl VisionTool { + pub fn camera(source: Box<dyn FrameSource>, llm: Arc<LlmClient>) -> Self { + Self { + source, + llm, + name: "mirar_por_la_camara", + description: "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.", + parameter_hint: "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?", + default_question: "¿Qué se ve en esta imagen?", + acknowledgement: "Voy a mirar.", + style: "Responde 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.", + } + } + + pub fn screen(source: Box<dyn FrameSource>, llm: Arc<LlmClient>) -> Self { + Self { + source, + llm, + name: "mirar_la_pantalla", + description: "Hace una captura de la pantalla del equipo y responde a una \ + pregunta sobre lo que hay en ella. Úsala cuando te pregunten qué \ + hay en pantalla, qué dice un error, qué pone en una ventana o qué \ + está abierto.", + parameter_hint: "La pregunta del usuario tal cual. Si sólo quiere saber qué hay \ + 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. + 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.", + } + } + + pub fn available(&self) -> Result<()> { + self.source.available() + } +} + +impl asist_core::tools::Tool for VisionTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + self.description + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "pregunta": { "type": "string", "description": self.parameter_hint } + }, + "required": ["pregunta"] + }) + } + + /// Enciende la cámara o fotografía lo que haya en pantalla; en ambos casos + /// conviene que se anuncie. + fn is_side_effecting(&self) -> bool { + true + } + + fn acknowledgement(&self) -> Option<&str> { + Some(self.acknowledgement) + } + + fn call(&self, args: &serde_json::Value) -> Result<String> { + let question = args + .get("pregunta") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|q| !q.is_empty()) + .unwrap_or(self.default_question); + + let frame = self.source.capture()?; + let started = Instant::now(); + let answer = self.llm.look( + &frame, + &format!("{question}\n\n{}", self.style), + &Cancel::new(), + )?; + + tracing::info!( + target: "vision", + fuente = self.source.label(), + kb = frame.len() / 1024, + ms = started.elapsed().as_millis(), + "descrito" + ); + Ok(answer) + } +} |