aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/asist-app/Cargo.toml2
-rw-r--r--crates/asist-app/src/main.rs95
-rw-r--r--crates/asist-app/src/pipeline.rs85
-rw-r--r--crates/asist-app/src/registry.rs122
-rw-r--r--crates/asist-app/tests/integracion.rs206
-rw-r--r--crates/asist-audio/src/lib.rs2
-rw-r--r--crates/asist-audio/src/playback.rs81
-rw-r--r--crates/asist-core/src/config.rs149
-rw-r--r--crates/asist-core/src/tools.rs12
-rw-r--r--crates/asist-llm/Cargo.toml1
-rw-r--r--crates/asist-llm/src/client.rs88
-rw-r--r--crates/asist-tools/Cargo.toml16
-rw-r--r--crates/asist-tools/src/camera.rs290
-rw-r--r--crates/asist-tools/src/lib.rs14
-rw-r--r--crates/asist-tools/src/search.rs489
15 files changed, 1614 insertions, 38 deletions
diff --git a/crates/asist-app/Cargo.toml b/crates/asist-app/Cargo.toml
index 6a5daee..205277f 100644
--- a/crates/asist-app/Cargo.toml
+++ b/crates/asist-app/Cargo.toml
@@ -14,6 +14,7 @@ asist-core.workspace = true
asist-asr.workspace = true
asist-llm.workspace = true
asist-tts.workspace = true
+asist-tools.workspace = true
[dependencies]
asist-core.workspace = true
@@ -21,6 +22,7 @@ asist-audio.workspace = true
asist-asr.workspace = true
asist-llm.workspace = true
asist-tts.workspace = true
+asist-tools.workspace = true
anyhow.workspace = true
crossbeam-channel.workspace = true
serde_json.workspace = true
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs
index 2788d97..7b00b1f 100644
--- a/crates/asist-app/src/main.rs
+++ b/crates/asist-app/src/main.rs
@@ -5,11 +5,13 @@
//! espera a la siguiente.
mod pipeline;
+mod registry;
mod render;
mod session;
mod supervisor;
use std::path::PathBuf;
+use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
@@ -19,7 +21,6 @@ use asist_asr::Recognizer;
use asist_audio::{Capture, CaptureBlock, Playback};
use asist_core::config::Config;
use asist_core::event::Event;
-use asist_core::tools::ToolRegistry;
use asist_llm::LlmClient;
use asist_tts::TtsClient;
@@ -52,7 +53,9 @@ fn run(config: Config, args: &Args) -> Result<()> {
// Los clientes se crean antes de arrancar nada: así se detecta lo que ya
// esté escuchando y no se levanta un servidor por duplicado.
- let llm = LlmClient::new(config.llm_authority(), &config.llm);
+ // Compartido: el orquestador conversa con él y la herramienta de cámara
+ // lo usa para describir lo que capta.
+ let llm = Arc::new(LlmClient::new(config.llm_authority(), &config.llm));
let tts = TtsClient::new(config.tts_authority(), &config.tts);
let (llm_up, tts_up) = (llm.healthy(), tts.healthy());
@@ -92,12 +95,33 @@ fn run(config: Config, args: &Args) -> Result<()> {
let (capture_tx, capture_rx) = unbounded::<CaptureBlock>();
let capture = Capture::open(&config.audio, capture_tx.clone())?;
- let tools = ToolRegistry::from_config(&config.tools);
+ let (tools, skipped) = registry::build(&config, &llm);
if tools.is_empty() {
eprintln!("Herramientas: ninguna");
} else {
eprintln!("Herramientas: {}", tools.names().join(", "));
}
+ // Lo que no se pudo activar se dice en voz alta, en vez de dejar al
+ // usuario preguntándose por qué el asistente no busca ni ve.
+ for skip in &skipped {
+ eprintln!(" · {} no disponible: {}", skip.tool, skip.reason);
+ }
+ // Las dos capacidades que tocan algo fuera del proceso se anuncian: una
+ // enciende la cámara y la otra ejecuta órdenes. Quien lo arranca debería
+ // saberlo sin tener que leerse la configuración.
+ if tools.get("mirar_por_la_camara").is_some() {
+ eprintln!(
+ "Cámara ACTIVA — {} a {}x{}{}",
+ config.camera.device.display(),
+ config.camera.width,
+ config.camera.height,
+ if config.camera.save_dir.is_empty() {
+ String::new()
+ } else {
+ format!(", guardando fotogramas en {}", config.camera.save_dir)
+ }
+ );
+ }
if config.tools.shell {
eprintln!(
"Ejecución de órdenes ACTIVA — permitidas: {}{}",
@@ -263,6 +287,61 @@ fn check(config: &Config) -> Result<()> {
}
}
+ println!("Capacidades:");
+ // Ninguna de las dos es fatal: el asistente conversa igual sin ellas, así
+ // que se informa y no se cuentan como problema.
+ if !config.search.enabled {
+ println!(" - búsqueda desactivada en la configuración");
+ } else {
+ match config.search.backend.trim().to_lowercase().as_str() {
+ "tavily" => match std::env::var(&config.search.api_key_env) {
+ Ok(key) if !key.trim().is_empty() => ok(
+ "búsqueda (tavily)",
+ format!("clave en ${}", config.search.api_key_env),
+ ),
+ _ => println!(
+ " - búsqueda (tavily) falta ${}",
+ config.search.api_key_env
+ ),
+ },
+ "ddgs" | "comando" => match config.search.command.first() {
+ // Un nombre suelto se resuelve por el PATH; una ruta tiene que
+ // existir, y más vale decirlo aquí que a mitad de una pregunta.
+ Some(program) if !program.contains('/') || PathBuf::from(program).exists() => {
+ ok("búsqueda (ddgs)", config.search.command.join(" "))
+ }
+ Some(program) => {
+ println!(" - búsqueda (ddgs) no existe {program}")
+ }
+ None => println!(" - búsqueda (ddgs) search.command está vacío"),
+ },
+ "searxng" => ok("búsqueda (searxng)", config.search.base_url.clone()),
+ other => println!(" - búsqueda backend desconocido: «{other}»"),
+ }
+ }
+
+ if !config.camera.enabled {
+ println!(" - cámara desactivada en la configuración");
+ } else if config.camera.device.exists() {
+ ok(
+ "cámara",
+ format!(
+ "{} a {}x{}",
+ config.camera.device.display(),
+ config.camera.width,
+ config.camera.height
+ ),
+ );
+ if which("ffmpeg").is_none() {
+ println!(" - ffmpeg no está; hace falta para capturar");
+ }
+ } else {
+ println!(
+ " - cámara no existe {}",
+ config.camera.device.display()
+ );
+ }
+
println!("Servidores:");
let llm = LlmClient::new(config.llm_authority(), &config.llm);
let tts = TtsClient::new(config.tts_authority(), &config.tts);
@@ -412,6 +491,16 @@ impl Args {
}
}
+/// Busca un ejecutable en el PATH, para avisar de lo que falta antes de que
+/// falle a mitad de una conversación.
+fn which(program: &str) -> Option<PathBuf> {
+ std::env::var_os("PATH").and_then(|path| {
+ std::env::split_paths(&path)
+ .map(|dir| dir.join(program))
+ .find(|candidate| candidate.is_file())
+ })
+}
+
/// ¿Hay una persona al otro lado de la entrada estándar?
fn stdin_is_tty() -> bool {
#[cfg(unix)]
diff --git a/crates/asist-app/src/pipeline.rs b/crates/asist-app/src/pipeline.rs
index 9e9037e..69fc66a 100644
--- a/crates/asist-app/src/pipeline.rs
+++ b/crates/asist-app/src/pipeline.rs
@@ -67,7 +67,7 @@ impl Pipeline {
config: &Config,
session: Session,
recognizer: Recognizer,
- llm: LlmClient,
+ llm: Arc<LlmClient>,
tts: TtsClient,
tools: ToolRegistry,
playback: PlaybackHandle,
@@ -175,7 +175,11 @@ fn run_segmenter(
}
// El micrófono se cierra mientras suena el altavoz. Con barge-in
// activo no se cierra, sólo sube el listón de volumen.
- segmenter.set_gate(if session.is_speaking() || playback.is_active() {
+ //
+ // La segunda condición mira la cola y no una bandera a propósito: la
+ // cola no puede quedarse desfasada, y una bandera que se olvide de
+ // bajar deja el micrófono cerrado para el resto de la sesión.
+ segmenter.set_gate(if session.is_speaking() || playback.queued_secs() > 0.0 {
Gate::Speaking
} else {
Gate::Open
@@ -253,7 +257,7 @@ fn run_segmenter(
fn run_brain(
config: &Config,
session: Session,
- llm: LlmClient,
+ llm: Arc<LlmClient>,
tools: ToolRegistry,
results: Receiver<AsrResult>,
speak: Sender<SpeakJob>,
@@ -337,6 +341,12 @@ struct Prompts {
/// Para la pasada en que el modelo decide si llamar a una herramienta.
deciding: String,
/// Para redactar la respuesta hablada, ya con los resultados en la mano.
+ ///
+ /// Lleva pegada la orden de usar lo que la herramienta devolvió. Sin ella
+ /// el modelo se limita a anunciar lo que acaba de hacer —«he tomado una
+ /// foto, ahora puedo responderte sobre el objeto o color»— y se deja el
+ /// dato que tenía delante. Aquí sí se puede añadir estilo sin riesgo: la
+ /// llamada ya ocurrió, así que no hay nada que estropear.
speaking: String,
/// `false` cuando no hay herramientas: entonces ambas son la misma.
two_phase: bool,
@@ -352,7 +362,11 @@ impl Prompts {
} else {
speaking.clone()
},
- speaking,
+ speaking: if two_phase {
+ format!("{speaking}\n\n{}", config.general.tool_result_prompt.trim())
+ } else {
+ speaking.clone()
+ },
two_phase,
}
}
@@ -377,6 +391,27 @@ fn answer(
let tools = (!tools.is_empty() && config.tools.enabled).then_some(tools);
let mut splitter = SentenceSplitter::new();
let mut spoken = String::new();
+ // Índice propio, y no el del troceador, porque al flujo de frases se le
+ // cuelan los acuses de las herramientas. El índice 0 marca el primer
+ // sonido del turno, que es de donde se mide la latencia percibida.
+ let mut emitted = 0usize;
+
+ // Manda una frase al sintetizador. Devuelve `false` si el canal se cerró.
+ let say = |index: &mut usize, text: String| -> bool {
+ let _ = events.send(Event::Sentence {
+ turn,
+ index: *index,
+ text: text.clone(),
+ });
+ let job = SpeakJob {
+ turn,
+ index: *index,
+ text,
+ turn_started,
+ };
+ *index += 1;
+ speak.send(job).is_ok()
+ };
for round in 0..=config.llm.max_tool_rounds {
let llm_started = Instant::now();
@@ -403,21 +438,7 @@ fn answer(
// Aquí está el solape: cada frase cerrada sale hacia el
// sintetizador sin esperar al resto de la respuesta.
for sentence in splitter.push(text) {
- let index = splitter.emitted() - 1;
- let _ = events.send(Event::Sentence {
- turn,
- index,
- text: sentence.clone(),
- });
- if speak
- .send(SpeakJob {
- turn,
- index,
- text: sentence,
- turn_started,
- })
- .is_err()
- {
+ if !say(&mut emitted, sentence) {
return false;
}
}
@@ -449,20 +470,9 @@ fn answer(
if outcome.tool_calls.is_empty() {
if let Some(rest) = splitter.flush() {
- let index = splitter.emitted() - 1;
- let _ = events.send(Event::Sentence {
- turn,
- index,
- text: rest.clone(),
- });
- let _ = speak.send(SpeakJob {
- turn,
- index,
- text: rest,
- turn_started,
- });
+ say(&mut emitted, rest);
}
- timer.sentences = splitter.emitted();
+ timer.sentences = emitted;
chat.push(Message::assistant(outcome.text.clone()));
// El turno siguiente vuelve a empezar decidiendo.
if prompts.two_phase {
@@ -500,6 +510,14 @@ fn answer(
name: call.name.clone(),
arguments: call.arguments.clone(),
});
+ // Se dice antes de ejecutar, no después: la gracia es tapar la
+ // espera, y una búsqueda con las dos pasadas del modelo detrás son
+ // seis segundos que sin esto pasan en silencio absoluto.
+ if config.tools.spoken_ack {
+ if let Some(ack) = tools.get(&call.name).and_then(|t| t.acknowledgement()) {
+ say(&mut emitted, ack.to_string());
+ }
+ }
let result = tools.dispatch(call);
let _ = events.send(Event::ToolFinished {
turn,
@@ -553,8 +571,9 @@ fn run_speech(
let Some(job) = job else {
// Sin trabajo: si ya no queda audio, el turno ha terminado de sonar.
if let Some(turn) = speaking_turn {
- if !playback.is_active() || playback.queued_secs() <= 0.0 {
+ if playback.queued_secs() <= 0.0 {
session.set_speaking(false);
+ playback.mark_idle();
speaking_turn = None;
let _ = events.send(Event::AudioFinished { turn });
}
diff --git a/crates/asist-app/src/registry.rs b/crates/asist-app/src/registry.rs
new file mode 100644
index 0000000..13bbb75
--- /dev/null
+++ b/crates/asist-app/src/registry.rs
@@ -0,0 +1,122 @@
+//! Montaje del juego de herramientas.
+//!
+//! `ToolRegistry::from_config` sólo conoce las que no dependen de nada
+//! —el reloj, la shell—, porque vive en el núcleo. Las que necesitan la red o
+//! el modelo multimodal se añaden aquí, que es exactamente el punto de
+//! extensión documentado en docs/EXTENDER.md.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use asist_core::config::{CameraConfig, Config, SearchConfig};
+use asist_core::tools::ToolRegistry;
+use asist_llm::LlmClient;
+use asist_tools::camera::CaptureConfig;
+use asist_tools::{Camera, SearchBackend, WebSearch};
+
+/// Lo que no se pudo activar y por qué, para decírselo al usuario al arrancar
+/// en vez de dejarlo en silencio.
+pub struct Skipped {
+ pub tool: &'static str,
+ pub reason: String,
+}
+
+pub fn build(config: &Config, llm: &Arc<LlmClient>) -> (ToolRegistry, Vec<Skipped>) {
+ let mut registry = ToolRegistry::from_config(&config.tools);
+ let mut skipped = Vec::new();
+
+ if !config.tools.enabled {
+ return (registry, skipped);
+ }
+
+ match search_tool(&config.search) {
+ Ok(Some(tool)) => registry.register(Arc::new(tool)),
+ Ok(None) => {}
+ Err(reason) => skipped.push(Skipped {
+ tool: "buscar_en_internet",
+ reason,
+ }),
+ }
+
+ match camera_tool(&config.camera, llm) {
+ Ok(Some(tool)) => registry.register(Arc::new(tool)),
+ Ok(None) => {}
+ Err(reason) => skipped.push(Skipped {
+ tool: "mirar_por_la_camara",
+ reason,
+ }),
+ }
+
+ (registry, skipped)
+}
+
+fn search_tool(config: &SearchConfig) -> Result<Option<WebSearch>, String> {
+ if !config.enabled {
+ return Ok(None);
+ }
+ let backend = match config.backend.trim().to_lowercase().as_str() {
+ "tavily" => {
+ // La clave sale del entorno, nunca del fichero de configuración.
+ let key = std::env::var(&config.api_key_env)
+ .ok()
+ .map(|k| k.trim().to_string())
+ .filter(|k| !k.is_empty())
+ .ok_or_else(|| {
+ format!(
+ "la variable {} está vacía o sin definir; expórtala o cambia \
+ search.backend a «searxng»",
+ config.api_key_env
+ )
+ })?;
+ SearchBackend::Tavily { api_key: key }
+ }
+ "searxng" => SearchBackend::SearxNG {
+ base_url: config.base_url.clone(),
+ },
+ "ddgs" | "comando" => {
+ let (program, args) = config
+ .command
+ .split_first()
+ .ok_or_else(|| "search.command está vacío".to_string())?;
+ if !std::path::Path::new(program).exists() && !program.contains('/') {
+ // Un nombre suelto se busca en el PATH; una ruta tiene que existir.
+ } else if !std::path::Path::new(program).exists() {
+ return Err(format!("no existe el buscador {program}"));
+ }
+ SearchBackend::Command {
+ program: program.into(),
+ args: args.to_vec(),
+ }
+ }
+ other => return Err(format!("search.backend desconocido: «{other}»")),
+ };
+
+ Ok(Some(WebSearch::new(
+ backend,
+ config.max_results,
+ Duration::from_secs(config.timeout_secs),
+ )))
+}
+
+fn camera_tool(config: &CameraConfig, llm: &Arc<LlmClient>) -> Result<Option<Camera>, String> {
+ if !config.enabled {
+ return Ok(None);
+ }
+ let capture = CaptureConfig {
+ device: config.device.clone(),
+ width: config.width,
+ height: config.height,
+ warmup_frames: config.warmup_frames,
+ timeout: Duration::from_secs(config.timeout_secs),
+ save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()),
+ };
+ // Registrar una herramienta que va a fallar siempre es peor que no tenerla:
+ // el modelo la llamaría, se comería el error y gastaría un turno entero.
+ if !Camera::available(&capture) {
+ return Err(format!(
+ "no existe {}; comprueba con «v4l2-ctl --list-devices»",
+ capture.device.display()
+ ));
+ }
+ Ok(Some(Camera::new(capture, Arc::clone(llm))))
+}
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs
index 91a074a..a7f7e15 100644
--- a/crates/asist-app/tests/integracion.rs
+++ b/crates/asist-app/tests/integracion.rs
@@ -14,7 +14,7 @@ use std::time::{Duration, Instant};
use asist_core::config::Config;
use asist_core::http::Cancel;
-use asist_core::tools::ToolRegistry;
+use asist_core::tools::{Tool, ToolRegistry};
use asist_llm::chat::Conversation;
use asist_llm::{Delta, LlmClient, Message};
use asist_tts::TtsClient;
@@ -264,3 +264,207 @@ fn el_asr_transcribe_lo_que_sintetiza_el_tts() {
"sólo se reconocieron {acertadas} palabras de «{frase}» en «{texto}»"
);
}
+
+// ---------------------------------------------------------------------------
+// Buscar en internet y mirar por la cámara
+// ---------------------------------------------------------------------------
+
+#[test]
+fn la_busqueda_devuelve_algo_pronunciable() {
+ let config = config();
+ if !config.search.enabled {
+ eprintln!("búsqueda desactivada; prueba omitida");
+ return;
+ }
+ let Ok(key) = std::env::var(&config.search.api_key_env) else {
+ eprintln!("sin ${}; prueba omitida", config.search.api_key_env);
+ return;
+ };
+ if key.trim().is_empty() {
+ eprintln!("${} vacía; prueba omitida", config.search.api_key_env);
+ return;
+ }
+
+ let tool = asist_tools::WebSearch::new(
+ asist_tools::SearchBackend::Tavily { api_key: key },
+ config.search.max_results,
+ Duration::from_secs(config.search.timeout_secs),
+ );
+ let started = Instant::now();
+ let out = tool
+ .call(&serde_json::json!({ "consulta": "capital de Australia" }))
+ .expect("la búsqueda falló");
+
+ eprintln!(
+ "búsqueda en {:?}: {}",
+ started.elapsed(),
+ &out[..out.len().min(200)]
+ );
+ assert!(!out.trim().is_empty());
+ assert!(
+ out.to_lowercase().contains("canberra"),
+ "esperaba la respuesta en el resumen, salió: {out}"
+ );
+ // Lo que devuelve se lee en voz alta; una parrafada de miles de caracteres
+ // ahoga al modelo que tiene que resumirla.
+ assert!(
+ out.len() < 6000,
+ "el resultado es demasiado largo: {} bytes",
+ out.len()
+ );
+}
+
+#[test]
+fn una_consulta_inventada_no_revienta_el_turno() {
+ let config = config();
+ let Ok(key) = std::env::var(&config.search.api_key_env) else {
+ return;
+ };
+ if key.trim().is_empty() {
+ return;
+ }
+ // Vía el registro, que es como llega de verdad: un fallo tiene que volver
+ // como texto para el modelo, nunca como un error que corte la respuesta.
+ let mut registry = ToolRegistry::new();
+ registry.register(std::sync::Arc::new(asist_tools::WebSearch::new(
+ asist_tools::SearchBackend::Tavily {
+ api_key: "clave-invalida".into(),
+ },
+ 3,
+ Duration::from_secs(10),
+ )));
+ let outcome = registry.dispatch(&asist_core::tools::ToolCall {
+ id: "1".into(),
+ name: "buscar_en_internet".into(),
+ arguments: r#"{"consulta":"algo"}"#.into(),
+ });
+ assert!(!outcome.ok);
+ assert!(
+ !outcome.output.contains("clave-invalida"),
+ "el mensaje de error no debe llevar la clave dentro: {}",
+ outcome.output
+ );
+}
+
+#[test]
+fn la_camara_captura_un_jpeg() {
+ let config = config();
+ let capture = asist_tools::camera::CaptureConfig {
+ device: config.camera.device.clone(),
+ width: config.camera.width,
+ height: config.camera.height,
+ warmup_frames: config.camera.warmup_frames,
+ timeout: Duration::from_secs(config.camera.timeout_secs),
+ save_dir: None,
+ };
+ if !config.camera.enabled || !capture.device.exists() {
+ eprintln!("sin cámara; prueba omitida");
+ return;
+ }
+
+ let started = Instant::now();
+ let frame = asist_tools::camera::capture(&capture).expect("la captura falló");
+ eprintln!(
+ "fotograma de {} KB en {:?}",
+ frame.len() / 1024,
+ started.elapsed()
+ );
+
+ assert!(
+ frame.len() > 1000,
+ "el fotograma es sospechosamente pequeño"
+ );
+ // Cabecera JPEG: si no está, ffmpeg devolvió otra cosa y el modelo la
+ // rechazaría sin decir por qué.
+ assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG");
+}
+
+#[test]
+#[ignore = "enciende la cámara y hace una pasada de visión; lento"]
+fn el_modelo_describe_lo_que_ve_la_camara() {
+ let _gpu = en_exclusiva();
+ let Some((config, llm, _)) = clientes() else {
+ return;
+ };
+ let capture = asist_tools::camera::CaptureConfig {
+ device: config.camera.device.clone(),
+ width: config.camera.width,
+ height: config.camera.height,
+ warmup_frames: config.camera.warmup_frames,
+ timeout: Duration::from_secs(config.camera.timeout_secs),
+ save_dir: None,
+ };
+ if !config.camera.enabled || !capture.device.exists() {
+ eprintln!("sin cámara; prueba omitida");
+ return;
+ }
+
+ let tool = asist_tools::Camera::new(capture, std::sync::Arc::new(llm));
+ let started = Instant::now();
+ let out = tool
+ .call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" }))
+ .expect("la descripción falló");
+
+ eprintln!("visión en {:?}: {out}", started.elapsed());
+ assert!(
+ out.split_whitespace().count() >= 3,
+ "descripción vacía o mínima: {out}"
+ );
+ assert!(
+ started.elapsed() < Duration::from_secs(20),
+ "tardó {:?}; revisa camera.width/height",
+ started.elapsed()
+ );
+}
+
+#[test]
+#[ignore = "sale a internet con los dos buscadores; lento"]
+fn los_dos_buscadores_responden_a_lo_mismo() {
+ // Compara lo que devuelve cada uno para las mismas preguntas. No afirma
+ // cuál es mejor —eso depende de la pregunta— sino que ambos funcionan y
+ // deja las cifras a la vista para elegir con datos.
+ let config = config();
+ let preguntas = [
+ "capital de Australia",
+ "qué tiempo hace hoy en Buenos Aires",
+ ];
+
+ let mut backends: Vec<(&str, asist_tools::SearchBackend)> = Vec::new();
+ if let Ok(key) = std::env::var(&config.search.api_key_env) {
+ if !key.trim().is_empty() {
+ backends.push((
+ "tavily",
+ asist_tools::SearchBackend::Tavily { api_key: key },
+ ));
+ }
+ }
+ if let Some(program) = config.search.command.first() {
+ if PathBuf::from(program).exists() {
+ backends.push(("ddgs", asist_tools::SearchBackend::ddgs(program)));
+ }
+ }
+ if backends.is_empty() {
+ eprintln!("ningún buscador configurado; prueba omitida");
+ return;
+ }
+
+ for (nombre, backend) in backends {
+ let tool = asist_tools::WebSearch::new(backend, 3, Duration::from_secs(30));
+ for pregunta in preguntas {
+ let started = Instant::now();
+ match tool.call(&serde_json::json!({ "consulta": pregunta })) {
+ Ok(out) => {
+ let primera = out.lines().next().unwrap_or("").to_string();
+ eprintln!(
+ "{nombre:7} {:>6.2}s {:>5} bytes «{pregunta}»\n {}",
+ started.elapsed().as_secs_f32(),
+ out.len(),
+ &primera[..primera.len().min(150)]
+ );
+ assert!(!out.trim().is_empty());
+ }
+ Err(err) => panic!("{nombre} falló en «{pregunta}»: {err}"),
+ }
+ }
+ }
+}
diff --git a/crates/asist-audio/src/lib.rs b/crates/asist-audio/src/lib.rs
index f6979ca..de3f002 100644
--- a/crates/asist-audio/src/lib.rs
+++ b/crates/asist-audio/src/lib.rs
@@ -15,7 +15,7 @@ pub mod vad;
pub use cpal;
pub use capture::{Capture, CaptureBlock, InputFormat};
-pub use playback::{Playback, PlaybackHandle};
+pub use playback::{detached_handle, Playback, PlaybackHandle};
pub use vad::{Gate, Segmenter, Utterance, VoiceEvent};
/// Frecuencia a la que trabaja Canary. La captura se abre directamente aquí
diff --git a/crates/asist-audio/src/playback.rs b/crates/asist-audio/src/playback.rs
index fcdfe7d..903519f 100644
--- a/crates/asist-audio/src/playback.rs
+++ b/crates/asist-audio/src/playback.rs
@@ -78,6 +78,20 @@ impl PlaybackHandle {
self.push(&to_mono_at(samples, format, self.sample_rate));
}
+ /// Marca que se ha terminado de alimentar audio y ya ha sonado todo.
+ ///
+ /// Hace falta un método aparte de `stop`: éste no tira nada, sólo apaga la
+ /// bandera de «hay audio en marcha». Sin él la bandera se quedaba puesta
+ /// tras la primera respuesta, el segmentador mantenía el micrófono cerrado
+ /// creyendo que el asistente seguía hablando, y el asistente no volvía a
+ /// oír nada en toda la sesión.
+ pub fn mark_idle(&self) {
+ let queue = self.ring.lock();
+ if queue.is_empty() {
+ self.ring.active.store(false, Ordering::SeqCst);
+ }
+ }
+
/// Calla ahora mismo y tira lo que quedaba por sonar.
pub fn stop(&self) {
let mut queue = self.ring.lock();
@@ -135,6 +149,15 @@ impl PlaybackHandle {
}
}
+/// Un mando sin dispositivo detrás, para probar la lógica del anillo sin
+/// abrir una tarjeta de sonido.
+pub fn detached_handle(sample_rate: u32) -> PlaybackHandle {
+ PlaybackHandle {
+ ring: Arc::new(Ring::new(1.0)),
+ sample_rate,
+ }
+}
+
pub struct Playback {
stream: cpal::Stream,
handle: PlaybackHandle,
@@ -285,3 +308,61 @@ fn preferred_config(device: &cpal::Device) -> Result<cpal::SupportedStreamConfig
.map_err(|e| Error::Audio(format!("sin configuración de salida: {e}"))),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn el_anillo_deja_de_estar_activo_cuando_se_vacia() {
+ // La regresión que dejaba mudo al asistente tras la primera respuesta:
+ // sin marcar el fin, el segmentador creía que seguía hablando y no
+ // volvía a abrir el micrófono nunca.
+ let handle = detached_handle(24_000);
+ assert!(!handle.is_active(), "recién creado no hay nada sonando");
+
+ handle.push(&[0.1; 480]);
+ assert!(handle.is_active());
+
+ handle.mark_idle();
+ assert!(
+ handle.is_active(),
+ "con audio pendiente todavía está sonando, no debe apagarse"
+ );
+
+ handle.stop();
+ handle.push(&[0.1; 240]);
+ handle.stop();
+ handle.mark_idle();
+ assert!(
+ !handle.is_active(),
+ "con la cola vacía debe quedar inactivo"
+ );
+ }
+
+ #[test]
+ fn la_cola_se_mide_en_segundos_de_audio() {
+ let handle = detached_handle(24_000);
+ handle.push(&[0.0; 12_000]);
+ assert!((handle.queued_secs() - 0.5).abs() < 1e-6);
+ handle.stop();
+ assert_eq!(handle.queued_secs(), 0.0);
+ }
+
+ #[test]
+ fn cortar_vacia_la_cola_al_instante() {
+ let handle = detached_handle(24_000);
+ handle.push(&[0.5; 48_000]);
+ handle.stop();
+ assert_eq!(handle.queued_secs(), 0.0, "cortar debe tirar lo pendiente");
+ assert!(!handle.is_active());
+ }
+
+ #[test]
+ fn el_audio_del_tts_se_remuestrea_al_dispositivo() {
+ // Medio segundo a 24 kHz debe seguir durando medio segundo a 48 kHz.
+ let handle = detached_handle(48_000);
+ handle.push_tts(&[0.0; 12_000]);
+ assert!((handle.queued_secs() - 0.5).abs() < 0.01);
+ }
+}
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, Seri