diff options
Diffstat (limited to 'crates/asist-core')
| -rw-r--r-- | crates/asist-core/src/config.rs | 149 | ||||
| -rw-r--r-- | crates/asist-core/src/tools.rs | 12 |
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, |