feat: Add analyze module for LLM integration

This commit introduces the `analyze` module, which orchestrates
communication with Large Language Models (LLMs) for text summarization
and analysis.

Key components include:
- `llm.rs`: Contains the `LlmError` enum and the `chat_once` function
  for interacting with OpenAI-compatible LLM APIs.
- `prompt.rs`: Defines the system prompt and logic for rendering user
  content from analysis inputs.
- `recovery.rs`: Implements logic to scan for and re-enqueue pending
  analysis jobs upon server startup.
- `worker.rs`: The core worker that consumes analysis jobs, processes
  them with the LLM, and writes the output.
- `mod.rs`: Defines `AnalyzeJob` and associated channel types for
  inter-component communication.

This module enables the server to process dictated recordings, summarize
them using an LLM, and store the results.
This commit is contained in:
2026-04-15 19:14:28 +02:00
parent 5435b60b80
commit e2a05a108a
6 changed files with 498 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tracing::debug;
/// Error variants returned by the LLM client (OpenAI-compatible Chat
/// Completions API — Ionos, Azure OpenAI, OpenAI proper, Together.ai, etc.).
///
/// **Security:** `Display` and `Debug` MUST NOT contain the HTTP response body.
/// Provider error bodies can echo back details that include the API key or
/// other sensitive context (e.g. `{"error": "invalid api_key sk-…"}`). Only
/// the status and a short category string are safe to log.
#[derive(Debug)]
pub enum LlmError {
/// Transport-level failure (DNS, TLS, connect, timeout). `reqwest::Error`
/// is intentionally included — its `Display` only reveals URL/method,
/// not request headers, so the API key stays out of logs.
Http(reqwest::Error),
/// Provider returned a non-2xx status. Body is deliberately discarded.
Status { status: u16 },
/// Response was 2xx but the expected `choices[0].message.content`
/// shape was absent or empty.
Parse(&'static str),
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Http(e) => write!(f, "llm http error: {e}"),
Self::Status { status } => write!(f, "llm returned status {status}"),
Self::Parse(msg) => write!(f, "llm response parse error: {msg}"),
}
}
}
impl std::error::Error for LlmError {}
/// Grouped provider configuration. Passed by reference to `chat_once`.
pub struct LlmSettings<'a> {
pub base_url: &'a str,
pub api_key: &'a str,
pub model: &'a str,
pub temperature: f32,
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
temperature: f32,
stream: bool,
messages: Vec<Message<'a>>,
}
#[derive(Serialize)]
struct Message<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
content: Option<String>,
}
/// Single-shot chat completion against an OpenAI-compatible endpoint.
/// The `api_key` in `settings` is used as a Bearer token; do not log it.
pub async fn chat_once(
client: &reqwest::Client,
settings: &LlmSettings<'_>,
system_prompt: &str,
user_content: &str,
timeout: Duration,
) -> Result<String, LlmError> {
let url = format!("{}/v1/chat/completions", settings.base_url.trim_end_matches('/'));
debug!(%url, model = settings.model, "calling llm chat completions");
let body = ChatRequest {
model: settings.model,
temperature: settings.temperature,
stream: false,
messages: vec![
Message { role: "system", content: system_prompt },
Message { role: "user", content: user_content },
],
};
let response = client
.post(&url)
.bearer_auth(settings.api_key)
.timeout(timeout)
.json(&body)
.send()
.await
.map_err(LlmError::Http)?;
let status = response.status();
if !status.is_success() {
// Body deliberately dropped — see LlmError docstring.
drop(response);
return Err(LlmError::Status { status: status.as_u16() });
}
let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?;
let content = parsed
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.ok_or(LlmError::Parse("missing choices[0].message.content"))?;
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
return Err(LlmError::Parse("empty content after trim"));
}
Ok(trimmed)
}