feat(analyze): structured LLM failure diagnostics + SSE reload on failure

Failure markers now carry finish_reason and usage tokens (prompt /
completion / reasoning) pulled from the chat-completions response, so
the case-page "Technische Details" block can show why an analysis
truncated — e.g. gpt-oss-120b hitting max_completion_tokens with the
chain-of-thought before producing any visible answer.

The worker also emits a new CaseEventKind::AnalysisFailed after writing
the marker, so subscribed browsers reload immediately instead of leaving
the page on the "Wird analysiert …" placeholder.
This commit is contained in:
2026-05-05 11:01:29 +02:00
parent a142725a8a
commit 44e93f08ee
8 changed files with 574 additions and 59 deletions
+51 -11
View File
@@ -4,6 +4,7 @@ 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,
@@ -66,13 +67,14 @@ async fn process(
let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
if let Err(e) = write_atomic(&document_path, stub).await {
error!(path = %document_path.display(), error = %e, "writing stub document failed");
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
&format!("stub write: {e}"),
None,
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 {
@@ -111,13 +113,32 @@ async fn process(
// 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 _ = auto_trigger::write_failure_marker(
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}"),
Some(&backend.id),
format!("llm: {e}"),
)
.with_backend_id(&backend.id)
.with_details(details)
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
};
@@ -139,25 +160,31 @@ async fn process(
backend_id = %backend.id,
"llm returned empty document — silent deletion guard triggered"
);
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
"llm returned empty document",
Some(&backend.id),
)
.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;
}
if let Err(e) = write_atomic(&document_path, document.as_bytes()).await {
error!(path = %document_path.display(), error = %e, "writing document failed");
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
&format!("write document: {e}"),
Some(&backend.id),
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;
}
@@ -183,6 +210,19 @@ async fn process(
);
}
/// 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.