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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user