aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-llm/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-llm/src')
-rw-r--r--crates/asist-llm/src/chat.rs46
-rw-r--r--crates/asist-llm/src/client.rs98
-rw-r--r--crates/asist-llm/src/lib.rs10
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;