aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools/src/vision.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools/src/vision.rs')
-rw-r--r--crates/asist-tools/src/vision.rs206
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)
+ }
+}