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
+127
View File
@@ -0,0 +1,127 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tracing::{error, info, warn};
use super::{llm, prompt, AnalysisInput, AnalyzeReceiver};
use crate::config::Config;
/// Consume analyze jobs sequentially. The external LLM tolerates parallelism,
/// but sequential processing keeps the architecture simple and shields the
/// provider from bursts. Trivial to lift later via `tokio::spawn(process(..))`.
pub async fn run(mut rx: AnalyzeReceiver, config: Arc<Config>, client: reqwest::Client) {
info!("Analyze worker started");
let timeout = Duration::from_secs(config.llm_timeout_seconds);
while let Some(job) = rx.recv().await {
process(&job.case_dir, job.version, &config, &client, timeout).await;
}
warn!("Analyze worker stopped (channel closed)");
}
async fn process(
case_dir: &Path,
version: u32,
config: &Config,
client: &reqwest::Client,
timeout: Duration,
) {
let input_path = case_dir.join(format!("analysis_input_v{version}.json"));
let document_path = case_dir.join(format!("document_v{version}.md"));
// If the document already exists, the job is obsolete (e.g. recovery
// re-enqueued a finished case during a race). Skip silently.
if document_path.exists() {
return;
}
let input: AnalysisInput = match tokio::fs::read(&input_path).await {
Ok(bytes) => match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
error!(path = %input_path.display(), error = %e, "analysis input parse failed");
return;
}
},
Err(e) => {
error!(path = %input_path.display(), error = %e, "analysis input read failed");
return;
}
};
let user_content = prompt::render_prompt(&input);
if user_content.is_empty() {
error!(case = %case_dir.display(), "analysis input has no usable recordings");
return;
}
let total_chars = user_content.chars().count();
info!(
case = %case_dir.display(),
version,
recording_count = input.recordings.len(),
total_chars,
"sending to llm"
);
// NOTE: If the server crashes between a successful LLM response and the
// document write below, the recovery scan will re-enqueue this job and
// the call will be made again. Accepted cost — rare event, small
// per-call price. Response-caching in a `.tmp` file would prevent it.
let settings = llm::LlmSettings {
base_url: &config.llm_url,
api_key: &config.llm_api_key,
model: &config.llm_model,
temperature: config.llm_temperature,
};
let document = match llm::chat_once(
client,
&settings,
prompt::SYSTEM_PROMPT,
&user_content,
timeout,
)
.await
{
Ok(text) => text,
Err(e) => {
// Do not log the response body — LlmError::Display is already
// redacted, but reinforce the rule here for future readers.
error!(case = %case_dir.display(), version, error = %e, "llm call failed");
return;
}
};
if let Err(e) = write_atomic(&document_path, document.as_bytes()).await {
error!(path = %document_path.display(), error = %e, "writing document failed");
return;
}
info!(
case = %case_dir.display(),
version,
bytes = document.len(),
"analysis done"
);
}
/// Write `bytes` to `path` atomically: write to `<path>.tmp`, fsync, rename.
/// A crash between write and rename leaves no half-written target file — the
/// state machine simply sees the job as still pending and retries.
async fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension({
let mut ext = path.extension().map(|s| s.to_os_string()).unwrap_or_default();
ext.push(".tmp");
ext
});
let mut file = tokio::fs::File::create(&tmp).await?;
file.write_all(bytes).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&tmp, path).await?;
Ok(())
}