diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index 91192ac..a514276 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -39,6 +39,75 @@ pub struct FailureMarker { /// the field deserialize to `None`. #[serde(default)] pub backend_id: Option, + /// Structured diagnostics — populated for LLM failures where the + /// provider response carries `finish_reason`/`usage`. Old markers + /// without the field deserialize to `None` (no migration needed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Structured failure diagnostics. All fields optional so the same struct +/// works for empty-content failures (everything populated) and for HTTP / +/// transport errors (only `model_id` known). +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct FailureDetails { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, +} + +impl FailureDetails { + pub fn new() -> Self { + Self::default() + } + + pub fn with_model_id(mut self, v: impl Into) -> Self { + self.model_id = Some(v.into()); + self + } + + pub fn with_finish_reason(mut self, v: impl Into) -> Self { + self.finish_reason = Some(v.into()); + self + } + + pub fn with_prompt_tokens(mut self, v: u32) -> Self { + self.prompt_tokens = Some(v); + self + } + + pub fn with_completion_tokens(mut self, v: u32) -> Self { + self.completion_tokens = Some(v); + self + } + + pub fn with_reasoning_tokens(mut self, v: u32) -> Self { + self.reasoning_tokens = Some(v); + self + } + + pub fn with_max_completion_tokens(mut self, v: u32) -> Self { + self.max_completion_tokens = Some(v); + self + } + + fn is_empty(&self) -> bool { + self.model_id.is_none() + && self.finish_reason.is_none() + && self.prompt_tokens.is_none() + && self.completion_tokens.is_none() + && self.reasoning_tokens.is_none() + && self.max_completion_tokens.is_none() + } } /// Outcome of the pre-condition check. `Skip` carries a static string so @@ -181,31 +250,74 @@ pub async fn try_enqueue_all_for_user( } } -/// Persist a failure marker atomically. Called by the worker after a -/// terminal failure. `last_recording_mtime` must equal the value read -/// from the `AnalysisInput` so `evaluate_case` can later detect an -/// unchanged input. -pub async fn write_failure_marker( - case_dir: &Path, - last_recording_mtime: &str, - reason: &str, - backend_id: Option<&str>, -) -> std::io::Result<()> { - let marker = FailureMarker { - last_recording_mtime: last_recording_mtime.to_owned(), - reason: reason.to_owned(), - failed_at: doctate_common::now_rfc3339(), - backend_id: backend_id.map(|s| s.to_owned()), - }; - let bytes = serde_json::to_vec_pretty(&marker) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let path = case_dir.join(FAILURE_MARKER_FILE); - let tmp = path.with_extension("json.tmp"); - let mut f = tokio::fs::File::create(&tmp).await?; - f.write_all(&bytes).await?; - f.sync_all().await?; - drop(f); - tokio::fs::rename(&tmp, &path).await +/// Builder for a failure marker write. Use `failure_marker(...)` to start. +/// +/// Fluent so the worker's call sites stay readable when carrying many +/// optional diagnostic fields (`finish_reason`, `usage`, `model_id`). +/// Consume the builder with `.write().await`. +pub struct FailureMarkerWriter<'a> { + case_dir: &'a Path, + last_recording_mtime: &'a str, + reason: String, + backend_id: Option, + details: FailureDetails, +} + +impl<'a> FailureMarkerWriter<'a> { + pub fn with_backend_id(mut self, id: impl Into) -> Self { + self.backend_id = Some(id.into()); + self + } + + pub fn with_details(mut self, details: FailureDetails) -> Self { + self.details = details; + self + } + + /// Persist atomically: write to `.tmp`, fsync, rename. + /// `last_recording_mtime` must equal the value read from the + /// `AnalysisInput` so `evaluate_case` can later detect an unchanged + /// input. + pub async fn write(self) -> std::io::Result<()> { + let marker = FailureMarker { + last_recording_mtime: self.last_recording_mtime.to_owned(), + reason: self.reason, + failed_at: doctate_common::now_rfc3339(), + backend_id: self.backend_id, + details: if self.details.is_empty() { + None + } else { + Some(self.details) + }, + }; + let bytes = serde_json::to_vec_pretty(&marker) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let path = self.case_dir.join(FAILURE_MARKER_FILE); + let tmp = path.with_extension("json.tmp"); + let mut f = tokio::fs::File::create(&tmp).await?; + f.write_all(&bytes).await?; + f.sync_all().await?; + drop(f); + tokio::fs::rename(&tmp, &path).await + } +} + +/// Start a failure-marker write. The minimum a caller must supply is the +/// case directory, the input mtime (so `evaluate_case` can later detect +/// "input changed since failure"), and a human-readable reason. Backend +/// id and structured details are added fluently. +pub fn failure_marker<'a>( + case_dir: &'a Path, + last_recording_mtime: &'a str, + reason: impl Into, +) -> FailureMarkerWriter<'a> { + FailureMarkerWriter { + case_dir, + last_recording_mtime, + reason: reason.into(), + backend_id: None, + details: FailureDetails::default(), + } } /// Best-effort delete. Called by the worker after a successful run so @@ -444,6 +556,7 @@ mod tests { reason: "test".into(), failed_at: "2026-01-01T10-05-00Z".into(), backend_id: None, + details: None, }; fs::write( dir.path().join(FAILURE_MARKER_FILE), @@ -474,6 +587,7 @@ mod tests { reason: "test".into(), failed_at: "2025-12-31T23-00-00Z".into(), backend_id: None, + details: None, }; fs::write( dir.path().join(FAILURE_MARKER_FILE), diff --git a/server/src/analyze/llm.rs b/server/src/analyze/llm.rs index cf22163..330c2aa 100644 --- a/server/src/analyze/llm.rs +++ b/server/src/analyze/llm.rs @@ -3,7 +3,7 @@ use std::time::Duration; use doctate_common::join_url; use serde::{Deserialize, Serialize}; use serde_json::json; -use tracing::debug; +use tracing::{debug, warn}; use super::backend::LlmBackend; @@ -13,12 +13,61 @@ use super::backend::LlmBackend; /// **Security:** `Display` and `Debug` MUST NOT contain the HTTP response body. /// Provider error bodies can echo back details that include the API key or /// other sensitive context (e.g. `{"error": "invalid api_key sk-…"}`). Only -/// the status and a short category string are safe to log. +/// the status and structured numeric/enum fields (token counts, finish_reason) +/// are safe to log. #[derive(Debug)] pub enum LlmError { Http(reqwest::Error), - Status { status: u16 }, + Status { + status: u16, + }, Parse(&'static str), + /// Response was 200 OK but `choices[0].message.content` was null/missing. + /// The structured fields below come from the same response and let the + /// caller distinguish "model truncated at token cap" (`finish_reason=length`) + /// from "content filter rejected" or other null-content modes. + EmptyContent { + finish_reason: Option, + prompt_tokens: Option, + completion_tokens: Option, + reasoning_tokens: Option, + max_completion_tokens: u32, + }, +} + +/// Diagnostic detail extracted from an `EmptyContent` error. The worker +/// copies these fields into the persisted `FailureDetails` so the UI's +/// "Technische Details" block can show finish_reason and token counts. +#[derive(Debug, Clone)] +pub struct EmptyContentDetails<'a> { + pub finish_reason: Option<&'a str>, + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub reasoning_tokens: Option, + pub max_completion_tokens: u32, +} + +impl LlmError { + /// `Some` iff this error is `EmptyContent`, exposing the diagnostic + /// fields the worker needs to populate the failure-marker UI. + pub fn empty_content_details(&self) -> Option> { + match self { + Self::EmptyContent { + finish_reason, + prompt_tokens, + completion_tokens, + reasoning_tokens, + max_completion_tokens, + } => Some(EmptyContentDetails { + finish_reason: finish_reason.as_deref(), + prompt_tokens: *prompt_tokens, + completion_tokens: *completion_tokens, + reasoning_tokens: *reasoning_tokens, + max_completion_tokens: *max_completion_tokens, + }), + _ => None, + } + } } impl std::fmt::Display for LlmError { @@ -27,6 +76,30 @@ impl std::fmt::Display for LlmError { Self::Http(e) => write!(f, "llm http error: {e}"), Self::Status { status } => write!(f, "llm returned status {status}"), Self::Parse(msg) => write!(f, "llm response parse error: {msg}"), + Self::EmptyContent { + finish_reason, + prompt_tokens, + completion_tokens, + reasoning_tokens, + max_completion_tokens, + } => { + let fr = finish_reason.as_deref().unwrap_or("unknown"); + write!( + f, + "llm returned no content (finish_reason={fr}, completion_tokens={ct}/{cap}", + ct = completion_tokens + .map(|n| n.to_string()) + .unwrap_or_else(|| "?".into()), + cap = max_completion_tokens, + )?; + if let Some(rt) = reasoning_tokens { + write!(f, ", reasoning_tokens={rt}")?; + } + if let Some(pt) = prompt_tokens { + write!(f, ", prompt_tokens={pt}")?; + } + write!(f, ")") + } } } } @@ -57,11 +130,15 @@ struct Message<'a> { #[derive(Deserialize)] struct ChatResponse { choices: Vec, + #[serde(default)] + usage: Option, } #[derive(Deserialize)] struct Choice { message: ResponseMessage, + #[serde(default)] + finish_reason: Option, } #[derive(Deserialize)] @@ -69,6 +146,22 @@ struct ResponseMessage { content: Option, } +#[derive(Deserialize, Default)] +struct Usage { + #[serde(default)] + prompt_tokens: Option, + #[serde(default)] + completion_tokens: Option, + #[serde(default)] + completion_tokens_details: Option, +} + +#[derive(Deserialize, Default)] +struct CompletionTokensDetails { + #[serde(default)] + reasoning_tokens: Option, +} + #[derive(Deserialize)] struct DocumentEnvelope { document: String, @@ -157,12 +250,48 @@ pub async fn chat_once( } let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?; - let content = parsed - .choices - .into_iter() - .next() - .and_then(|c| c.message.content) - .ok_or(LlmError::Parse("missing choices[0].message.content"))?; + let usage = parsed.usage; + let first = parsed.choices.into_iter().next(); + let (finish_reason, content) = match first { + Some(c) => (c.finish_reason, c.message.content), + None => (None, None), + }; + + let content = match content { + Some(s) => s, + None => { + // Pull diagnostic fields out of the response so the caller can + // surface them in logs and the failure-marker UI. With + // reasoning models (gpt-oss, o1) the most common cause is + // `finish_reason=length`: the CoT consumed the budget before + // the answer started. Without these fields the failure used + // to read "missing choices[0].message.content" — true but + // useless. + let prompt_tokens = usage.as_ref().and_then(|u| u.prompt_tokens); + let completion_tokens = usage.as_ref().and_then(|u| u.completion_tokens); + let reasoning_tokens = usage + .as_ref() + .and_then(|u| u.completion_tokens_details.as_ref()) + .and_then(|d| d.reasoning_tokens); + warn!( + model = %backend.model_id, + backend = %backend.id, + finish_reason = ?finish_reason, + prompt_tokens = ?prompt_tokens, + completion_tokens = ?completion_tokens, + reasoning_tokens = ?reasoning_tokens, + max_completion_tokens = backend.max_completion_tokens, + "llm returned no content" + ); + return Err(LlmError::EmptyContent { + finish_reason, + prompt_tokens, + completion_tokens, + reasoning_tokens, + max_completion_tokens: backend.max_completion_tokens, + }); + } + }; let text = if backend.use_json_schema { let env: DocumentEnvelope = serde_json::from_str(&content) diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 7ba2166..2809ada 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -112,6 +112,7 @@ mod tests { reason: "test".into(), failed_at: "2026-05-03T00:00:00Z".into(), backend_id: None, + details: None, }; fs::write( case_dir.join(FAILURE_MARKER_FILE), diff --git a/server/src/analyze/worker.rs b/server/src/analyze/worker.rs index b31a13f..0f53545 100644 --- a/server/src/analyze/worker.rs +++ b/server/src/analyze/worker.rs @@ -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 `.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. diff --git a/server/src/events.rs b/server/src/events.rs index 31a18a3..372cedb 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -30,6 +30,7 @@ pub enum CaseEventKind { OnelinerUpdated, AnalysisQueued, DocumentReady, + AnalysisFailed, CaseClosed, CaseReopened, CasePurged, diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index fdfd530..e24a284 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -399,6 +399,21 @@ struct FailureBanner { /// the marker's `backend_id`). Empty if the marker predates the /// per-backend tracking or the id is no longer in the catalog. backend_label: String, + /// Provider-side model identifier (e.g. `openai/gpt-oss-120b`). + /// Empty when the marker predates structured details. + model_id: String, + /// `finish_reason` from the chat-completions response. Empty when not + /// applicable (HTTP/transport errors don't produce one). Typical + /// values: `length` (token cap hit), `content_filter`, `stop`. + finish_reason: String, + /// Token counts from the response's `usage` block. `None` when the + /// provider did not return usage or the failure was pre-response. + prompt_tokens: Option, + completion_tokens: Option, + reasoning_tokens: Option, + /// `max_completion_tokens` value the call was made with. Pairs with + /// `completion_tokens` to show "X / Y" in the UI. + max_completion_tokens: Option, } /// Flags derived from filesystem state + config. @@ -438,18 +453,26 @@ async fn compute_flags( // analysis would otherwise occupy the same UI slot. Skips the FS read on // the success hot-path (document already on disk). let analysis_failed = if !has_document && !analyzing { - auto_trigger::read_failure_marker(case_dir) - .await - .map(|m| FailureBanner { + auto_trigger::read_failure_marker(case_dir).await.map(|m| { + let backend_label = m + .backend_id + .as_deref() + .and_then(crate::analyze::backend::find_backend) + .map(|b| b.label.clone()) + .unwrap_or_default(); + let details = m.details.unwrap_or_default(); + FailureBanner { reason: m.reason, failed_at: m.failed_at, - backend_label: m - .backend_id - .as_deref() - .and_then(crate::analyze::backend::find_backend) - .map(|b| b.label.clone()) - .unwrap_or_default(), - }) + backend_label, + model_id: details.model_id.unwrap_or_default(), + finish_reason: details.finish_reason.unwrap_or_default(), + prompt_tokens: details.prompt_tokens, + completion_tokens: details.completion_tokens, + reasoning_tokens: details.reasoning_tokens, + max_completion_tokens: details.max_completion_tokens, + } + }) } else { None }; diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 3275ea2..3ded4e4 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -201,6 +201,26 @@ font-size: 0.85em; color: #4a1014; } + .failure-banner dl.failure-details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2em 0.8em; + margin: 0.4em 0 0.6em 0; + padding: 0.5em 0.8em; + background: #fff; + border: 1px solid #f5c6cb; + border-radius: 3px; + font-size: 0.85em; + color: #4a1014; + } + .failure-banner dl.failure-details dt { + font-weight: 600; + color: #6b1f25; + } + .failure-banner dl.failure-details dd { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } .failure-banner form { margin: 0.8em 0 0 0; } @@ -609,10 +629,7 @@ {% when None %} {% match analysis_failed %} {% when Some with (failure) %}