diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/asist-app/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/asist-app/src/main.rs | 95 | ||||
| -rw-r--r-- | crates/asist-app/src/pipeline.rs | 85 | ||||
| -rw-r--r-- | crates/asist-app/src/registry.rs | 122 | ||||
| -rw-r--r-- | crates/asist-app/tests/integracion.rs | 206 | ||||
| -rw-r--r-- | crates/asist-audio/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/asist-audio/src/playback.rs | 81 | ||||
| -rw-r--r-- | crates/asist-core/src/config.rs | 149 | ||||
| -rw-r--r-- | crates/asist-core/src/tools.rs | 12 | ||||
| -rw-r--r-- | crates/asist-llm/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/asist-llm/src/client.rs | 88 | ||||
| -rw-r--r-- | crates/asist-tools/Cargo.toml | 16 | ||||
| -rw-r--r-- | crates/asist-tools/src/camera.rs | 290 | ||||
| -rw-r--r-- | crates/asist-tools/src/lib.rs | 14 | ||||
| -rw-r--r-- | crates/asist-tools/src/search.rs | 489 |
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 |