# Design-Spec: alpha-id — ICD-Code-Vorschläge aus Diktat **Datum:** 2026-05-18 **Status:** PoC implementiert; **Input-Vertrag revidiert 2026-05-18** (siehe §1a) — Eingabe ist nicht mehr das leerzeilen-segmentierte Diktat, sondern eine Liste vom Caller **extrahierter Diagnose-Formulierungen**. **Stack:** Rust — Core-Library + dünne CLI (PoC) --- ## 1. Ziel & Kontext Aus einem (bereits transkribierten, fehlerbehafteten) ärztlichen Diktat eine **einzige priorisierte Liste von max. 10 ICD-10-GM-Codes** mit Beschreibung erzeugen, mit Filterung nach Tags. Zwei Zwecke: 1. **Vergessene abrechenbare Codes aufdecken** — im Stress vergessene Codes kosten Erlös. Das ist primär ein **Recall-Problem**. Leitmetrik: **Recall@10 abrechenbarer Codes**. Mit dem revidierten Input-Vertrag (§1a) ist der Recall *dieser Komponente* nach oben durch den **Extraktions-Recall** begrenzt: was der Caller nicht als Formulierung übergibt, kann der Matcher nicht retten. Der Ziel-1-Nutzen bleibt nur erhalten, wenn der Extraktionsvertrag **großzügig** ist (jede Diagnose-*Erwähnung* extrahieren, auch beiläufige in Anamnese/Verlauf — nicht nur das, was der Arzt bereits explizit als Diagnose kodiert). 2. **Hilfreiche klinische Impulse** — die bloße Sichtbarkeit verwandter Einträge (ICD-Hierarchie-Geschwister, Alpha-ID-Synonyme) erweitert das Bild und wirkt absichernd. Das System ist ein **PoC**. Übernahme in das Schwesterprojekt `doctate` erfolgt **nur, wenn es nachweisbar taugt** (gemessen am Eval-Harness). ### I/O-Vertrag (revidiert 2026-05-18) - **Eingabe:** eine **Liste vom Caller extrahierter Diagnose-Formulierungen**, eine pro Zeile (newline-getrennt; eine Zeile = eine Retrieval-Einheit). *Nicht* mehr das komplette, leerzeilen-segmentierte Diktat. Die Extraktion leistet das **LLM in doctate**; **dieses Projekt enthält weiterhin keinen generativen LLM** — es bekommt nur einen saubereren Input. Begründung siehe §1a. - **Ausgabe:** **eine** priorisierte Liste von max. 10 ICD-Codes **für die gesamte Eingabe** — nicht pro Zeile. Pro Formulierung wird einzeln retrievet/gerankt, danach global fusioniert/dedupliziert. ### 1a. Warum extrahierte Formulierungen statt Diktat-Absätze (Revision 2026-05-18) **Belegter Befund (Source-String-Analyse, 2026-05-18):** Der Match-Korpus (Alpha-ID Feld 8) besteht aus **knappen** Diagnose-Strings (2–4 Wörter, z. B. `I10.90 Arterielle Hypertonie`). Ein realer Befund-Absatz ist eine 15–25-Wort-Anamnese-Prosa, in der die Diagnose von nicht-diagnostischen Tokens (Alter, Medikament, Messwerte) umgeben ist. `bge-m3` mittelt den ganzen Absatz → der Query-Vektor wird vom Kontext dominiert, nicht vom Diagnosekern; die knappe Ziel-Phrase liegt zwar im Korpus, wird aber von Mess-/Kontext-„Fallen" (z. B. `R03.0 Erhöhter Blutdruckwert ohne Diagnose`) verdrängt. Dies ist **kein Bug**, sondern eine strukturelle Folge der Absatz-als-Einheit-Repräsentation — durch keine Fusions-/Reranker-Verbesserung behebbar. **Auflösung:** Der Caller (doctate-LLM) extrahiert die relevanten Diagnose-Formulierungen; nur diese knappen Phrasen werden gematcht. Das stellt die **Kurz↔Kurz-Symmetrie** her — genau das Regime, in dem dichtes Retrieval + Reranker stark sind. Scope bleibt sauber: kein Mode B, kein LLM in diesem Projekt, nur ein präziserer Eingabevertrag. Der Pipeline-Kern (Lexik ∪ Semantik → RRF → Reranker → Cross-Einheit-Dedupe → Tag-Filter → Top-10) bleibt unverändert; lediglich die Einheiten-Bildung wechselt von „Split an Leerzeilen" zu „eine Formulierung pro Zeile". **Extraktionsvertrag (Verantwortung von doctate, nicht dieses Projekts):** - **Großzügig:** jede Diagnose-/Befund-*Erwähnung* aus dem gesamten Diktat (Anamnese, Verlauf, Beurteilung) als eigene Zeile — auch beiläufige und Nebendiagnosen. Ziel 1 lebt von genau den nur beiläufig erwähnten, nicht kodierten Diagnosen. - **Knapp & diagnosezentriert:** die Diagnose-Nominalphrase, ohne Anamnese-Floskeln, Medikamente, Messwerte, Datumsangaben. Negationen/Ausschlüsse („kein Hinweis auf …") **nicht** als Diagnose extrahieren (das Projekt verarbeitet Negation nicht). - **Wörtlich genug:** nahe an der Diktatformulierung; leichte Transkriptionsfehler sind toleriert (Lexik/Semantik sind dafür robust, Umlaut-Faltung ist implementiert). - **Keine Codes, keine Wertung:** doctate liefert Text, nicht ICD; Priorisierung/Abrechenbarkeit entscheidet diese Komponente. **Ehrlicher Trade-off (vom Nutzer bewusst akzeptiert):** Der Extraktor wird zum alleinigen Recall-Flaschenhals (§1, Ziel 1). Konsequenz für das Eval (§6): der Harness muss Extraktor-Verhalten modellieren (Auslassungen, Paraphrasen), nicht nur Transkriptionsfehler. ### Datenquellen (vorhanden in `/home/brummel/dev/alpha-id/`) - `data/icd10gm2026_alphaidse_edvtxt_20250926.txt` — Alpha-ID-SE, 90.898 Sätze, 8 `|`-getrennte Felder (UTF-8). Feld 8 = ~90k natürlichsprachliche Diagnose-Formulierungen inkl. Synonyme (primärer Match-Korpus). Feld 1 = Gültigkeit (0/1). Felder 3–6 = ICD-Codes (Primär/Stern/Ausrufezeichen/Primär2). Feld 7 = Orpha. - `icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml` — ICD-10-GM 2026 als ClaML 2.0.0 (14 MB): Beschreibungen, Hierarchie, ``-Tags (Para295/Para301, Kapitel/Gruppe, Exotic, Sex, Age, IfSG, Content). ### Modelle / Infrastruktur IONOS AI Model Hub (`https://openai.inference.de-txl.ionos.com/v1`), Token `~/.ionos_token`. Nur zwei Modelle: - **Embeddings:** `BAAI/bge-m3` (multilingual, deutscher Medizintext) - **Reranker:** `Qwen/Qwen3-VL-Reranker-8B` (Cross-Encoder) Kein generativer Chat-LLM. IONOS-Client-Pattern aus `doctate/server/src/analyze/backend.rs` und `doctate/docs/ionos-llm-api.md` wiederverwenden (Token-Handling, Retry, Quirks). --- ## 2. Architektur Zwei Modi über Config-Schalter, **gleiche Schnittstelle**, im selben Eval-Harness vergleichbar: - **Modus C — Lexik-Baseline:** BM25/Trigramm + Tag-Filter. Kein IONOS, offline, sofort. Die Baseline, die A schlagen muss. - **Modus A — Hybrid + Reranker (Kern):** Lexik ∪ Semantik (`bge-m3`) → `Qwen3-VL-Reranker-8B` → Fusion → Tag-Filter → Top-10. **Modus B (generativer LLM-Rerank/-Vorschlag) ist explizit NICHT Scope.** Embedding- und Reranker-Modelle sind keine generativen LLMs und bleiben in A. ### Datenfluss ``` Eingabe = extrahierte Diagnose-Formulierungen (eine pro Zeile) │ ▼ [1] Parser → Einheiten (eine nicht-leere Zeile = eine │ Formulierung; im Code weiterhin „segment" genannt) ▼ [2] Normalisierung → pro Einheit: lowercasing, Medizin-Abkürzungen, │ Umlaut-Faltung, Tokenisierung (kein LLM) ├─►[3a] Lexik (tantivy BM25/Trigramm) ┐ pro Einheit: └─►[3b] Semantik (bge-m3-Query-Embedding vs. ┘ Pool ~50–100 (RRF-Mix) vorab persistierter Vektorindex) [A; in C nur 3a] ▼ [4] Reranker → Qwen3-VL-Reranker-8B: (Formulierung, Alpha-ID-Text) │ paarweise → rerank-Score [nur A] ▼ [5] Fusion → alle Einheiten-Kandidaten in EINE Liste; je │ normalisiertem ICD-Code dedupliziert │ (score=max, alle Herkünfte erhalten) ▼ [6] Tag-Filter → harte Constraints: Abrechenbarkeit (Para295/301), │ Gültigkeit (Feld 1), Kapitel/Gruppe, │ klinische Marker (Exot/Sex/Alter/IfSG/Content) ▼ [7] Top-10 → globales Budget; je Treffer: Code, Beschreibung, Score, Quell-Phrase, Quell-Segmente, Tags ``` ### Module (klar abgegrenzt) | Modul | Aufgabe | Abhängigkeit | |---|---|---| | `corpus` | Alpha-ID parsen → In-Memory-Index (ICD↔Phrasen, Gültigkeit, Orpha) | `data/` | | `claml` | ClaML-XML einmal parsen → `code → IcdMeta` | `icd-claml/` | | `embed` | IONOS `bge-m3`-Client (Batch, Cache, Retry, Resume) | IONOS | | `rerank` | IONOS `Qwen3-VL-Reranker-8B`-Client (Paar-Scoring, Batch, Retry) | IONOS | | `index` | Lexik-Index (tantivy) + persistierter Vektorindex (ANN) bauen/laden | `corpus`,`embed` | | `tags` | Filter-Prädikate aus ClaML-Meta + Alpha-ID-Gültigkeit | `claml` | | `pipeline` | Orchestrierung [1]–[7], Modus C/A per Config | alle obigen | | `eval` | Gold-Set-Generator + Recall@k-Metrik | `pipeline` | | `bin` (CLI) | dünner Wrapper: `index`, `suggest`, `eval` | `pipeline`,`eval` | Jedes Modul ist für sich testbar; `pipeline` ist die einzige Stelle, die Modi kennt. Die Core-Library bleibt frei von CLI-Belangen, damit doctate sie später unverändert einbinden kann. --- ## 3. Datenmodell (Kerntypen) ``` AlphaIdEntry { alpha_id, valid: bool, icd_primary, icd_star, icd_addon, icd_primary2, orpha, text } // icd_addon = Zusatzschlüssel (Feld 5, „!"); NICHT ICD-Exklusiva IcdMeta { code, description, chapter, group, para295, para301, exotic, sex, age_low, age_high, ifsg, content } Candidate { icd_code, alpha_text, segment_idx, lexical: Option, semantic: Option, rerank: Option } Suggestion { icd_code, description, score, matched_phrase, source_segments: Vec, tags: Tags } Filter { billable_only, valid_only, chapters: Option, exclude_exotic, … } Config { mode: C|A, top_k, pool_size, filter, ionos } ``` **Dedupe-Schlüssel = normalisierter ICD-Code** (ohne Kreuz `+`, Stern `*`, Ausrufezeichen `!`). Ein getroffener Alpha-ID-Eintrag steuert seinen Primärcode bei; Stern-/`!`-Codes werden als ergänzend markiert. Abrechenbarkeit entscheidet der Tag-Filter (Schritt 6), nicht der Match. **Terminologie-Hinweis (Revision 2026-05-18):** Eine **Einheit** = eine extrahierte Diagnose-Formulierung (eine Eingabezeile). Die Code-Feldnamen `segment_idx` / `source_segments` (sowie `split_segments`) bleiben aus DRY-/Churn-Gründen unverändert; „segment" bezeichnet im Code ab dieser Revision diese Formulierungs-Einheit, nicht mehr einen leerzeilen-getrennten Befund-Absatz. Bewusste Entscheidung: der Vertragswechsel ist semantisch groß, der Code-Eingriff minimal (nur die Einheiten-Bildung in `segment.rs`). ### Fusion (Schritt 5, präzisiert) - **Innerhalb** eines Segments: Kandidatenpool = Lexik-Top-N ∪ Semantik-Top-N, gemischt per **Reciprocal Rank Fusion (RRF)**. In Modus C ist die RRF-Ordnung die finale Vor-Filter-Reihung; in A folgt der Reranker. - **Über** Segmente: dedupliziert je normalisiertem ICD-Code, `score = max` über alle Vorkommen, **alle Herkunfts-(Segment, Phrase) erhalten**. Zahl getroffener Segmente = Tiebreaker und „Breiten"-Signal für Ziel 2. --- ## 3a. ICD-Code-Granularität & ClaML-Subklassifikation (Revision 2026-05-18) **Befund (an Echtdaten belegt):** ~26,5 % der gültigen Alpha-ID-Primärcodes sind 6-stellig (5. ICD-Stelle, z. B. `E11.72`, `I10.91`). ClaML modelliert die 4./5. Stelle vieler Gruppen — u. a. **alle Diabetes-Codes E10–E14** — **nicht** als eigene ``, sondern über ``/`` (Subklassifikation), referenziert per `` am übergeordneten ``. Ein naiver `meta.get(code)` (bzw. Strip-/Root-Fallback) klassifiziert dadurch **1.193 distinkte gültige Codes (≈ 4.394 Alpha-ID-Zeilen)** fälschlich als nicht-abrechenbar (Rückfall auf 3-Steller mit `Para295=V`) → unter `--billable-only` werden real abrechenbare Codes still verworfen. **Das untergräbt Ziel 1.** **Entschiedene Auflösung (Nutzer, 2026-05-18): korrekte ClaML-Modifier-Expansion.** `claml.rs` wertet zusätzlich `` (mit ``), `` (mit ``, preferred `