diff options
| author | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-26 20:20:19 -0300 |
| commit | 8518a63f55153e7f45fd49ad6caff5555f4e374f (patch) | |
| tree | 636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-llm/src | |
| parent | 69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff) | |
| download | asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.tar.gz asist-p-8518a63f55153e7f45fd49ad6caff5555f4e374f.zip | |
Translate code, comments, logs and terminal UI to English; add English README; rename scriptsHEADmain
Diffstat (limited to 'crates/asist-llm/src')
| -rw-r--r-- | crates/asist-llm/src/chat.rs | 46 | ||||
| -rw-r--r-- | crates/asist-llm/src/client.rs | 98 | ||||
| -rw-r--r-- | crates/asist-llm/src/lib.rs | 10 |
3 files changed, 75 insertions, 79 deletions
diff --git a/crates/asist-llm/src/chat.rs b/crates/asist-llm/src/chat.rs index 01a9a74..d8fa1f5 100644 --- a/crates/asist-llm/src/chat.rs +++ b/crates/asist-llm/src/chat.rs @@ -1,4 +1,4 @@ -//! Historial de conversación en el formato que espera la API de chat. +//! Conversation history in the format the chat API expects. use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -29,9 +29,9 @@ impl Role { pub struct Message { pub role: Role, pub content: String, - /// Herramientas que el asistente pidió en este turno. + /// Tools the assistant asked for in this turn. pub tool_calls: Vec<ToolCall>, - /// Para los mensajes de rol `tool`: a qué llamada responden. + /// For `tool` role messages: which call they answer. pub tool_call_id: Option<String>, } @@ -57,7 +57,7 @@ impl Message { } } - /// Turno del asistente que en vez de hablar pidió herramientas. + /// Assistant turn that asked for tools instead of speaking. pub fn tool_request(content: String, tool_calls: Vec<ToolCall>) -> Self { Self { role: Role::Assistant, @@ -67,7 +67,7 @@ impl Message { } } - /// Resultado devuelto al modelo. + /// Result handed back to the model. pub fn tool_result(outcome: &ToolOutcome) -> Self { Self { role: Role::Tool, @@ -100,8 +100,8 @@ impl Message { } } -/// Historial con la instrucción de sistema fija y una ventana deslizante de -/// turnos, para que la conversación no crezca sin fin. +/// History with a fixed system prompt and a sliding window of turns, so +/// the conversation does not grow without end. #[derive(Debug, Clone)] pub struct Conversation { system: Message, @@ -118,11 +118,11 @@ impl Conversation { } } - /// Cambia la instrucción de sistema sin tocar el historial. + /// Changes the system prompt without touching the history. /// - /// El turno alterna entre la guía de herramientas y la de estilo, y el - /// historial tiene que sobrevivir al cambio: si se reiniciara, el modelo - /// perdería los resultados que acaba de pedir. + /// The turn alternates between the tool guide and the style guide, and the + /// history must survive the switch: if it were reset, the model would lose + /// the results it just asked for. pub fn set_system(&mut self, prompt: &str) { self.system = Message::system(prompt); } @@ -137,9 +137,9 @@ impl Conversation { self.trim(); } - /// Recorta a `max_turns` intervenciones de usuario, sin dejar nunca un - /// mensaje de rol `tool` huérfano al principio: la API lo rechaza si no - /// va precedido de la llamada que lo originó. + /// Trims to `max_turns` user utterances, never leaving an orphan `tool` + /// role message at the start: the API rejects it unless it comes right + /// after the call that caused it. fn trim(&mut self) { if self.max_turns == 0 { self.turns.clear(); @@ -187,7 +187,7 @@ mod tests { use super::*; #[test] - fn la_instruccion_de_sistema_va_siempre_delante() { + fn system_prompt_always_goes_first() { let mut chat = Conversation::new("sé breve", 4); chat.push(Message::user("hola")); let json = chat.to_json(); @@ -197,7 +197,7 @@ mod tests { } #[test] - fn cambiar_la_instruccion_de_sistema_conserva_el_historial() { + fn changing_the_system_prompt_keeps_the_history() { let mut chat = Conversation::new("primera", 4); chat.push(Message::user("hola")); chat.set_system("segunda"); @@ -205,12 +205,12 @@ mod tests { assert_eq!(json[0]["content"], "segunda"); assert_eq!( json[1]["content"], "hola", - "el historial no puede perderse al cambiar" + "the history must not be lost on the switch" ); } #[test] - fn el_historial_se_recorta_por_turnos_de_usuario() { + fn history_is_trimmed_by_user_turns() { let mut chat = Conversation::new("s", 2); for i in 0..5 { chat.push(Message::user(format!("p{i}"))); @@ -226,9 +226,9 @@ mod tests { } #[test] - fn el_recorte_no_deja_un_resultado_de_herramienta_huerfano() { - // La API rechaza un mensaje `tool` que no venga detrás de la llamada - // que lo pidió, así que el corte tiene que caer en un turno de usuario. + fn trimming_does_not_orphan_a_tool_result() { + // The API rejects a `tool` message that does not follow the call that + // asked for it, so the cut must land on a user turn. let mut chat = Conversation::new("s", 1); chat.push(Message::user("p0")); chat.push(Message::tool_request( @@ -252,14 +252,14 @@ mod tests { } #[test] - fn con_cero_turnos_no_hay_memoria() { + fn zero_turns_means_no_memory() { let mut chat = Conversation::new("s", 0); chat.push(Message::user("hola")); assert_eq!(chat.messages().len(), 1); } #[test] - fn una_llamada_a_herramienta_se_serializa_como_espera_la_api() { + fn tool_call_is_serialized_as_the_api_expects() { let message = Message::tool_request( String::new(), vec![ToolCall { diff --git a/crates/asist-llm/src/client.rs b/crates/asist-llm/src/client.rs index 7282947..f43ad26 100644 --- a/crates/asist-llm/src/client.rs +++ b/crates/asist-llm/src/client.rs @@ -1,4 +1,4 @@ -//! Transporte hacia llama-server. +//! Transport to llama-server. use std::time::{Duration, Instant}; @@ -11,21 +11,21 @@ use asist_core::tools::{ToolCall, ToolRegistry}; use crate::chat::Conversation; -/// Un trozo de respuesta según sale del modelo. +/// A piece of the answer as it comes out of the model. #[derive(Debug, Clone)] pub enum Delta { - /// Texto para el usuario. + /// Text for the user. Text(String), - /// El modelo ha decidido llamar a herramientas; no habrá más texto. + /// The model decided to call tools; no more text will come. ToolCalls(Vec<ToolCall>), } -/// Cómo terminó una respuesta en streaming. +/// How a streaming answer ended. #[derive(Debug, Clone, Default)] pub struct StreamOutcome { pub text: String, pub tool_calls: Vec<ToolCall>, - /// Tiempo hasta el primer fragmento con contenido. + /// Time until the first fragment with content. pub ttft: Option<Duration>, pub stopped_early: bool, } @@ -48,7 +48,7 @@ impl LlmClient { self.http.healthy("/health") } - /// Espera a que el servidor termine de cargar el modelo. + /// Waits for the server to finish loading the model. pub fn wait_ready(&self, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { @@ -58,18 +58,18 @@ impl LlmClient { std::thread::sleep(Duration::from_millis(500)); } Err(Error::Llm(format!( - "{} no respondió a /health en {} s", + "{} did not answer /health within {} s", self.http.authority, timeout.as_secs() ))) } - /// Comprueba que la plantilla de chat no arranca un bloque de - /// razonamiento sin cerrar. + /// Checks that the chat template does not open a reasoning block + /// without closing it. /// - /// Merece la pena avisar: con la plantilla original de Qwen3.5 el modelo - /// se pasa entre 7 y 9 segundos «pensando» antes de la primera palabra - /// audible, y desde fuera parece que el asistente se ha colgado. + /// It is worth a warning: with the original Qwen3.5 template the model + /// spends 7 to 9 seconds «thinking» before the first audible word, and from + /// the outside the assistant looks hung. pub fn warn_if_thinking_template(&self) { let Ok(body) = self.http.get("/props") else { return; @@ -86,24 +86,24 @@ impl LlmClient { if opens > closes { tracing::warn!( target: "llm", - "la plantilla de chat abre <think> sin cerrarlo: el modelo razonará \ - varios segundos antes de contestar. Arranca llama-server con \ + "the chat template opens <think> without closing it: the model will reason \ + for several seconds before answering. Start llama-server with \ --chat-template-file config/qwen35-no-think.jinja" ); } } - /// Pregunta al modelo por una imagen, en una petición aparte de la - /// conversación. + /// Asks the model about an image, in a request separate from the + /// conversation. /// - /// El servidor tiene cargado el proyector multimodal (`--mmproj`), así que - /// acepta partes `image_url` con la imagen en base64. Se hace fuera del - /// historial a propósito: una imagen ocupa cientos de tokens de contexto y - /// arrastrarla turno tras turno saldría carísimo para lo poco que aporta - /// una vez descrita. Lo que vuelve a la conversación es el texto. + /// The server has the multimodal projector loaded (`--mmproj`), so it + /// accepts `image_url` parts with the image in base64. It is kept out of the + /// history on purpose: an image takes hundreds of context tokens and dragging + /// it turn after turn would be very expensive for how little it adds once + /// described. What goes back into the conversation is the text. /// - /// Medido en esta máquina, el coste depende mucho del tamaño: 1,3 s a - /// 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720. + /// Measured on this machine, the cost depends heavily on size: 1.3 s at + /// 320x240, 2.9 s at 640x480 and 7.8 s at 1280x720. pub fn look(&self, image_jpeg: &[u8], question: &str, cancel: &Cancel) -> Result<String> { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(image_jpeg); @@ -127,9 +127,9 @@ impl LlmClient { body["model"] = json!(self.config.model); } - // En streaming aunque no se use el texto parcial: una petición de - // visión tarda segundos y sin flujo el socket puede quedarse callado - // hasta pasado el plazo de lectura. + // Streaming even though the partial text is unused: a vision request + // takes seconds and without a stream the socket may stay silent past the + // read deadline. let started = Instant::now(); let mut text = String::new(); let mut finished = false; @@ -172,11 +172,11 @@ impl LlmClient { target: "llm", bytes = image_jpeg.len(), ms = started.elapsed().as_millis(), - "visión" + "vision" ); let text = text.trim().to_string(); if text.is_empty() { - return Err(Error::Llm("el modelo no describió la imagen".into())); + return Err(Error::Llm("the model did not describe the image".into())); } Ok(text) } @@ -202,10 +202,10 @@ impl LlmClient { body } - /// Envía la conversación y entrega la respuesta a trozos. + /// Sends the conversation and delivers the answer in pieces. /// - /// `on_delta` devuelve `false` para cortar —lo que hace el orquestador - /// cuando el usuario interrumpe—; `cancel` hace lo mismo desde otro hilo. + /// `on_delta` returns `false` to stop (what the orchestrator does when the + /// user interrupts); `cancel` does the same from another thread. pub fn stream( &self, chat: &Conversation, @@ -253,8 +253,8 @@ impl LlmClient { } } } - // Un `finish_reason` cierra la respuesta aunque el servidor no - // llegue a mandar el [DONE] (pasa al cortar la conexión). + // A `finish_reason` closes the answer even if the server never + // sends [DONE] (it happens when the connection is cut). if choice.get("finish_reason").is_some_and(|r| !r.is_null()) { finished = true; return false; @@ -264,8 +264,8 @@ impl LlmClient { match result { Ok(()) => {} - // Cortar a propósito no es un fallo: `on_delta` devolvió false o - // se activó la cancelación. + // Stopping on purpose is not a failure: `on_delta` returned false or + // cancellation was triggered. Err(Error::Cancelled) if finished || outcome.stopped_early || cancel.is_cancelled() => { } Err(err) => return Err(err), @@ -281,11 +281,11 @@ impl LlmClient { } } -/// Reensambla las llamadas a herramientas, que llegan repartidas en fragmentos. +/// Reassembles tool calls, which arrive split across fragments. /// -/// El streaming manda el nombre en un fragmento y los argumentos en varios -/// más, identificados sólo por su `index`; hasta que no termina el flujo no -/// hay una llamada completa que ejecutar. +/// Streaming sends the name in one fragment and the arguments in several +/// more, identified only by their `index`; until the stream ends there is +/// no complete call to run. #[derive(Default)] struct ToolCallAssembler { partial: Vec<PartialCall>, @@ -357,7 +357,7 @@ mod tests { } #[test] - fn los_argumentos_troceados_se_reensamblan() { + fn chunked_arguments_are_reassembled() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -375,7 +375,7 @@ mod tests { } #[test] - fn se_reensamblan_varias_llamadas_en_paralelo() { + fn several_parallel_calls_are_reassembled() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -392,7 +392,7 @@ mod tests { } #[test] - fn sin_argumentos_se_asume_el_objeto_vacio() { + fn no_arguments_means_an_empty_object() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -402,24 +402,20 @@ mod tests { } #[test] - fn un_hueco_sin_nombre_no_produce_una_llamada() { - // El servidor puede numerar el índice 1 sin haber mandado nunca el 0. + fn slot_without_a_name_produces_no_call() { + // The server may number index 1 without ever having sent 0. let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, r#"[{"index":1,"id":"b","function":{"name":"dos","arguments":"{}"}}]"#, ); let calls = assembler.finish(); - assert_eq!( - calls.len(), - 1, - "el hueco no debe convertirse en una llamada vacía" - ); + assert_eq!(calls.len(), 1, "the gap must not become an empty call"); assert_eq!(calls[0].name, "dos"); } #[test] - fn sin_id_se_genera_uno_estable() { + fn without_an_id_a_stable_one_is_generated() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, diff --git a/crates/asist-llm/src/lib.rs b/crates/asist-llm/src/lib.rs index 4c860df..d4dc4e9 100644 --- a/crates/asist-llm/src/lib.rs +++ b/crates/asist-llm/src/lib.rs @@ -1,9 +1,9 @@ -//! Cliente de llama-server. +//! llama-server client. //! -//! Habla la API de chat de OpenAI en modo streaming, porque en un asistente de -//! voz la respuesta no se espera: se trocea en frases y se va sintetizando -//! mientras el modelo sigue escribiendo. Esperar a la respuesta entera sumaría -//! el tiempo del modelo al del sintetizador en lugar de solaparlos. +//! It speaks the OpenAI chat API in streaming mode, because in a voice +//! assistant the answer is not waited for: it is split into sentences and +//! synthesized while the model keeps writing. Waiting for the whole answer +//! would add the model time to the synthesizer time instead of overlapping them. pub mod chat; pub mod client; |