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