docs: input-contract revision — extracted phrases instead of dictation paragraphs

Source-string analysis showed the Hypertonie miss is not a bug but a
structural consequence of embedding whole noisy Anamnese paragraphs
against terse Alpha-ID corpus strings. Resolution (user-decided): the
caller (doctate's LLM) extracts diagnostic phrases; this project still
contains no LLM — it just receives a cleaner input contract.

Spec: new §1a (rationale + generous extraction contract + the honest
extractor-is-recall-bottleneck trade-off); §1/§2/§3/§6/§8/§9/status
updated; §6 reworks eval (representativeness shift, paraphrase axis,
hand-labeled phrase-list set, separated extraction-ceiling vs matcher
recall). Plan: Goal/Architecture, revision banner, Task 6 rewritten
(one non-empty line = one unit; split_segments name/fields retained,
minimal-churn decision), Task 11/19 revision notes.

Code not yet changed (segment.rs still blank-line splits) — next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 23:26:30 +02:00
parent 8348e33264
commit 7cec295d99
2 changed files with 112 additions and 55 deletions
@@ -2,14 +2,16 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A Rust core library + thin CLI that turns a marker-segmented dictation into one prioritized list of ≤10 ICD-10-GM codes, with tag filtering, validated by a transcription-error eval harness.
**Goal:** A Rust core library + thin CLI that turns a list of caller-extracted diagnostic phrases (one per line) into one prioritized list of ≤10 ICD-10-GM codes, with tag filtering, validated by a noise+paraphrase eval harness.
**Architecture:** Two modes behind one interface. Mode C = lexical baseline (tantivy BM25 over the ~90k Alpha-ID phrase corpus) + ClaML tag filter. Mode A = adds IONOS `bge-m3` semantic retrieval (brute-force cosine over precomputed embeddings) fused with lexical via Reciprocal Rank Fusion, then IONOS `Qwen3-VL-Reranker-8B` cross-encoder rerank. Cross-segment fusion produces one global top-10. Embedding is one-time, hash-cached, resumable, cost-guarded. On IONOS failure mode A degrades to C.
**Architecture:** Two modes behind one interface. Mode C = lexical baseline (tantivy BM25 over the ~90k Alpha-ID phrase corpus) + ClaML tag filter. Mode A = adds IONOS `bge-m3` semantic retrieval (brute-force cosine over precomputed embeddings) fused with lexical via Reciprocal Rank Fusion, then IONOS `Qwen3-VL-Reranker-8B` cross-encoder rerank. Cross-unit fusion (one unit = one extracted phrase) produces one global top-10. Embedding is one-time, hash-cached, resumable, cost-guarded. On IONOS failure mode A degrades to C.
**Tech Stack:** Rust 1.94, `clap` (CLI), `tantivy` (lexical), `quick-xml` (ClaML streaming parse), `reqwest` blocking (IONOS HTTP), `serde`/`serde_json`/`toml`, `sha2` (cache keys), `rand` (seeded error injection). No ANN crate — brute-force exact cosine over 90k×1024 f32 is <50 ms and removes a native-build dependency (YAGNI).
Spec: `docs/superpowers/specs/2026-05-18-alpha-id-icd-suggestion-design.md`. Data lives in gitignored `data/` and `icd-claml/` (already present).
> **Revision 2026-05-18 (Input-Vertrag), spec §1a:** The input is no longer a blank-line-segmented dictation but a list of **caller-extracted diagnostic phrases, one per non-empty line** (extraction is doctate's LLM, never this project). Evidence-driven: terse corpus strings need terse query input; whole-paragraph embedding dilutes the diagnosis (see spec §1a). **Scope of code change is deliberately minimal:** only Task 6 (`segment.rs`) changes — split on newlines instead of blank lines; function name `split_segments` and the `segment_idx`/`source_segments` fields are retained (a "segment" now denotes one extracted phrase). The pipeline core (Tasks 9/10/18) is unchanged. Eval Tasks 11 & 19 gain paraphrase injection + a hand-labeled phrase-list set + separated reporting of extraction-recall ceiling vs. matcher recall. Where this plan still says "dictation/blank lines/Befund segment", read "phrase list / newlines / extracted-phrase unit".
---
## File Structure
@@ -22,7 +24,7 @@ src/model.rs # core types: AlphaIdEntry, IcdMeta, Tags, Cand
src/corpus.rs # parse Alpha-ID-SE -> Vec<AlphaIdEntry> + helpers
src/claml.rs # stream-parse ClaML XML -> HashMap<String, IcdMeta>
src/normalize.rs # text normalization + abbreviation expansion
src/segment.rs # split dictation on blank lines -> Vec<Segment>
src/segment.rs # split phrase list: one non-empty line -> one unit (rev 2026-05-18)
src/lexical.rs # tantivy index build/load/query over Alpha-ID texts
src/tags.rs # build Tags from IcdMeta+validity; Filter predicate
src/fusion.rs # RRF within segment; cross-segment dedupe(max)
@@ -703,7 +705,13 @@ git commit -m "feat: text normalization with medical abbreviation expansion"
---
### Task 6: Dictation segmentation
### Task 6: Phrase-list splitting (rev 2026-05-18, spec §1a)
Input is a caller-extracted phrase list: **one non-empty (trimmed) line =
one retrieval unit**. Blank/whitespace-only lines are skipped — they are no
longer paragraph delimiters. Function name `split_segments` and the
`Vec<String>` shape are retained (minimal-churn decision, spec §3 note); a
"segment" now denotes one extracted phrase.
**Files:**
- Modify: `src/segment.rs`
@@ -715,23 +723,31 @@ git commit -m "feat: text normalization with medical abbreviation expansion"
use alpha_id::segment::split_segments;
#[test]
fn splits_on_blank_lines() {
let d = "Befund eins\nmit Zeile zwei\n\nBefund zwei\n\n\nBefund drei\n";
let segs = split_segments(d);
assert_eq!(segs, vec![
"Befund eins\nmit Zeile zwei".to_string(),
"Befund zwei".to_string(),
"Befund drei".to_string(),
fn one_unit_per_nonempty_line() {
let input = "arterielle Hypertonie\nTyp-2-Diabetes mit Polyneuropathie\ngemischte Hyperlipidämie";
assert_eq!(split_segments(input), vec![
"arterielle Hypertonie".to_string(),
"Typ-2-Diabetes mit Polyneuropathie".to_string(),
"gemischte Hyperlipidämie".to_string(),
]);
}
#[test]
fn single_segment_when_no_blank_line() {
assert_eq!(split_segments("nur ein Befund"), vec!["nur ein Befund".to_string()]);
fn blank_and_whitespace_lines_are_skipped_and_each_line_trimmed() {
let input = " \n\n Kreuzschmerz \n \nGonarthrose rechts\n\n";
assert_eq!(split_segments(input), vec![
"Kreuzschmerz".to_string(),
"Gonarthrose rechts".to_string(),
]);
}
#[test]
fn empty_input_yields_no_segments() {
fn single_phrase_is_one_unit() {
assert_eq!(split_segments("nur eine Diagnose"), vec!["nur eine Diagnose".to_string()]);
}
#[test]
fn empty_or_whitespace_only_input_yields_no_units() {
assert!(split_segments(" \n\n").is_empty());
}
```
@@ -739,43 +755,36 @@ fn empty_input_yields_no_segments() {
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test --test segment_tests`
Expected: FAIL — `split_segments` not found.
Expected: FAIL — old blank-line-paragraph behavior joins lines / new test names absent.
- [ ] **Step 3: Write minimal implementation**`src/segment.rs`
```rust
/// Split a dictation into Befund segments on Markdown paragraph
/// breaks (one or more blank lines). Trims each segment; drops empties.
pub fn split_segments(dictation: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in dictation.lines() {
if line.trim().is_empty() {
if !current.is_empty() {
segments.push(current.join("\n").trim().to_string());
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
segments.push(current.join("\n").trim().to_string());
}
segments.into_iter().filter(|s| !s.is_empty()).collect()
/// Split the caller-extracted phrase list into retrieval units: one
/// non-empty (trimmed) input line = one unit. Blank/whitespace-only
/// lines are skipped. Rev 2026-05-18 (spec §1a): input is extracted
/// diagnostic phrases, not a blank-line-segmented dictation. Name and
/// signature kept; a "segment" now denotes one extracted phrase.
pub fn split_segments(phrases: &str) -> Vec<String> {
phrases
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test --test segment_tests`
Expected: PASS (3 tests).
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/segment.rs tests/segment_tests.rs
git commit -m "feat: dictation segmentation on blank lines"
git commit -m "refactor: input contract — one extracted phrase per line (spec §1a)"
```
---
@@ -1321,6 +1330,17 @@ git commit -m "feat: Mode C pipeline end-to-end (lexical + tags + fusion)"
### Task 11: Eval harness — error injection + Recall@k
> **Revision 2026-05-18 (spec §6):** Two changes layered on this task.
> (1) `inject_errors` case "umlaut" must produce the **ASCII digraph**
> (ä→ae, ö→oe, ü→ue, ß→ss), not delete the umlaut — the realistic
> transcription variant the umlaut fold (Task 5 / `normalize.rs`) handles;
> already implemented on `main` (commit `8348e33`).
> (2) Add a **paraphrase-injection** axis: a seeded transform that yields
> a synonym/word-order/brevity variant of the corpus phrase (not only the
> verbatim string + char noise), so the eval no longer tests the exact
> corpus byte-string (addresses the self-retrieval over-estimation, spec
> §6). Keep determinism-by-seed and the existing Recall@k tests.
**Files:**
- Modify: `src/eval.rs`
- Test: `tests/eval_tests.rs`
@@ -2351,6 +2371,20 @@ git commit -m "feat: Mode A hybrid pipeline with graceful degradation to C"
### Task 19: Eval comparison C vs A + smoke against real IONOS
> **Revision 2026-05-18 (spec §6):** First C-vs-A run done — Mode C 0.520
> vs A **0.995** BillableRecall@10 (200 cases, seed 42). Under the *old*
> dictation-paragraph contract that 0.995 was self-retrieval-inflated;
> under the new phrase-list contract (spec §1a) the short↔short regime is
> legitimately representative for noise/paraphrase robustness. Add to this
> task: (a) a small **hand-labeled set** of realistic *extracted phrase
> lists* (one phrase/line — the new input form) paired with the gold
> billable-ICD set, **plus the source dictation** stored alongside to
> document the extraction-recall ceiling; (b) the eval report must show
> **extraction-recall ceiling (dictation→phrases) vs. matcher recall
> (phrases→codes) separately**, never conflated. The hand-labeled set
> needs realistic example dictations from the user (still an open input,
> spec §9) — flag as BLOCKED-pending-user-data if absent, don't fabricate.
**Files:**
- Modify: `src/bin/alpha_id.rs` (Eval arm prints both modes when `--mode both`)
- Test: `tests/eval_compare_tests.rs`
@@ -1,7 +1,7 @@
# Design-Spec: alpha-id — ICD-Code-Vorschläge aus Diktat
**Datum:** 2026-05-18
**Status:** Entwurf zur Freigabe (Brainstorming abgeschlossen)
**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)
---
@@ -12,15 +12,30 @@ Aus einem (bereits transkribierten, fehlerbehafteten) ärztlichen Diktat eine **
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**.
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
### I/O-Vertrag (revidiert 2026-05-18)
- **Eingabe:** das *komplette* Diktat in *einem* Aufruf, durch **leere Zeilen (Markdown-Absätze)** in Befund-Segmente getrennt. Die Segmentierung erfolgt upstream durch doctates LLM; **dieses Projekt segmentiert nicht und enthält keinen generativen LLM**.
- **Ausgabe:** **eine** priorisierte Liste von max. 10 ICD-Codes **für das gesamte Diktat** — nicht pro Segment. Marker werden intern genutzt (fokussierte Retrieval-Einheiten), danach global fusioniert.
- **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 (24 Wörter, z. B. `I10.90 Arterielle Hypertonie`). Ein realer Befund-Absatz ist eine 1525-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/`)
@@ -50,17 +65,18 @@ Zwei Modi über Config-Schalter, **gleiche Schnittstelle**, im selben Eval-Harne
### Datenfluss
```
Komplettes Diktat (Befunde durch Leerzeilen getrennt)
Eingabe = extrahierte Diagnose-Formulierungen (eine pro Zeile)
▼ [1] Parser → Segmente (Split an Leerzeilen)
▼ [2] Normalisierung → pro Segment: lowercasing, Medizin-Abkürzungen,
│ Tokenisierung (kein LLM)
├─►[3a] Lexik (tantivy BM25/Trigramm) ┐ pro Segment:
▼ [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 ~50100 (RRF-Mix)
vorab persistierter Vektorindex) [A; in C nur 3a]
▼ [4] Reranker → Qwen3-VL-Reranker-8B: (Segmenttext, Alpha-ID-Text)
▼ [4] Reranker → Qwen3-VL-Reranker-8B: (Formulierung, Alpha-ID-Text)
│ paarweise → rerank-Score [nur A]
▼ [5] Fusion → alle Segment-Kandidaten in EINE Liste; je
▼ [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),
@@ -108,6 +124,8 @@ 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.
@@ -161,11 +179,13 @@ Reihenfolge im Staging: **C zuerst** (braucht kein Embedding, sofort lauffähig
Kein gelabeltes Datenmaterial vorhanden → der Harness ist Teil des PoC, kein Nachgedanke. Der Alpha-ID-Korpus ist sein eigenes Gold-Set (jeder Text hat einen bekannten ICD-Code).
- **Generator:** sampelt gültige, abrechenbare Alpha-ID-Einträge → `(text, erwarteter ICD)`. Injiziert **deterministisch (Seed)** realistische Transkriptionsfehler: deutsche Medizin-Homophone, Wortgrenzen-Merge/Split, Zifferndreher, fehlende Umlaute/Diakritika, typische ASR-Verwechslungen. Konfigurierbare Fehlerrate, mehrere Varianten/Eintrag.
- **Plus** ein kleines, handgelabeltes Set realistischer Mehr-Befund-Diktate (leerzeilengetrennt) — die echte Eingabeform.
- **Metriken:** Recall@1/5/10 gesamt; **gesondert Recall@10 abrechenbarer Codes** (Leitzahl Ziel 1); Modus C vs. A nebeneinander; Latenz + IONOS-Calls/Kosten; Breiten-Maß (distinkte assoziierte Codes) für Ziel 2.
**Repräsentativitäts-Konsequenz des Vertragswechsels (wichtig, ehrlich):** Unter dem alten Diktat-Absatz-Vertrag war der synthetische Eval *nicht* repräsentativ — er verrauschte kurze Korpus-Strings und matchte gegen denselben Korpus (Kurz↔Kurz), während der reale Input lange Prosa war; die so gemessene Zahl (erster Lauf: Modus C 0,520 vs. A **0,995** BillableRecall@10) **überschätzte** die Realität (Self-Retrieval-Kontamination). Unter dem neuen Vertrag ist der reale Input *selbst* eine knappe Formulierung → das Kurz↔Kurz-Regime des Harness wird **legitim repräsentativ** für die *Rausch-/Varianten-Robustheit*. Damit verbleibt als dominante Unbekannte nicht mehr die Längen-Asymmetrie, sondern das **Extraktor-Verhalten**.
„Taugt's" ist eine **Go/No-Go-Entscheidung des Nutzers** anhand der Eval-Tabelle: A vs. C bei Recall@10 abrechenbarer Codes auf dem fehlerinjizierten Gold-Set, abgewogen gegen Latenz und IONOS-Kosten. Eine feste numerische Schwelle wird hier bewusst **nicht** erfunden; sie wird nach dem ersten Eval-Lauf gemeinsam festgelegt.
- **Generator (Rausch- & Varianten-Achse):** sampelt gültige, abrechenbare Alpha-ID-Einträge → `(phrase, erwarteter ICD)`. Injiziert **deterministisch (Seed)**: (a) Transkriptionsfehler (deutsche Medizin-Homophone, Wortgrenzen-Merge/Split, Zifferndreher, Umlaut↔ae/oe/ue, ASR-Verwechslungen) **und (neu) (b) Extraktor-Paraphrase**: Synonym-/Wortstellungs-/Knappheits-Varianten der Formulierung (nicht nur die wörtliche Korpus-Phrase), damit nicht die exakte Korpus-Zeichenkette getestet wird.
- **Plus** ein kleines, handgelabeltes Set: **realistische, extrahierte Formulierungs-Listen** (eine Phrase pro Zeile — die *neue* Eingabeform), gepaart mit dem Gold-Satz abrechenbarer ICD-Codes, den ein Kodierer dafür vergäbe. Zu jedem Beispiel wird **zusätzlich das Quelldiktat** abgelegt, um die **Extraktions-Recall-Decke** zu dokumentieren (= Anteil der Gold-Diagnosen, die ein großzügiger Extraktor aus dem Diktat überhaupt als Zeile liefern würde). Diese Decke ist **doctates Verantwortung, nicht die dieser Komponente** — wird gemessen/berichtet, aber nicht hier optimiert.
- **Metriken:** Recall@1/5/10 gesamt; **gesondert Recall@10 abrechenbarer Codes** (Leitzahl Ziel 1) — gemessen *gegeben die extrahierten Formulierungen*; Modus C vs. A nebeneinander; **separat ausgewiesen:** Extraktions-Recall-Decke (Diktat→Formulierungen) vs. Matcher-Recall (Formulierungen→Codes), damit die zwei Recall-Verluste nicht vermischt werden; Latenz + IONOS-Calls/Kosten; Breiten-Maß (distinkte assoziierte Codes) für Ziel 2.
„Taugt's" ist eine **Go/No-Go-Entscheidung des Nutzers** anhand der Eval-Tabelle: A vs. C bei Recall@10 abrechenbarer Codes auf dem rausch-+varianten-injizierten Gold-Set **und** gegen das handgelabelte Formulierungs-Set, abgewogen gegen Latenz/IONOS-Kosten und die separat berichtete Extraktions-Decke. Eine feste numerische Schwelle wird bewusst **nicht** erfunden; sie wird nach dem ersten Eval-Lauf gemeinsam festgelegt.
---
@@ -176,7 +196,7 @@ alpha-id index build [--sample N | --full] [--confirm]
# Smoke-Test (Sample) bzw. einmaliger Full-Run;
# Korpus+ClaML parsen, bge-m3-Embeddings,
# Lexik-+Vektorindex persistieren (resumierbar)
alpha-id suggest [FILE|-] # Diktat → Top-10
alpha-id suggest [FILE|-] # Formulierungs-Liste (eine pro Zeile) → Top-10
--mode c|a --top 10 --billable-only --valid-only
--chapters I,J --exclude-exotic --json|--table
alpha-id eval --mode c|a --cases N --seed S [--gold PATH]
@@ -190,7 +210,8 @@ alpha-id eval --mode c|a --cases N --seed S [--gold PATH]
## 8. Abgrenzung (Out of Scope)
- Generativer Chat-LLM in der Pipeline (Modus B).
- Segmentierung des Diktats (kommt fertig aus doctate).
- **Extraktion der Diagnose-Formulierungen aus dem Rohdiktat** — Verantwortung des Callers (doctate-LLM) gemäß Extraktionsvertrag §1a; dieses Projekt segmentiert/extrahiert nicht.
- Negationsverarbeitung („kein Hinweis auf …") — Negierte Diagnosen werden laut Vertrag gar nicht erst extrahiert; der Matcher modelliert Negation nicht.
- Eigene/projektspezifische Tag-Whitelists (nur ClaML-abgeleitete Filter).
- Produktions-Deployment / doctate-Integration (erst nach erfolgreichem PoC).
- UI (CLI genügt für den PoC).
@@ -201,5 +222,7 @@ alpha-id eval --mode c|a --cases N --seed S [--gold PATH]
- Genaues IONOS-Antwortformat von `bge-m3` (Vektordimension) und `Qwen3-VL-Reranker-8B` (Score-Skala, Batch-API) wird im Sample-Smoke-Test (Abschnitt 4.4) empirisch verifiziert, bevor der Full-Run läuft.
- Wahl der konkreten Rust-Crates (tantivy für Lexik; ANN-Lib z. B. `usearch`/`hnsw_rs`; Embedding-Persistenz) wird im Implementierungsplan fixiert.
- Das Repo ist derzeit **kein** Git-Repository; vor dem Commit dieses Specs ggf. `git init`.
- **Revision 2026-05-18 (Input-Vertrag):** Eingabe = extrahierte Diagnose-Formulierungen statt Diktat-Absätze (§1a). Plan-Konsequenz: Task 6 (`segment.rs`) wechselt von „Split an Leerzeilen" zu „eine nicht-leere Zeile = eine Einheit"; Eval-Tasks (11/19) ergänzen Paraphrase-Injektion + handgelabeltes Formulierungs-Set + getrennte Berichterstattung Extraktions-Decke vs. Matcher-Recall. Pipeline-Kern unverändert.
- **Offener Punkt:** Der Extraktor (doctate-LLM) ist der alleinige Recall-Flaschenhals für Ziel 1; sein Real-Recall ist erst mit dem handgelabelten Diktat→Formulierungs-Set quantifizierbar und gehört nicht in dieses Projekt — wird aber getrennt berichtet (§6).
- **Aufgelöst 2026-05-18:** Korpus↔ClaML-Code-Granularität (5. Stelle nur via `<ModifierClass>`) — siehe §3a; korrekte ClaML-Modifier-Expansion beschlossen und in den Plan aufgenommen.
- **Aufgelöst 2026-05-18:** Git-Repository initialisiert; PoC implementiert, auf `main` gemergt, Umlaut-Faltung gefixt, einmaliger IONOS-Embedding-Lauf abgeschlossen (90.896 Vektoren).