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