From 71764752f28d31028b9c2ca486c0fc1bcea1cf95 Mon Sep 17 00:00:00 2001 From: elvis Date: Sun, 6 Sep 2026 22:05:14 -0300 Subject: Web search and camera vision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 594 +++++++++++++++++++++++++++++++++- Cargo.toml | 2 + README.md | 30 +- config/asistente.toml | 56 +++- crates/asist-app/Cargo.toml | 2 + crates/asist-app/src/main.rs | 95 +++++- crates/asist-app/src/pipeline.rs | 85 +++-- crates/asist-app/src/registry.rs | 122 +++++++ crates/asist-app/tests/integracion.rs | 206 +++++++++++- crates/asist-audio/src/lib.rs | 2 +- crates/asist-audio/src/playback.rs | 81 +++++ crates/asist-core/src/config.rs | 149 +++++++++ crates/asist-core/src/tools.rs | 12 + crates/asist-llm/Cargo.toml | 1 + crates/asist-llm/src/client.rs | 88 +++++ crates/asist-tools/Cargo.toml | 16 + crates/asist-tools/src/camera.rs | 290 +++++++++++++++++ crates/asist-tools/src/lib.rs | 14 + crates/asist-tools/src/search.rs | 489 ++++++++++++++++++++++++++++ docs/EXTENDER.md | 127 +++++++- docs/RENDIMIENTO.md | 131 ++++++++ scripts/buscar-ddgs.sh | 54 ++++ 22 files changed, 2592 insertions(+), 54 deletions(-) create mode 100644 crates/asist-app/src/registry.rs create mode 100644 crates/asist-tools/Cargo.toml create mode 100644 crates/asist-tools/src/camera.rs create mode 100644 crates/asist-tools/src/lib.rs create mode 100644 crates/asist-tools/src/search.rs create mode 100755 scripts/buscar-ddgs.sh diff --git a/Cargo.lock b/Cargo.lock index ba0a895..1b67d66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -48,6 +54,7 @@ dependencies = [ "asist-audio", "asist-core", "asist-llm", + "asist-tools", "asist-tts", "crossbeam-channel", "serde_json", @@ -95,11 +102,23 @@ name = "asist-llm" version = "0.1.0" dependencies = [ "asist-core", + "base64 0.22.1", "serde", "serde_json", "tracing", ] +[[package]] +name = "asist-tools" +version = "0.1.0" +dependencies = [ + "asist-core", + "asist-llm", + "serde_json", + "tracing", + "ureq", +] + [[package]] name = "asist-tts" version = "0.1.0" @@ -250,6 +269,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -310,6 +358,15 @@ dependencies = [ "windows", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -341,6 +398,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch2" version = "0.3.1" @@ -351,6 +414,26 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -379,6 +462,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -394,6 +487,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -418,6 +520,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -463,6 +576,110 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -552,6 +769,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.34" @@ -598,6 +827,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -677,6 +916,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -932,6 +1177,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "primal-check" version = "0.3.4" @@ -1006,6 +1266,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rubato" version = "2.0.0" @@ -1049,6 +1323,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1058,6 +1347,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1172,6 +1472,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1195,12 +1501,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1223,6 +1541,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1230,7 +1559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1285,6 +1614,46 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.8.23" @@ -1433,6 +1802,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.4.1" @@ -1440,15 +1815,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" dependencies = [ "base64 0.23.1", + "cookie_store", "der", + "flate2", "log", "native-tls", "percent-encoding", + "rustls", "rustls-pki-types", + "serde", + "serde_json", "socks", "ureq-proto", "utf8-zero", "webpki-root-certs", + "webpki-roots", ] [[package]] @@ -1463,12 +1844,30 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -1481,6 +1880,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "visibility" version = "0.1.1" @@ -1502,6 +1907,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.128" @@ -1576,6 +1987,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1723,7 +2143,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1741,13 +2170,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1765,42 +2210,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1819,12 +2312,95 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 1ef3f34..fc2b030 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/asist-asr", "crates/asist-llm", "crates/asist-tts", + "crates/asist-tools", "crates/asist-app", ] # Los submódulos son dependencias por ruta, no miembros del workspace: sin esto @@ -27,6 +28,7 @@ asist-audio = { path = "crates/asist-audio" } asist-asr = { path = "crates/asist-asr" } asist-llm = { path = "crates/asist-llm" } asist-tts = { path = "crates/asist-tts" } +asist-tools = { path = "crates/asist-tools" } canary-rs = { path = "vendor/canary-rs", default-features = false, features = ["cpu", "ort-defaults"] } # canary-rs pide "2.0.0-rc.12", que en semver de prelanzamientos también admite diff --git a/README.md b/README.md index deeef3c..90d108e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ cargo run --release -- run --no-manage | `asist-asr` | Canary en ONNX: ventana deslizante y transcripción final | | `asist-llm` | llama-server: streaming SSE, historial, llamadas a herramientas | | `asist-tts` | qwentts: síntesis en streaming y voces clonadas | +| `asist-tools` | Buscar en internet y mirar por la cámara | | `asist-app` | Supervisor de procesos, orquestador y binario `asistente` | Los tres motores viven en `vendor/` como submódulos fijados a un commit @@ -120,14 +121,37 @@ de configuración valieron más que cualquier cambio de código: - La plantilla del modelo dejaba `` abierto: **8630 ms → 413 ms** al primer token. - El prompt de estilo anulaba las llamadas a herramientas: **0/8 → 8/8**. +- La cámara capturaba a 720p: **7,8 s → 2,9 s** por imagen a 640x480. +- Las herramientas se ejecutaban en silencio: **~8,4 s → 4,1 s** hasta oír algo. Los tres, con el porqué y cómo se midieron, en [docs/RENDIMIENTO.md](docs/RENDIMIENTO.md). ## Herramientas -El asistente puede llamar a funciones. Viene con `hora_actual` y con -`ejecutar_comando`, esta última **apagada**: darle una shell a un modelo que +El asistente puede llamar a funciones. Vienen cuatro: + +| Herramienta | Qué hace | Coste medido | +|---|---|---| +| `hora_actual` | Fecha y hora del sistema | 0 ms | +| `buscar_en_internet` | Busca y resume | 0,5–3,6 s | +| `mirar_por_la_camara` | Hace una foto y responde sobre lo que ve | 4,2–5,2 s | +| `ejecutar_comando` | Órdenes del sistema, **apagada** por defecto | — | + +Mientras una herramienta trabaja, el asistente dice «Déjame que lo busque» o +«Voy a mirar». No es adorno: sin eso el turno pasa seis segundos en silencio y +se lee como un cuelgue. Con el acuse, el primer audio llega en 3–4 s. + +**Buscar** admite tres buscadores: `tavily` (con clave, devuelve una respuesta +ya redactada), `ddgs` (sin clave, la misma librería que hay bajo +`duckduckgo-mcp`) y `searxng`. El de comando ejecuta un guion que escriba JSON, +así que meter otro es escribir un guion, no tocar Rust. + +**Mirar** aprovecha que el servidor ya carga el proyector multimodal: el mismo +modelo que conversa describe la imagen. La foto no se guarda en disco ni entra +en el historial. + +`ejecutar_comando` está apagada a conciencia: darle una shell a un modelo que obedece a lo que oye por el micrófono es un cambio de postura de seguridad. Cuando se enciende, sólo pasan las órdenes de una lista blanca, sin shell que interprete metacaracteres y con un plazo máximo. @@ -160,4 +184,6 @@ frase y comprueba que el reconocedor la recupera. - CUDA para los motores en C++ (funcionan en CPU, pero muy justos) - PipeWire o ALSA - ~6 GB de disco para los modelos +- `ffmpeg` para la cámara +- Para buscar: una clave de Tavily en `$TAVILY_API_KEY`, o `uv tool install ddgs` diff --git a/config/asistente.toml b/config/asistente.toml index b638acd..a219142 100644 --- a/config/asistente.toml +++ b/config/asistente.toml @@ -30,6 +30,17 @@ es así, llámala primero y espera su resultado; no contestes de memoria. Sólo cuando tengas el resultado, resúmelo en una frase. """ +# Se añade a system_prompt para redactar la respuesta cuando una herramienta ya +# devolvió su resultado. Aquí sí se puede añadir estilo —la llamada ya ocurrió— +# y hace falta: sin esto el modelo anuncia lo que acaba de hacer («he tomado una +# foto, ahora puedo responderte sobre el color») en vez de decir lo que averiguó. +tool_result_prompt = """ +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. +""" + [audio] # Vacío = el dispositivo por defecto. `asistente devices` los lista. input_device = "" @@ -113,11 +124,54 @@ dedicated_prompt = true # una shell a un modelo que obedece a lo que oye por el micrófono es un cambio # de postura de seguridad, no una comodidad. Actívala aquí o con --shell. shell = false -shell_allowlist = ["date", "uptime", "free", "df", "ls"] +shell_allowlist = ["date", "uptime", "free", "df", "ls", "mkdir", "cat"] shell_timeout_secs = 10 shell_dry_run = false shell_working_dir = "" +# Búsqueda en internet. La clave NO va aquí: se lee de la variable de entorno +# que indique api_key_env, porque este fichero se versiona y un secreto dentro +# acaba en el historial de git. +# +# Tres buscadores, medidos: +# tavily ~2,4 s. Devuelve una respuesta YA REDACTADA además de los enlaces, +# que es lo que se puede leer en voz alta sin gastar otra vuelta del +# modelo en resumir. Necesita clave. +# ddgs 1,5-5,8 s. Sin clave. Consulta DuckDuckGo, Brave, Mojeek, Startpage +# y compañía a través de scripts/buscar-ddgs.sh. Sólo devuelve +# fragmentos: la síntesis la tiene que hacer el modelo, y con 2B sale +# algo peor que con tavily. +# searxng Sin clave, pero hace falta una instancia propia. Como ddgs, sólo +# devuelve resultados. +[search] +enabled = true +backend = "tavily" # "tavily", "ddgs" o "searxng" +api_key_env = "TAVILY_API_KEY" +base_url = "" # sólo para searxng, p. ej. "http://127.0.0.1:8888" +# Programa del backend "ddgs". {consulta} y {max} se sustituyen antes de +# ejecutar, y tiene que escribir JSON por la salida estándar. Vale cualquier +# otro programa que respete eso, incluido un puente a un servidor MCP. +command = ["../scripts/buscar-ddgs.sh", "{consulta}", "{max}"] +max_results = 5 +timeout_secs = 20 + +# Mirar por la cámara. El servidor ya carga el proyector multimodal, así que el +# mismo modelo que conversa describe lo que capta. +[camera] +enabled = true +device = "/dev/video0" +# La resolución manda en la latencia. Medido en esta máquina: 1,3 s a 320x240, +# 2,9 s a 640x480 y 7,8 s a 1280x720. 640x480 es el equilibrio. +width = 640 +height = 480 +# Fotogramas descartados para que se asiente la exposición automática; el +# primero suele salir quemado y descartar unos pocos es casi gratis. +warmup_frames = 5 +timeout_secs = 15 +# Vacío = no se guarda ningún fotograma en disco, que es lo que corresponde. +# Ponle una carpeta sólo para depurar qué está viendo el modelo. +save_dir = "" + [supervisor] manage = true # lanzar los servidores; --no-manage los supone arriba startup_timeout_secs = 180 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::(); 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 { + 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, 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, tools: ToolRegistry, results: Receiver, speak: Sender, @@ -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) -> (ToolRegistry, Vec) { + 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, 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) -> Result, 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