Files
doctate/server/src/analyze/worker.rs
T
Brummel 9e8db7518d Refactor document handling to JSON envelope
The `document.md` file has been replaced by `document.json`. This new
format acts as a JSON envelope, containing the original markdown content
along with metadata such as `covered_mtime`, `written_at`, and
`backend_id`.

This change addresses a bug where new recordings arriving during an LLM
analysis call could be ignored. The `covered_mtime` field in the new
JSON envelope ensures that the document's metadata accurately reflects
the state of recordings at the time of analysis, preventing incorrect
"analysis current" states.

Additionally, the `analysis_input.json` file is now removed upon
successful analysis, and a failure marker is used to indicate analysis
failures. This ensures that a case is re-analyzed if necessary.

The `analysis_idle_threshold_secs` configuration option has been
introduced to control the delay before a case in the `Required` state is
automatically promoted to `Idle` and enqueued for LLM analysis.
2026-05-05 14:01:43 +02:00

262 lines
9.7 KiB
Rust

use std::path::Path;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tracing::{error, info, warn};
use super::auto_trigger::FailureDetails;
use super::backend::{default_backend, find_backend};
use super::{
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, auto_trigger, llm, prompt,
};
use crate::events::{self, CaseEventKind, EventSender};
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, WorkerBusy};
/// 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,
client: reqwest::Client,
worker_busy: WorkerBusy,
vocab: Arc<Gazetteer>,
events_tx: EventSender,
) {
info!(vocab_entries = vocab.len(), "Analyze worker started");
while let Some(job) = rx.recv().await {
let _guard = BusyGuard::new(worker_busy.clone());
process(&job.case_dir, &client, &vocab, &events_tx).await;
}
warn!("Analyze worker stopped (channel closed)");
}
async fn process(
case_dir: &Path,
client: &reqwest::Client,
vocab: &Gazetteer,
events_tx: &EventSender,
) {
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
let document_path = case_dir.join(DOCUMENT_FILE);
// 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;
}
};
// Silent-only case: no usable transcripts. Skip LLM, write a stub.
if input.recordings.is_empty() {
let stub_md = "_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
let doc = super::Document {
covered_mtime: input.last_recording_mtime.clone(),
written_at: doctate_common::timestamp::now_rfc3339(),
backend_id: String::new(),
content_md: stub_md.to_owned(),
};
let stub_bytes =
serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes");
if let Err(e) = write_atomic(&document_path, &stub_bytes).await {
error!(path = %document_path.display(), error = %e, "writing stub document failed");
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
format!("stub write: {e}"),
)
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
if let Err(e) = tokio::fs::remove_file(&input_path).await {
warn!(path = %input_path.display(), error = %e, "removing analysis input failed");
}
auto_trigger::remove_failure_marker(case_dir).await;
info!(case = %case_dir.display(), "analysis done (stub, no recordings)");
events::emit(
events_tx,
events::user_slug_of(case_dir),
events::case_id_of(case_dir),
CaseEventKind::DocumentReady,
);
return;
}
let user_content = prompt::render_prompt(&input);
let total_chars = user_content.chars().count();
let backend = find_backend(&input.backend_id).unwrap_or_else(default_backend);
info!(
case = %case_dir.display(),
recording_count = input.recordings.len(),
total_chars,
backend_id = %backend.id,
model = %backend.model_id,
"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 document = match llm::chat_once(client, backend, &user_content).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(), backend_id = %backend.id, error = %e, "llm call failed");
let mut details = FailureDetails::new().with_model_id(&backend.model_id);
if let Some(d) = e.empty_content_details() {
details = details.with_max_completion_tokens(d.max_completion_tokens);
if let Some(fr) = d.finish_reason {
details = details.with_finish_reason(fr);
}
if let Some(v) = d.prompt_tokens {
details = details.with_prompt_tokens(v);
}
if let Some(v) = d.completion_tokens {
details = details.with_completion_tokens(v);
}
if let Some(v) = d.reasoning_tokens {
details = details.with_reasoning_tokens(v);
}
}
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
format!("llm: {e}"),
)
.with_backend_id(&backend.id)
.with_details(details)
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
};
// Post-AI terminology normalization: enforce vocab-canonical forms
// over the LLM's training priors (e.g. rewrite anglicized INNs like
// `Amiodarone` back to `Amiodaron`).
let document = vocab.replace(&document);
// Silent-deletion guard: schema-aware backends can return formally valid
// but empty envelopes (`{"document": ""}`) when the prompt is incompatible
// with the format constraint. Without this guard the worker would persist
// a Document with empty `content_md`, drop the failure marker, and the
// user would see a blank note with no diagnostic. Observed on Llama 3.1
// 405B FP8 (test T4, 2026-05-03) and previously on Mistral 24B.
if document.trim().is_empty() {
error!(
case = %case_dir.display(),
backend_id = %backend.id,
"llm returned empty document — silent deletion guard triggered"
);
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
"llm returned empty document",
)
.with_backend_id(&backend.id)
.with_details(FailureDetails::new().with_model_id(&backend.model_id))
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
let doc = super::Document {
covered_mtime: input.last_recording_mtime.clone(),
written_at: doctate_common::timestamp::now_rfc3339(),
backend_id: backend.id.clone(),
content_md: document.clone(),
};
let doc_bytes =
serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes");
if let Err(e) = write_atomic(&document_path, &doc_bytes).await {
error!(path = %document_path.display(), error = %e, "writing document failed");
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
format!("write document: {e}"),
)
.with_backend_id(&backend.id)
.with_details(FailureDetails::new().with_model_id(&backend.model_id))
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
// Job done — drop the queue marker so the live "analyzing" flag
// becomes accurate immediately and recovery on next boot does not
// re-enqueue this version.
if let Err(e) = tokio::fs::remove_file(&input_path).await {
warn!(path = %input_path.display(), error = %e, "removing analysis input failed");
}
auto_trigger::remove_failure_marker(case_dir).await;
info!(
case = %case_dir.display(),
bytes = document.len(),
"analysis done"
);
events::emit(
events_tx,
events::user_slug_of(case_dir),
events::case_id_of(case_dir),
CaseEventKind::DocumentReady,
);
}
/// Notify subscribed browsers that an analysis attempt failed. Called
/// after the failure marker is on disk so a debounced reload sees the
/// fresh banner immediately instead of leaving the page on the
/// "Wird analysiert …" placeholder.
fn emit_analysis_failed(events_tx: &EventSender, case_dir: &Path) {
events::emit(
events_tx,
events::user_slug_of(case_dir),
events::case_id_of(case_dir),
CaseEventKind::AnalysisFailed,
);
}
/// 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(())
}