From cbb072d0ccec871b089d66837ce94161312c969a Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 3 May 2026 13:53:55 +0200 Subject: [PATCH] feat: Show LLM failure banner and retry button When an LLM analysis fails, a `.analysis_failed.json` marker is created. If no document exists yet and the auto-trigger is blocked by this marker, the case page must display a banner. This banner provides the failure reason and a button to retry the analysis, ensuring users are not left in a dead-end state. The `read_failure_marker` function is made public to allow the web layer to access this failure information. The `CasePageTemplate` is updated to include `analysis_failed` data, which conditionally renders the new `.failure-banner` HTML. This change prevents cases from becoming unrecoverable due to transient LLM errors. --- docs/ionos-llm-api.md | 259 +++++++++++++++++++++++++++++ server/src/analyze/auto_trigger.rs | 8 +- server/src/routes/user_web.rs | 40 +++++ server/templates/case_page.html | 95 ++++++++++- server/tests/case_page_test.rs | 73 ++++++++ 5 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 docs/ionos-llm-api.md diff --git a/docs/ionos-llm-api.md b/docs/ionos-llm-api.md new file mode 100644 index 0000000..cf8378d --- /dev/null +++ b/docs/ionos-llm-api.md @@ -0,0 +1,259 @@ +# Ionos LLM API: Quirks und Workarounds + +**Stand:** 2026-05-03 — empirisch ermittelt gegen +`https://openai.inference.de-txl.ionos.com/v1/chat/completions`. +Verhalten kann sich aendern; Datum oben mit dem aktuellen Test-Befund +abgleichen, bevor man auf diese Notizen baut. + +## TL;DR + +- Die **Marketing-Doku** unter `docs.ionos.com/cloud/ai/...` ist + unvollstaendig. Die dort gezeigten Beispiele fuer Llama 3.1 405B FP8 + laufen in der Praxis in einen Gateway-Timeout, weil sie wesentliche + Parameter weglassen. +- Die **echte aktuelle API-Spezifikation** liegt unter + `https://api.ionos.com/docs/inference-openai/v1/`. Das ist eine + Redoc-Seite mit eingebetteter OpenAPI-3.0.3-Spec — die einzige + verlaessliche Quelle fuer unterstuetzte Parameter, Defaults und Limits. +- Llama 3.1 405B FP8 hat einen **vLLM-Tokenizer-Bug**: das + End-of-Turn-Token `<|eot_id|>` wird als Klartext-String + `assistant\n\n` ausgegeben statt als Special-Token. Folge: der + offizielle `stop`-Parameter mit `<|eot_id|>` feuert nie, das Modell + laeuft bis `max_tokens` voll. +- **Empfohlene Loesung fuer Doctate**: `response_format: json_schema`. + Schema-erzwungenes Decoding stoppt natuerlich am schliessenden `}`, + funktioniert modell-agnostisch und braucht keine Provider-spezifischen + Workarounds. + +## So kommt man an die richtige API-Info + +### Verfuegbare Modelle abfragen + +Die Marketing-Doku listet Modelle, ist aber nicht immer synchron mit dem +Backend. Authoritative Liste: + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + https://openai.inference.de-txl.ionos.com/v1/models | jq . +``` + +Aktuell (2026-05-03) verfuegbar: + +| Modell | Kategorie | Anmerkung | +|---|---|---| +| `meta-llama/Meta-Llama-3.1-8B-Instruct` | Chat | schnell, klein | +| `meta-llama/Meta-Llama-3.1-405B-Instruct-FP8` | Chat | siehe Quirks unten | +| `meta-llama/Llama-3.3-70B-Instruct` | Chat | stoppt sauber, <1s | +| `mistralai/Mistral-Nemo-Instruct-2407` | Chat | ungetestet | +| `mistralai/Mistral-Small-24B-Instruct` | Chat | siehe Doctate-Memory | +| `openai/gpt-oss-120b` | Reasoning | stoppt sauber, vorherige Doctate-Wahl | +| `BAAI/bge-m3`, `bge-large-en-v1.5` | Embedding | – | +| `sentence-transformers/paraphrase-multilingual-mpnet-base-v2` | Embedding | – | +| `meta-llama/CodeLlama-13b-Instruct-hf` | Code | – | +| `Qwen/Qwen3-Coder-Next` | Code | – | +| `lightonai/LightOnOCR-2-1B` | OCR | – | +| `black-forest-labs/FLUX.1-schnell` | Bild | – | + +### Aktuelle OpenAPI-Spec extrahieren + +Die Redoc-Seite rendert die Spec clientseitig — `openapi.json` ist nicht +unter einer stabilen URL erreichbar, sondern liegt eingebettet im HTML +als JavaScript-Variable `__redoc_state`. Extraktion mit Brace-Balancing, +weil ein Regex auf `` zu viel mitnimmt: + +```python +import json, re +import urllib.request + +html = urllib.request.urlopen( + "https://api.ionos.com/docs/inference-openai/v1/").read().decode() +start = html.find('{"menu":{"activeItemIdx":-1}') +depth, in_str, esc = 0, False, False +for i, c in enumerate(html[start:], start): + if in_str: + if esc: esc = False + elif c == '\\': esc = True + elif c == '"': in_str = False + else: + if c == '"': in_str = True + elif c == '{': depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + end = i + 1; break + +spec = json.loads(html[start:end])['spec']['data'] +print(json.dumps(spec['paths']['/v1/chat/completions']['post'], indent=2)) +``` + +### Parameter-Schema fuer `chat/completions` (Stand 2026-05-03) + +Wichtige Defaults und Limits aus der Spec (Auszug, fett = abweichend von +OpenAI): + +| Parameter | Typ | Default | Anmerkung | +|---|---|---|---| +| `model` | string | required | – | +| `messages` | array | required | – | +| `temperature` | number | **1** | – | +| `top_p` | number | **-1** | Sentinel fuer "deaktiviert" | +| `n` | int | 1 | – | +| `stream` | bool | false | – | +| `stop` | array | – | **max 4 Sequenzen** | +| `max_tokens` | int | **16** | **deprecated** zugunsten `max_completion_tokens` | +| `max_completion_tokens` | int | **16** | – | +| `presence_penalty` | number | 0 | – | +| `frequency_penalty` | number | 0 | – | +| `logit_bias` | object | – | Token-ID -> Bias | +| `response_format` | object | – | `text` / `json_object` / `json_schema` | +| `tools`, `tool_choice` | – | – | Function Calling | + +**Nicht exposed** (wuerde es mit anderen vLLM-Backends geben): +`repetition_penalty`, `min_p`, `top_k`, `skip_special_tokens`, +`ignore_eos`, `stop_token_ids`. Wer diese vLLM-Backdoors braucht, ist +auf Ionos falsch. + +### Warum die Marketing-Doku nicht reicht + +`https://docs.ionos.com/cloud/ai/ai-model-hub/models/llms/meta-llama-3-1-405b` +zeigt das Beispiel: + +```json +{ "model": "...405B-FP8", "messages": [...], + "temperature": 0.7, "max_tokens": 100 } +``` + +Das funktioniert nur deshalb, weil `max_tokens: 100` zufaellig klein +genug ist, um vor Quirk B (siehe unten) zurueckzukommen. Sobald man +realistische Ausgabelaengen will (z.B. 2k+ Tokens fuer einen Arztbrief), +laeuft die Combo in den Gateway-Timeout, weil die Doku den Tokenizer-Bug +nicht erwaehnt und die Empfehlungen nicht praxistauglich validiert sind. + +## Llama 3.1 405B FP8: Drei Quirks + +### Quirk A — `<|eot_id|>` wird als ASCII geleakt + +Das Llama-3.1-Chat-Template nutzt `<|eot_id|>` (End-of-Turn) als +Stop-Marker. Auf Ionos's vLLM-Deployment ist dieses Token nicht +korrekt im Tokenizer als Special-Token registriert. Folge: das Modell +emittiert es als die einzelnen ASCII-Zeichen `a s s i s t a n t \n \n` +und labert weiter. Beobachtbar an Outputs der Form: + +``` +Hallo! Wie kann ich helfen?assistant\n\n +Oder moechtest du einfach plaudern?assistant\n\n +Entschuldigung, ich habe mich wiederholt...assistant\n\n +``` + +Konsequenz: der `stop`-Parameter mit `["<|eot_id|>"]` greift nie, weil +diese String-Sequenz nicht im Output auftaucht. Workarounds: + +| Workaround | Funktioniert | Sauber? | +|---|---|---| +| `stop: ["assistant"]` zusaetzlich | ja, 0.79 s fuer "Sag hallo." | unsauber, Provider-Bug-Patch | +| System-Prompt instruiert eigenen End-Marker, `stop` matcht ihn | ja, 0.89 s | mittel, Instruction-Drift moeglich | +| `response_format: json_schema` | ja, 0.84 s | sauber, offizielles Feature | + +### Quirk B — Ohne `max_tokens` laeuft das Modell bis Context-Ceiling + +Da das Modell selbst keinen Stop-Token emittiert (Quirk A) und +`max_tokens` Default = 16 nur theoretisch greift, generiert FP8-405B +auf vielen Pfaden bis zum Kontext-Limit (128k Tokens) weiter. Bei +~20 tok/s Generierungsrate sind das ~107 Minuten — laenger als jeder +Gateway-Timeout. Beobachtung: HTTP 504 "stream timeout" nach ~3 +Minuten, oder schon `HTTP 000` clientseitig. + +Konsequenz: **immer** `max_tokens` (oder `max_completion_tokens`) +explizit setzen. Realistische Werte fuer Doctate-Arztbriefe: 2048-4096. + +### Quirk C — Generierungsrate ~20-23 tok/s + +Empirisch ueber 6 Runs ermittelt (siehe Tabelle unten). Das ist deutlich +langsamer als `Llama-3.3-70B-Instruct` (~1 s fuer 10 Tokens) und +`gpt-oss-120b` (~0.5 s fuer 10 Tokens) auf demselben Endpoint. +Konsequenz: praktischer Hardlimit bei ~3000 Output-Tokens, sonst +Gateway-Timeout. + +### Empirische Datentabelle + +Alle Tests mit `meta-llama/Meta-Llama-3.1-405B-Instruct-FP8`, +Input `"Sag hallo."` (ausser T22 = realer Doctate-Body, ~1.5k Prompt-Tokens): + +| Test | Parameter | Ergebnis | Tok/s | +|---|---|---|---| +| T1 | `temp=0`, `max_tokens=8192` | Timeout 25 s | – | +| T3 | `temp=0`, `max_tokens=100` | 5.1 s, finish=length, 100 Tokens | 19.6 | +| T8 | `temp=0`, `max_tokens=500` | 23 s, finish=length, 500 Tokens | 21.7 | +| T9 | `temp=0`, `max_tokens=1500` | 114 s, finish=length, 1500 Tokens | 13.2 | +| T10 | `temp=0`, `max_tokens=3000` | 183 s, finish=length, 3000 Tokens | 16.4 | +| T11 | `temp=0`, `max_tokens=4500` | Gateway-Timeout (curl 200 s) | – | +| T17 | Doku-Combo + `stop:["...","assistant"]` | **0.79 s, finish=stop, 11 Tokens** | – | +| T19 | Doku-Combo + System-Marker `<|12345|>` | **0.89 s, finish=stop, 14 Tokens** | – | +| T21 | `response_format: json_schema` (nur `document:string`) | **0.84 s, finish=stop, 8 Tokens** | – | +| T22 | T21 + realer Doctate-Body (1539 Prompt-Tokens) | **22.77 s, finish=stop, 482 Tokens** | 22.7 | + +Konsistenz: **`finish_reason: "length"` heisst Modell wollte +weiter** — kein sauberer Stop. Nur die letzten vier Loesungswege +bekommen sauberes `"stop"`. + +## Loesungswege im Vergleich + +| Loesung | Speed | Sauber | Modell-agnostisch | Kommentar | +|---|---|---|---|---| +| `stop: ["assistant"]` | sehr schnell | nein | nein | Patch fuer Provider-Bug; bricht im englischen Text | +| Eigener End-Marker via System-Prompt | sehr schnell | mittel | ja | Modell kann Marker bei langem Output vergessen | +| `response_format: json_schema` | schnell | ja | **ja** | Server erzwingt Stop ueber Grammar-Decoding | +| Modellwechsel auf `gpt-oss-120b` / `Llama-3.3-70B` | sehr schnell | ja | – | Quirks A+B betreffen diese Modelle nicht | + +Empfehlung: **`response_format: json_schema`** als Default-Pfad fuer +Doctate, weil derselbe Code-Pfad auf gpt-oss-120b und Llama 3.3 70B +unveraendert weiter funktioniert. Wenn das Schema-Decoding bei +zukuenftigen, viel groesseren Outputs Latenzprobleme macht, wechselt +man auf einen schnelleren Modell-Endpoint, nicht zurueck auf +String-Hacks. + +## Entscheidungsbaum + +```mermaid +graph TD + A[Neuer Aufruf gegen Ionos] --> B{Brauche ich strukturierten Output?} + B -->|Ja oder unsicher| C[response_format: json_schema setzen] + B -->|Nein, freier Text| D{Modell?} + D -->|gpt-oss-120b oder Llama 3.3 70B| E[Standard-Aufruf reicht] + D -->|Llama 3.1 405B FP8| F[max_tokens MUSS gesetzt sein] + F --> G{Wie soll gestoppt werden?} + G -->|Sauber via Schema| C + G -->|String-Hack ok| H["stop: array enthaelt 'assistant'"] + G -->|Eigener Marker| I[System-Prompt instruiert + stop matcht] + C --> J[Output ist JSON, server-seitig parsen] + E --> K[Output direkt nutzen] + H --> K + I --> K +``` + +## Quellen und Verweise + +- API-Endpoint: `https://openai.inference.de-txl.ionos.com/v1/chat/completions` +- Authoritative API-Doku (Redoc): + `https://api.ionos.com/docs/inference-openai/v1/` +- Marketing-Doku (oft veraltet, mit Vorsicht geniessen): + `https://docs.ionos.com/cloud/ai/ai-model-hub` +- vLLM-Tokenizer-Hintergrund (warum `<|eot_id|>` als ASCII leakt): + Llama-3.1-Chat-Template + `add_special_tokens=False`-Detokenization-Pfad. +- Verwandte Doctate-Memories: `project_canary_ollama_vram_conflict`, + `project_mistral_24b_silent_deletion`, + `feedback_no_inline_llm_in_handlers`. + +## Wartungshinweis + +Diese Datei dokumentiert Verhalten zu einem Zeitpunkt. Bevor man bei +einem neuen Bug auf diese Workarounds setzt, immer zuerst pruefen, ob: + +1. Die Modellliste sich geaendert hat (`/v1/models`). +2. `<|eot_id|>` mittlerweile im Output sauber als Token statt als + `assistant\n\n` ankommt — dann ist Quirk A behoben und die + String-Hacks koennen weg. +3. Die OpenAPI-Spec neue/geaenderte Parameter zeigt. + +Datum oben aktualisieren, wenn man verifiziert hat, dass diese +Dokumentation noch dem realen Backend-Verhalten entspricht. diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index bc3d083..73590a8 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -269,7 +269,13 @@ async fn scan_m4as(case_dir: &Path) -> M4aScan { scan } -async fn read_failure_marker(case_dir: &Path) -> Option { +/// Read and parse the per-case failure marker. `pub(crate)` so the web +/// layer can surface the failure state in the case page (otherwise a +/// transport-level LLM error leaves the user with no UI affordance to +/// retry — the auto-trigger sees the marker and skips, but no document +/// exists yet either, so the existing `Neu analysieren` button is not +/// rendered). +pub(crate) async fn read_failure_marker(case_dir: &Path) -> Option { let bytes = tokio::fs::read(case_dir.join(FAILURE_MARKER_FILE)) .await .ok()?; diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 0d31bf4..5806940 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -333,6 +333,11 @@ struct CasePageTemplate { llm_missing: bool, analyzing: bool, has_document: bool, + /// `Some` iff a previous LLM analysis failed AND no document exists. + /// Renders a dead-end-recovery banner with reason + retry button so + /// the user is never stranded when a transient LLM error left only a + /// `.analysis_failed.json` marker behind. See `compute_flags`. + analysis_failed: Option, /// True iff at least one `.m4a.failed` recording exists. Drives the /// verdrängende `fehler`-Badge in the case header; see the same /// field on `UserCaseView` for semantics. @@ -369,6 +374,22 @@ struct CaseRecordingsTemplate { csrf_token: String, } +/// Information about a previous failed analysis run, surfaced in the +/// case page when no document exists yet so the user always has an +/// affordance to retry. Populated from `.analysis_failed.json` only +/// when `!has_document && !analyzing` — otherwise the marker is either +/// already obsolete (a successful run will overwrite it) or the worker +/// is currently producing a fresh result anyway. +struct FailureBanner { + /// Raw `reason` string from the marker. Shown verbatim inside a + /// `
` block — useful for admins, harmless for users. + reason: String, + /// RFC3339 UTC timestamp of the failed run. Browser JS reformats it + /// into the doctor's local timezone (same `time_format.js` that + /// formats `recorded_at_iso`). + failed_at: String, +} + /// Flags derived from filesystem state + config. /// `can_analyze` covers both first analysis and re-analysis — same precondition /// (all non-failed recordings transcribed, LLM configured, no analysis in @@ -379,6 +400,9 @@ struct CaseFlags { analyzing: bool, can_analyze: bool, llm_missing: bool, + /// `Some` iff a previous LLM run failed AND no document exists yet. + /// Drives the dead-end-recovery banner on the case page. + analysis_failed: Option, } async fn compute_flags( @@ -399,11 +423,26 @@ async fn compute_flags( let analyzable = !analyzing && all_transcribed; + // Only surface the failure marker when neither a document nor an in-flight + // 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, + }) + } else { + None + }; + CaseFlags { has_document, analyzing, can_analyze: analyzable && llm_configured, llm_missing: analyzable && !llm_configured, + analysis_failed, } } @@ -702,6 +741,7 @@ pub async fn handle_case_page( llm_missing: flags.llm_missing, analyzing: flags.analyzing, has_document: flags.has_document, + analysis_failed: flags.analysis_failed, has_failed_recording, is_admin, is_closed, diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 1e0be82..1ad9021 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -164,6 +164,58 @@ background: #f6f6f6; border-radius: 4px; } + .failure-banner { + margin: 1em 0; + padding: 1em 1.2em; + background: #fdecea; + border: 1px solid #f5c6cb; + border-radius: 4px; + color: #842029; + } + .failure-banner strong { + font-size: 1.05em; + } + .failure-banner .failure-time { + display: block; + margin-top: 0.4em; + font-size: 0.9em; + color: #6b1f25; + } + .failure-banner details { + margin-top: 0.6em; + font-size: 0.9em; + } + .failure-banner details summary { + cursor: pointer; + color: #6b1f25; + } + .failure-banner pre.failure-reason { + white-space: pre-wrap; + word-break: break-word; + margin: 0.4em 0 0 0; + padding: 0.6em 0.8em; + background: #fff; + border: 1px solid #f5c6cb; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + color: #4a1014; + } + .failure-banner form { + margin: 0.8em 0 0 0; + } + .failure-banner button { + padding: 0.4em 1em; + background: #b02a37; + color: #fff; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 0.95em; + } + .failure-banner button:hover { + background: #842029; + } .recordings-link { display: inline-block; margin: 1em 0; @@ -533,6 +585,33 @@ }); })(); + {% when None %} {% match analysis_failed %} {% when Some with (failure) %} + {% when None %} {% if analyzing %}
Wird analysiert …
{% else if recordings_count == 0 %} @@ -542,7 +621,7 @@ {{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert. - {% endif %} {% endmatch %} + {% endif %} {% endmatch %} {% endmatch %}
element. + (() => { + const el = document.querySelector( + ".failure-banner time[datetime]", + ); + if (!el) return; + const d = new Date(el.dateTime); + if (isNaN(d.getTime())) return; + el.textContent = + TimeFmt.dateLabel(d) + " " + TimeFmt.timeLabel(d); + })(); + (() => { const cb = document.getElementById("admin-view-toggle"); if (!cb) return; diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 376e950..e7e9f5c 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -109,6 +109,79 @@ async fn case_page_shows_analyze_button_when_transcribed_without_document() { ); } +/// Bug regression: a previous LLM run that failed (e.g. transient HTTP +/// transport error to the inference endpoint) leaves a `.analysis_failed.json` +/// marker on disk. With no `document.md` yet, the existing "Neu analysieren" +/// button — which lives inside the document-rendered branch — is invisible, +/// and the auto-trigger refuses to retry as long as the marker matches the +/// current input. The case page must therefore surface a recovery banner +/// that exposes the reason and a retry form, otherwise the user is stuck. +#[tokio::test] +async fn case_page_shows_failure_banner_with_retry_when_marker_present() { + let (cfg, settings) = TestConfig::new() + .with_label("cp-fail-banner") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build_pair(); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording( + &case_dir, + "2026-04-15T10-00-00Z", + Some("transkribierter Text"), + ); + + // Seed the marker exactly as the analyze worker would on a transport + // failure. `last_recording_mtime` is intentionally close to (but not + // exactly equal to) the freshly-seeded recording's mtime — the banner + // path reads the marker unconditionally, so equality is irrelevant + // here; we only need the file to exist and parse. + std::fs::write( + case_dir.join(".analysis_failed.json"), + r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout to https://example.invalid/v1/chat/completions","failed_at":"2026-04-15T10:05:00Z"}"#, + ) + .unwrap(); + + let app = doctate_server::create_router_with_settings(cfg, settings); + let cookie = login_dr_a(&app).await; + + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp).await; + + assert!( + body.contains(r#"class="failure-banner""#), + "failure-banner div missing" + ); + assert!( + body.contains("Analyse fehlgeschlagen"), + "user-facing failure title missing" + ); + assert!( + body.contains("connect timeout"), + "raw reason text not surfaced (expected inside
)" + ); + assert!( + body.contains(r#"datetime="2026-04-15T10:05:00Z""#), + "failed_at not rendered as