aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-06 22:05:14 -0300
committerelvis <elvis@claros.ar>2026-09-06 22:05:14 -0300
commit71764752f28d31028b9c2ca486c0fc1bcea1cf95 (patch)
treeffd463969826157819f5a3319e1895c3f51deb22 /crates/asist-core
parentf8f98e83481e7376a235fb305a095552433f79ab (diff)
downloadasist-p-71764752f28d31028b9c2ca486c0fc1bcea1cf95.tar.gz
asist-p-71764752f28d31028b9c2ca486c0fc1bcea1cf95.zip
Web search and camera vision
Dos capacidades nuevas en un crate aparte, asist-tools, registradas desde asist-app: son el ejemplo de que el punto de extensión documentado funciona sin tocar el orquestador. Buscar admite tres buscadores intercambiables. Tavily devuelve una respuesta ya redactada, que es lo que se puede leer en voz alta sin gastar otra vuelta del modelo en resumir (0,54 s para «capital de Australia»); ddgs no necesita clave y devuelve fragmentos que el modelo sintetiza (2,24 s). El tercero es un backend de comando genérico: ejecuta un programa y lee JSON de su salida, así que añadir otro buscador —o un puente a un servidor MCP— es escribir un guion. Sobre DuckDuckGo, comprobado y no supuesto: curl contra html.duckduckgo.com devuelve HTTP 202 con una página anti-bot y ni un resultado, y la API sin clave api.duckduckgo.com devuelve vacío para casi todo lo que no sea una entidad de enciclopedia. La librería ddgs —la misma que hay bajo duckduckgo-mcp— sí funciona porque rota buscadores y cabeceras, y es la que usa scripts/buscar-ddgs.sh. Mirar aprovecha que el servidor ya carga el proyector multimodal: el mismo modelo que conversa describe lo que capta la cámara. La pregunta del usuario viaja hasta ahí, porque «¿de qué color es mi camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen y descripciones distintas. La imagen no entra en el historial —cientos de tokens por turno para algo ya descrito— ni toca el disco. Tres cosas más que salieron de medir, en docs/RENDIMIENTO.md: - La resolución de captura manda en la latencia: 7,8 s a 1280x720 frente a 2,9 s a 640x480, con la misma descripción útil. 640x480 pasa a ser el valor por defecto. - Una herramienta ejecutándose en silencio deja el turno seis segundos mudo y parece un cuelgue. Tool::acknowledgement pronuncia una frase antes de ejecutar y baja el primer audio de ~8,4 s a 4,1 s. - Con la instrucción de voz a secas en la pasada de redacción, el modelo anunciaba lo que acababa de hacer en vez de contar lo que averiguó, ignorando el resultado que tenía delante. general.tool_result_prompt lo corrige; ahí sí se puede añadir estilo sin riesgo, porque la llamada ya ocurrió. Con tres herramientas declaradas el modelo elige bien entre ellas (15 de 16 medido), pero le cuesta abstenerse y busca cosas que ya sabe. Intentar corregirlo con instrucciones empeora los aciertos sin reducir los falsos positivos, coherente con la fragilidad ya documentada. Corregido además un fallo que dejaba mudo al asistente tras la primera respuesta: el anillo de reproducción no bajaba nunca su bandera de actividad, así que el segmentador mantenía el micrófono cerrado creyendo que seguía hablando. El segmentador se guía ahora por la cola, que no puede desfasarse, y hay pruebas del anillo con un mando sin dispositivo detrás. Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'crates/asist-core')
-rw-r--r--crates/asist-core/src/config.rs149
-rw-r--r--crates/asist-core/src/tools.rs12
2 files changed, 161 insertions, 0 deletions
diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs
index 4ca8d8c..792ad3f 100644
--- a/crates/asist-core/src/config.rs
+++ b/crates/asist-core/src/config.rs
@@ -17,6 +17,8 @@ pub struct Config {
pub llm: LlmConfig,
pub tts: TtsConfig,
pub tools: ToolsConfig,
+ pub search: SearchConfig,
+ pub camera: CameraConfig,
pub supervisor: SupervisorConfig,
}
@@ -39,6 +41,13 @@ pub struct General {
/// con «Responde breve.» detrás, 1/8; con la persona de asistente de voz,
/// 0/8. Ver docs/RENDIMIENTO.md.
pub tools_prompt: String,
+ /// Se añade a `system_prompt` para redactar la respuesta cuando una
+ /// herramienta ya ha devuelto su resultado.
+ ///
+ /// Aquí sí se puede añadir estilo sin miedo —la llamada ya ocurrió—, y
+ /// hace falta: sin esta orden el modelo anuncia lo que acaba de hacer en
+ /// vez de contar lo que averiguó.
+ pub tool_result_prompt: String,
/// Turnos de historial que se envían al modelo (0 = sin memoria).
pub history_turns: usize,
/// Imprime un resumen de latencias por turno al terminar cada respuesta.
@@ -63,6 +72,13 @@ impl Default for General {
"Sólo cuando tengas el resultado, resúmelo en una frase."
)
.into(),
+ tool_result_prompt: concat!(
+ "Acabas de recibir el resultado de una herramienta. Contesta a la pregunta ",
+ "usando ese resultado y nada más. No anuncies lo que has hecho ni lo que ",
+ "podrías hacer: di directamente lo que has averiguado. Si el resultado ",
+ "viene en otro idioma, tradúcelo al español."
+ )
+ .into(),
history_turns: 8,
report_latency: true,
}
@@ -291,6 +307,9 @@ pub struct ToolsConfig {
/// porque el limpiador de texto quita el markdown antes de hablar.
/// Ponlo a `false` para priorizar el estilo sobre las herramientas.
pub dedicated_prompt: bool,
+ /// Pronuncia una frase corta al empezar una herramienta lenta, para que la
+ /// espera no se confunda con un cuelgue.
+ pub spoken_ack: bool,
/// Habilita la herramienta de ejecución de órdenes del sistema.
///
/// Apagada por defecto a conciencia: darle una shell a un modelo que
@@ -313,6 +332,7 @@ impl Default for ToolsConfig {
Self {
enabled: true,
dedicated_prompt: true,
+ spoken_ack: true,
shell: false,
shell_allowlist: vec![
"date".into(),
@@ -328,6 +348,88 @@ impl Default for ToolsConfig {
}
}
+/// Búsqueda en internet.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct SearchConfig {
+ pub enabled: bool,
+ /// `tavily` (necesita clave), `ddgs` (sin clave) o `searxng` (necesita
+ /// una instancia propia).
+ pub backend: String,
+ /// Variable de entorno de la que se lee la clave.
+ ///
+ /// La clave se lee del entorno y no del fichero a propósito: la
+ /// configuración se versiona y se comparte, y un secreto ahí dentro acaba
+ /// en el historial de git.
+ pub api_key_env: String,
+ /// URL base de la instancia de SearXNG.
+ pub base_url: String,
+ /// Programa que ejecuta el backend `ddgs`, con sus argumentos.
+ ///
+ /// `{consulta}` y `{max}` se sustituyen antes de ejecutar. Tiene que
+ /// escribir JSON por la salida estándar; se admiten tanto la lista suelta
+ /// que devuelve ddgs como la forma `{answer, results}`. Cualquier otro
+ /// programa que respete eso sirve igual, incluido un puente a un servidor
+ /// MCP.
+ pub command: Vec<String>,
+ pub max_results: usize,
+ pub timeout_secs: u64,
+}
+
+impl Default for SearchConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ backend: "tavily".into(),
+ api_key_env: "TAVILY_API_KEY".into(),
+ base_url: String::new(),
+ command: vec![
+ "scripts/buscar-ddgs.sh".into(),
+ "{consulta}".into(),
+ "{max}".into(),
+ ],
+ // Cinco resultados llenan bien el contexto sin ahogar a un modelo
+ // de 2B, que con más se pierde entre fuentes.
+ max_results: 5,
+ timeout_secs: 20,
+ }
+ }
+}
+
+/// Mirar por la cámara.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct CameraConfig {
+ /// Si se apaga, la herramienta no llega a registrarse y el modelo ni
+ /// sabe que existe. También se apaga sola si no hay dispositivo.
+ pub enabled: bool,
+ pub device: PathBuf,
+ /// Resolución de captura. Manda mucho en la latencia: medido, el modelo
+ /// tarda 1,3 s a 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720.
+ pub width: u32,
+ pub height: u32,
+ /// Fotogramas descartados para que la exposición automática se asiente.
+ pub warmup_frames: u32,
+ pub timeout_secs: u64,
+ /// Carpeta donde guardar cada fotograma capturado. Vacío = no se guarda
+ /// ninguno, que es lo que corresponde por defecto.
+ pub save_dir: String,
+}
+
+impl Default for CameraConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ device: PathBuf::from("/dev/video0"),
+ width: 640,
+ height: 480,
+ warmup_frames: 5,
+ timeout_secs: 15,
+ save_dir: String::new(),
+ }
+ }
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SupervisorConfig {
@@ -467,6 +569,18 @@ impl Config {
fix(&mut self.supervisor.tts.binary);
fix(&mut self.supervisor.tts.model);
fix(&mut self.supervisor.tts.codec);
+ if let Some(program) = self.search.command.first_mut() {
+ let path = PathBuf::from(&*program);
+ if path.is_relative() && program.contains('/') {
+ *program = base.join(path).to_string_lossy().into_owned();
+ }
+ }
+ if !self.camera.save_dir.is_empty() {
+ let dir = PathBuf::from(&self.camera.save_dir);
+ if dir.is_relative() {
+ self.camera.save_dir = base.join(dir).to_string_lossy().into_owned();
+ }
+ }
if let Some(reference) = self.tts.reference.as_mut() {
fix(&mut reference.speaker);
fix(&mut reference.codes);
@@ -488,6 +602,41 @@ impl Config {
"asr.window debe ser mayor que asr.step, y ambos > 0".into(),
));
}
+ if self.search.enabled {
+ match self.search.backend.trim().to_lowercase().as_str() {
+ "tavily" => {
+ if self.search.api_key_env.trim().is_empty() {
+ return Err(Error::Config(
+ "search.backend = «tavily» necesita search.api_key_env".into(),
+ ));
+ }
+ }
+ "searxng" => {
+ if self.search.base_url.trim().is_empty() {
+ return Err(Error::Config(
+ "search.backend = «searxng» necesita search.base_url".into(),
+ ));
+ }
+ }
+ "ddgs" | "comando" => {
+ if self.search.command.is_empty() {
+ return Err(Error::Config(
+ "search.backend = «ddgs» necesita search.command".into(),
+ ));
+ }
+ }
+ other => {
+ return Err(Error::Config(format!(
+ "search.backend desconocido: «{other}». Usa «tavily», «ddgs» o «searxng»"
+ )))
+ }
+ }
+ }
+ if self.camera.enabled && (self.camera.width == 0 || self.camera.height == 0) {
+ return Err(Error::Config(
+ "camera.width y camera.height deben ser > 0".into(),
+ ));
+ }
if self.tools.shell && self.tools.shell_allowlist.is_empty() {
return Err(Error::Config(
"tools.shell está activo pero tools.shell_allowlist está vacía: \
diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs
index 0b8a646..ac08141 100644
--- a/crates/asist-core/src/tools.rs
+++ b/crates/asist-core/src/tools.rs
@@ -35,6 +35,17 @@ pub trait Tool: Send + Sync {
fn is_side_effecting(&self) -> bool {
false
}
+
+ /// Frase que se pronuncia nada más empezar a ejecutarla.
+ ///
+ /// Existe por una razón de trato, no de adorno: una búsqueda son unos
+ /// 2,5 s, la cámara casi 3, y a eso hay que sumarle las dos pasadas del
+ /// modelo. Seis segundos de silencio absoluto se leen como que el
+ /// asistente se ha colgado. Devuelve `None` la herramienta que responda
+ /// al instante, donde el acuse molestaría más que ayudar.
+ fn acknowledgement(&self) -> Option<&str> {
+ None
+ }
}
/// Una llamada tal y como la pide el modelo.
@@ -453,6 +464,7 @@ mod tests {
ToolsConfig {
enabled: true,
dedicated_prompt: true,
+ spoken_ack: true,
shell: true,
shell_allowlist: vec!["echo".into(), "sleep".into()],
shell_timeout_secs: 1,