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.
This commit is contained in:
@@ -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 `</script>` 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.
|
||||||
@@ -269,7 +269,13 @@ async fn scan_m4as(case_dir: &Path) -> M4aScan {
|
|||||||
scan
|
scan
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_failure_marker(case_dir: &Path) -> Option<FailureMarker> {
|
/// 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<FailureMarker> {
|
||||||
let bytes = tokio::fs::read(case_dir.join(FAILURE_MARKER_FILE))
|
let bytes = tokio::fs::read(case_dir.join(FAILURE_MARKER_FILE))
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|||||||
@@ -333,6 +333,11 @@ struct CasePageTemplate {
|
|||||||
llm_missing: bool,
|
llm_missing: bool,
|
||||||
analyzing: bool,
|
analyzing: bool,
|
||||||
has_document: 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<FailureBanner>,
|
||||||
/// True iff at least one `.m4a.failed` recording exists. Drives the
|
/// True iff at least one `.m4a.failed` recording exists. Drives the
|
||||||
/// verdrängende `fehler`-Badge in the case header; see the same
|
/// verdrängende `fehler`-Badge in the case header; see the same
|
||||||
/// field on `UserCaseView` for semantics.
|
/// field on `UserCaseView` for semantics.
|
||||||
@@ -369,6 +374,22 @@ struct CaseRecordingsTemplate {
|
|||||||
csrf_token: String,
|
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
|
||||||
|
/// `<details>` 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.
|
/// Flags derived from filesystem state + config.
|
||||||
/// `can_analyze` covers both first analysis and re-analysis — same precondition
|
/// `can_analyze` covers both first analysis and re-analysis — same precondition
|
||||||
/// (all non-failed recordings transcribed, LLM configured, no analysis in
|
/// (all non-failed recordings transcribed, LLM configured, no analysis in
|
||||||
@@ -379,6 +400,9 @@ struct CaseFlags {
|
|||||||
analyzing: bool,
|
analyzing: bool,
|
||||||
can_analyze: bool,
|
can_analyze: bool,
|
||||||
llm_missing: 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<FailureBanner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn compute_flags(
|
async fn compute_flags(
|
||||||
@@ -399,11 +423,26 @@ async fn compute_flags(
|
|||||||
|
|
||||||
let analyzable = !analyzing && all_transcribed;
|
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 {
|
CaseFlags {
|
||||||
has_document,
|
has_document,
|
||||||
analyzing,
|
analyzing,
|
||||||
can_analyze: analyzable && llm_configured,
|
can_analyze: analyzable && llm_configured,
|
||||||
llm_missing: 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,
|
llm_missing: flags.llm_missing,
|
||||||
analyzing: flags.analyzing,
|
analyzing: flags.analyzing,
|
||||||
has_document: flags.has_document,
|
has_document: flags.has_document,
|
||||||
|
analysis_failed: flags.analysis_failed,
|
||||||
has_failed_recording,
|
has_failed_recording,
|
||||||
is_admin,
|
is_admin,
|
||||||
is_closed,
|
is_closed,
|
||||||
|
|||||||
@@ -164,6 +164,58 @@
|
|||||||
background: #f6f6f6;
|
background: #f6f6f6;
|
||||||
border-radius: 4px;
|
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 {
|
.recordings-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
@@ -533,6 +585,33 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
|
||||||
|
<div class="failure-banner" role="alert">
|
||||||
|
<strong>Analyse fehlgeschlagen.</strong>
|
||||||
|
Bitte erneut versuchen.
|
||||||
|
<span class="failure-time"
|
||||||
|
>Zuletzt versucht:
|
||||||
|
<time datetime="{{ failure.failed_at }}"
|
||||||
|
>{{ failure.failed_at }}</time
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<details>
|
||||||
|
<summary>Technische Details</summary>
|
||||||
|
<pre class="failure-reason">{{ failure.reason }}</pre>
|
||||||
|
</details>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/web/cases/{{ case_id }}/analyze"
|
||||||
|
>
|
||||||
|
{% call csrf::field(csrf_token) %}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="return_to"
|
||||||
|
value="/web/cases/{{ case_id }}"
|
||||||
|
/>
|
||||||
|
<button type="submit">Erneut analysieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% when None %} {% if analyzing %}
|
{% when None %} {% if analyzing %}
|
||||||
<div class="placeholder">Wird analysiert …</div>
|
<div class="placeholder">Wird analysiert …</div>
|
||||||
{% else if recordings_count == 0 %}
|
{% else if recordings_count == 0 %}
|
||||||
@@ -542,7 +621,7 @@
|
|||||||
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
|
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
|
||||||
endif %}, davon {{ transcribed_count }} transkribiert.
|
endif %}, davon {{ transcribed_count }} transkribiert.
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% endmatch %}
|
{% endif %} {% endmatch %} {% endmatch %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
@@ -567,6 +646,20 @@
|
|||||||
TimeFmt.dateLabel(d) + " - " + TimeFmt.timeLabel(d);
|
TimeFmt.dateLabel(d) + " - " + TimeFmt.timeLabel(d);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Same locale treatment for the failure-banner timestamp.
|
||||||
|
// No-JS fallback is the raw RFC3339 UTC string the server
|
||||||
|
// rendered into the <time> 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");
|
const cb = document.getElementById("admin-view-toggle");
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
|
|||||||
@@ -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 <details>)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(r#"datetime="2026-04-15T10:05:00Z""#),
|
||||||
|
"failed_at not rendered as <time datetime=...> for browser locale formatting"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
|
||||||
|
"retry form must POST to the analyze endpoint"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("Erneut analysieren"),
|
||||||
|
"retry button label missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!body.contains("Wird analysiert"),
|
||||||
|
"the analyzing placeholder must not coexist with the failure banner"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn case_page_shows_llm_missing_hint_without_llm() {
|
async fn case_page_shows_llm_missing_hint_without_llm() {
|
||||||
let cfg = TestConfig::new()
|
let cfg = TestConfig::new()
|
||||||
|
|||||||
Reference in New Issue
Block a user