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:
@@ -39,6 +39,75 @@ pub struct FailureMarker {
|
||||
/// the field deserialize to `None`.
|
||||
#[serde(default)]
|
||||
pub backend_id: Option<String>,
|
||||
/// 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<FailureDetails>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl FailureDetails {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_model_id(mut self, v: impl Into<String>) -> Self {
|
||||
self.model_id = Some(v.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_finish_reason(mut self, v: impl Into<String>) -> 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,25 +250,49 @@ 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<()> {
|
||||
/// 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<String>,
|
||||
details: FailureDetails,
|
||||
}
|
||||
|
||||
impl<'a> FailureMarkerWriter<'a> {
|
||||
pub fn with_backend_id(mut self, id: impl Into<String>) -> 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 `<path>.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: last_recording_mtime.to_owned(),
|
||||
reason: reason.to_owned(),
|
||||
last_recording_mtime: self.last_recording_mtime.to_owned(),
|
||||
reason: self.reason,
|
||||
failed_at: doctate_common::now_rfc3339(),
|
||||
backend_id: backend_id.map(|s| s.to_owned()),
|
||||
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 = case_dir.join(FAILURE_MARKER_FILE);
|
||||
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?;
|
||||
@@ -207,6 +300,25 @@ pub async fn write_failure_marker(
|
||||
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<String>,
|
||||
) -> 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
|
||||
/// the next `evaluate_case` does not mistake a resolved failure for a
|
||||
@@ -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),
|
||||
|
||||
+138
-9
@@ -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<String>,
|
||||
prompt_tokens: Option<u32>,
|
||||
completion_tokens: Option<u32>,
|
||||
reasoning_tokens: Option<u32>,
|
||||
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<u32>,
|
||||
pub completion_tokens: Option<u32>,
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
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<EmptyContentDetails<'_>> {
|
||||
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<Choice>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Choice {
|
||||
message: ResponseMessage,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -69,6 +146,22 @@ struct ResponseMessage {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Usage {
|
||||
#[serde(default)]
|
||||
prompt_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
completion_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CompletionTokensDetails {
|
||||
#[serde(default)]
|
||||
reasoning_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,6 +30,7 @@ pub enum CaseEventKind {
|
||||
OnelinerUpdated,
|
||||
AnalysisQueued,
|
||||
DocumentReady,
|
||||
AnalysisFailed,
|
||||
CaseClosed,
|
||||
CaseReopened,
|
||||
CasePurged,
|
||||
|
||||
@@ -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<u32>,
|
||||
completion_tokens: Option<u32>,
|
||||
reasoning_tokens: Option<u32>,
|
||||
/// `max_completion_tokens` value the call was made with. Pairs with
|
||||
/// `completion_tokens` to show "X / Y" in the UI.
|
||||
max_completion_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// Flags derived from filesystem state + config.
|
||||
@@ -438,17 +453,25 @@ 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 {
|
||||
reason: m.reason,
|
||||
failed_at: m.failed_at,
|
||||
backend_label: m
|
||||
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(),
|
||||
.unwrap_or_default();
|
||||
let details = m.details.unwrap_or_default();
|
||||
FailureBanner {
|
||||
reason: m.reason,
|
||||
failed_at: m.failed_at,
|
||||
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
|
||||
|
||||
@@ -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 @@
|
||||
</script>
|
||||
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
|
||||
<div class="failure-banner" role="alert">
|
||||
<strong>
|
||||
Analyse{% if !failure.backend_label.is_empty() %} mit {{ failure.backend_label }}{% endif %} fehlgeschlagen.
|
||||
</strong>
|
||||
Bitte erneut versuchen.
|
||||
<strong>Analyse fehlgeschlagen.</strong>
|
||||
<span class="failure-time"
|
||||
>Zuletzt versucht:
|
||||
<time datetime="{{ failure.failed_at }}"
|
||||
@@ -621,6 +638,34 @@
|
||||
>
|
||||
<details>
|
||||
<summary>Technische Details</summary>
|
||||
<dl class="failure-details">
|
||||
{% if !failure.backend_label.is_empty() %}
|
||||
<dt>Backend</dt>
|
||||
<dd>{{ failure.backend_label }}</dd>
|
||||
{% endif %}
|
||||
{% if !failure.model_id.is_empty() %}
|
||||
<dt>Modell</dt>
|
||||
<dd><code>{{ failure.model_id }}</code></dd>
|
||||
{% endif %}
|
||||
{% if !failure.finish_reason.is_empty() %}
|
||||
<dt>finish_reason</dt>
|
||||
<dd><code>{{ failure.finish_reason }}</code></dd>
|
||||
{% endif %}
|
||||
{% match failure.completion_tokens %}{% when Some with (ct) %}
|
||||
<dt>Completion-Tokens</dt>
|
||||
<dd>
|
||||
{{ ct }}{% match failure.max_completion_tokens %}{% when Some with (cap) %} / {{ cap }}{% when None %}{% endmatch %}
|
||||
</dd>
|
||||
{% when None %}{% endmatch %}
|
||||
{% match failure.reasoning_tokens %}{% when Some with (rt) %}
|
||||
<dt>davon Reasoning</dt>
|
||||
<dd>{{ rt }}</dd>
|
||||
{% when None %}{% endmatch %}
|
||||
{% match failure.prompt_tokens %}{% when Some with (pt) %}
|
||||
<dt>Prompt-Tokens</dt>
|
||||
<dd>{{ pt }}</dd>
|
||||
{% when None %}{% endmatch %}
|
||||
</dl>
|
||||
<pre class="failure-reason">{{ failure.reason }}</pre>
|
||||
</details>
|
||||
<form
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Regression test for the diagnostic surface around an empty-content
|
||||
//! LLM response.
|
||||
//!
|
||||
//! Pre-fix behaviour: when the provider returned `200 OK` with
|
||||
//! `choices[0].message.content == null` (typical for reasoning models that
|
||||
//! exhausted `max_completion_tokens` on the chain-of-thought), the worker
|
||||
//! wrote a failure marker that read `llm: missing choices[0].message.content`
|
||||
//! — true but useless. Diagnostic fields the provider sent in the same
|
||||
//! response (`finish_reason`, `usage.completion_tokens`, reasoning_tokens)
|
||||
//! were dropped on the floor.
|
||||
//!
|
||||
//! This test pins the new `LlmError::EmptyContent` variant to the parsed
|
||||
//! response fields so the failure-marker UI can render structured details.
|
||||
|
||||
use doctate_server::analyze::backend::LlmBackend;
|
||||
use doctate_server::analyze::llm::{LlmError, chat_once};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn test_backend(uri: String) -> LlmBackend {
|
||||
LlmBackend {
|
||||
id: "test_backend".into(),
|
||||
label: "Test".into(),
|
||||
url: uri,
|
||||
api_key: "test-key".into(),
|
||||
requires_api_key: true,
|
||||
model_id: "openai/gpt-oss-120b".into(),
|
||||
temperature: 0.2,
|
||||
top_p: None,
|
||||
max_completion_tokens: 16384,
|
||||
reasoning_effort: Some("high".into()),
|
||||
use_json_schema: false,
|
||||
system_prompt: "system".into(),
|
||||
format_instruction: None,
|
||||
timeout_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_content_with_full_usage_surfaces_finish_reason_and_tokens() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"choices": [{
|
||||
"message": { "content": null },
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1287,
|
||||
"completion_tokens": 16384,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 14102
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let backend = test_backend(server.uri());
|
||||
let client = reqwest::Client::new();
|
||||
let err = chat_once(&client, &backend, "user content")
|
||||
.await
|
||||
.expect_err("must fail when content is null");
|
||||
|
||||
let d = err
|
||||
.empty_content_details()
|
||||
.expect("must classify as EmptyContent");
|
||||
assert_eq!(d.finish_reason, Some("length"));
|
||||
assert_eq!(d.prompt_tokens, Some(1287));
|
||||
assert_eq!(d.completion_tokens, Some(16384));
|
||||
assert_eq!(d.reasoning_tokens, Some(14102));
|
||||
assert_eq!(d.max_completion_tokens, 16384);
|
||||
|
||||
// Display string must mention finish_reason and the X/Y token ratio so
|
||||
// the failure-marker `reason` field already carries a hint even when
|
||||
// structured details are not yet rendered.
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("finish_reason=length"), "got: {s}");
|
||||
assert!(s.contains("16384/16384"), "got: {s}");
|
||||
assert!(s.contains("reasoning_tokens=14102"), "got: {s}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_content_without_usage_degrades_gracefully() {
|
||||
// Some OpenAI-compatible providers omit `usage` entirely on null-content
|
||||
// responses. The parser must still classify this as EmptyContent and not
|
||||
// crash on the missing field.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"choices": [{
|
||||
"message": { "content": null },
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let backend = test_backend(server.uri());
|
||||
let client = reqwest::Client::new();
|
||||
let err = chat_once(&client, &backend, "user content")
|
||||
.await
|
||||
.expect_err("must fail when content is null");
|
||||
|
||||
let d = err
|
||||
.empty_content_details()
|
||||
.expect("must classify as EmptyContent even without usage");
|
||||
assert_eq!(d.finish_reason, Some("stop"));
|
||||
assert_eq!(d.prompt_tokens, None);
|
||||
assert_eq!(d.completion_tokens, None);
|
||||
assert_eq!(d.reasoning_tokens, None);
|
||||
assert_eq!(d.max_completion_tokens, 16384);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populated_content_returns_ok() {
|
||||
// Sanity check: the new code path must not regress the happy path.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"choices": [{
|
||||
"message": { "content": "Hallo Welt." },
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let backend = test_backend(server.uri());
|
||||
let client = reqwest::Client::new();
|
||||
let text = chat_once(&client, &backend, "user content")
|
||||
.await
|
||||
.expect("must succeed when content is populated");
|
||||
assert_eq!(text, "Hallo Welt.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_error_does_not_carry_empty_content_details() {
|
||||
// Status errors are a different LlmError variant — `empty_content_details`
|
||||
// must not falsely classify them as EmptyContent.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("upstream down"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let backend = test_backend(server.uri());
|
||||
let client = reqwest::Client::new();
|
||||
let err = chat_once(&client, &backend, "user content")
|
||||
.await
|
||||
.expect_err("must fail on 503");
|
||||
assert!(matches!(err, LlmError::Status { status: 503 }));
|
||||
assert!(err.empty_content_details().is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user