aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core
diff options
context:
space:
mode:
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,