# 05 — LLM-Analyse (Qwen3-4B auf der NPU) ## Modell | | | |---|---| | Modell | **Qwen3-4B-Instruct-2507** (non-thinking), `qwen3-4b-w8a8g128-16k.rkllm` (5,3 GB), seit 2026-07-16 | | Runtime | rkllm **1.3.0**; Chat-Template = **ChatML** (`<\|im_start\|>user` / `<\|im_end\|><\|im_start\|>assistant`) in `rkllm_server.cpp` | | Quantisierung | **grouped w8a8 (g128)** — gleiche Wahl wie bei Gemma (plain w8a8 riskant, w4a16 auf RK3588 nicht unterstützt) | | Kontext | 16k konvertiert, Runtime **12288** → Whole-Pass bis **32 000 Zeichen** (`WHOLE_MAX_CHARS`) — Qwen3 schafft die Synthese, an der Gemma ab ~6k Token scheiterte | | Tempo | Laden ~96 s (READY-Timeout deshalb 180 s!), 22k-Zeichen-Whole-Pass ~12-15 min, Kurzclip-Analyse ~7 min (gedrosselt) | | Historie | Qwen2.5-3B (4096 ctx, Feld-Echos) → Gemma-3-4B-it (gut ≤ 20k, Synthese-blind) → **Qwen3-4B** (A/B 2026-07-16: 3/3 Prompts besser, Whole-Pass 21,8k fehlerfrei gewichtet). Rollback: `rkllm_server{,.cpp}.bak-gemmatmpl` + Drop-in auf gemma3-Modell zurückstellen. gemma3n-E4B zu groß (10,4 GB). | **Hart:** niemals Online-LLM — alles lokal auf der NPU. ## Pfadwahl nach Transkriptlänge ```mermaid flowchart TB T["Transkript"] --> A{"≤ 4 800 Zeichen?"} A -- ja --> SINGLE["analyzeSingle:
EIN Call = bereinigter Dialog + JSON-Analyse
(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"] A -- nein --> SPK{"labeled + ≥ 5 Sprecher?
(MANY_SPEAKERS, countSpeakers)"} SPK -- "ja (Round-Robin-Meeting)" --> LONG SPK -- nein --> B{"≤ 32 000 Zeichen?"} B -- ja --> WHOLE["analyzeWhole:
GANZES Gespräch in einem Analyse-Call
(kohärente Summary, kleine JSON-Ausgabe)
Instruktion VOR und NACH dem Transkript"] WHOLE --> WCHECK{"JSON parsebar +
Zusammenfassung ≥ 20 Zeichen?"} WCHECK -- "JSON kaputt" --> REPAIR["Repair-Call: NUR der kaputte
Output (klein) → gültiges JSON"] REPAIR --> WCHECK WCHECK -- "weiterhin leer/Junk" --> LONG WCHECK -- ja --> OUT B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):
Chunks à 4 200 Zeichen
(bei 8k wird der Inhalt generisch/namenlos,
bei 4k bleibt das Modell geerdet);
chunkText splittet an Zeilen = Turn-Grenzen"] LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT
(1-Satz-Kurzfassungen der vorherigen Abschnitte →
Änderungen erkennbar: 'jetzt X, vorher Y'):
Mini-JSON thema (Überschrift ≤ 6 Wörter) +
zusammenfassung (JEDE berichtende Person, beruflich;
Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)
+ teilnehmer + todos (nur beruflich, Verantwortlicher
in Klammern) — bewusst minimaler Prompt"] MAP --> SALV{"JSON parsebar +
kein Feldbeschreibungs-Echo?"} SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT
(2.: ohne Kontext, 3.: ohne Hint —
manche Inhalte echoen sonst JEDES Mal)
dann Feld-Salvage per Regex
(zusammenfassung/thema-Strings überleben fast immer),
zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"] RETRY --> COLLECT SALV -- ja --> COLLECT["Sektionen einsammeln"] COLLECT --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen
OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)
→ nur {titel} — kleines Input/Output = zuverlässig"] TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen
→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet
(Key-tolerant parsen: auch 'überblick' oder
irgendein langer String-Value; leer = weglassen)"] OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):
Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'
chronologisch + 'Genannte Namen: …' am Ende
→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"] ASSEMBLE --> OUT["Ergebnis"] SINGLE --> OUT ``` ### Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17) Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER `WHOLE_MAX_CHARS` und lief als Whole-Pass — Ergebnis: Summary fast nur aus den LETZTEN Sprechern (Gasblender, Sitas, Zeiterfassung), alle frühen Wochenberichte fehlten. Ein 4B kann 15+ gleichgewichtige Themen aus einem ~10k-Token-Prompt nicht ausbalancieren — der Whole-Pass funktioniert nur für narrative Gespräche mit wenigen Sprechern (dort validiert, #70/#71). Deshalb routet `analyze()` **labeled Transkripte mit ≥ 5 Sprechern (`MANY_SPEAKERS`) immer in den Sektions-Pfad**, der jede Gesprächsphase per Konstruktion abdeckt. Der Sektions-Pfad wurde dabei auf Copilot-Niveau ausgebaut (3 Iterationen auf #74): 1. **Themen-Überschrift pro Chunk** (`thema`-Feld) → Konglomerat liest sich wie Besprechungsnotizen („Performanceproblem bei SAP-System:" statt „Teil 2/10:"). 2. **Jede berichtende Person MUSS vorkommen** (Prompt-Regel) — vorher fielen einzelne Sprecher weg. 3. **Smalltalk-Dämpfung**: Privates (Essen, Familie, Auto, Urlaubserzählungen) max. ein Halbsatz; Todos nur beruflich, mit Verantwortlichem in Klammern (37 → 30 saubere Todos auf #74). 4. **Überblick-Mikro-Call** (`PROMPT_OVERVIEW`) liefert den Intro-Absatz; gleiche kleine Input/Output-Klasse wie der Titel-Call. GOTCHA: Qwen antwortet teils mit Key `"überblick"` (ü!) statt `"ueberblick"` — Parser akzeptiert jeden langen String-Value. 5. Summary-Cap 6 000 → **16 000** Zeichen: 10 Sektionen ≈ 8k, der alte Cap schnitt mitten im Satz ab. Validiert auf #74: alle 13 Sprecher + alle Themen (DWP-Bank, Diakonie, SAP-GUI, Sitas, Zeiterfassung, Wellpass, LinkedIn, Polavis, Gasblender) abgedeckt, kein Recency-Bias mehr. Backup `llm.js.bak-standup`. ### Warum kein LLM-Reduce mehr (Prompt-Lab 2026-07-16) Der alte Reduce (Teil-Summaries → LLM baut EINE Gesamt-Summary) war die Fehlgewichtungs-Quelle. Im Prompt-Lab auf #70 (temporärer `/api/debug/ask`-Endpoint, ~15 Varianten) scheiterte JEDE Synthese-Form reproduzierbar: Gewichtungsregeln ignoriert (Auto-Smalltalk blieb im Titel), Namen durch Rollen ersetzt („Personalwesen"), Themen-Tabellen mit 4× demselben Eintrag, Repetitionsschleifen. **Abschnittsübergreifende Synthese liegt jenseits von Gemma-3-4B** — Verstehen einzelner 4k-Abschnitte dagegen funktioniert gut (Com4Tec, René, Tobi+Niklas, 20 Bewerbungen alle korrekt extrahiert). Also: LLM versteht, Code führt zusammen. ### Whole-Pass-Grenze = Modellfrage (Gemma-Geschichte, gelöst durch Qwen3) Mit **Qwen3-4B** liegt die Grenze bei 32 000 Zeichen (Kontext-Budget: 12288 − 1536 ≈ 10 752 Token) — der 21,8k-Whole-Pass von #70 kam in EINEM Call korrekt gewichtet zurück (Auto-Smalltalk raus, Namen drin). Zur Geschichte: mit Gemma-3-4B war die Grenze 20 000 Zeichen und zwar als MODELL-Grenze — Aufnahme #70 (26 min, 22,6k Zeichen ≈ 6 900 Token) scheiterte REPRODUZIERBAR an Gemma selbst (vier Iterationen, alle Fehlbilder real beobachtet): 1. **Instruktion nur oben** → Modell „vergisst" die Aufgabe, antwortet mit englischem Geplauder *über* das Gespräch („Okay! That was a fascinating conversation…"). Fix: Instruktion nach dem Transkript wiederholen (ohne `{"titel": ...}`-Skelett — die `...`-Platzhalter wurden sonst wörtlich kopiert!). 2. **Recency-Bias:** Titel/Summary beschreiben den SCHLUSS des Gesprächs (Auto-Smalltalk der letzten 3 min) statt der Hauptthemen — mehr Kontextfenster liest nicht gleichmäßiger. 3. **„themen"-Inventar-Feld als Gegenmittel** → Repetitionsschleife mit zerfallendem JSON (typografische Anführungszeichen, doppelte Keys, 16-min-Generierung bis Token-Cap). Konsequenz: > 20k Zeichen geht IMMER über Map-Reduce; dessen Reduce gewichtet stattdessen nach Abschnitts-Anteil. `WHOLE_MAX_CHARS` nicht erhöhen ohne Inhalts-Revalidierung. ## Strukturierte Zusammenfassung (seit 2026-07-15) Das LLM liefert getrennte Felder; `composeSummary()` baut daraus den Summary-Text: ``` <Überblick, 2–4 Sätze> Kernpunkte: • • Entscheidungen: • ``` GUI rendert mit `white-space: pre-line` (style.css) → Bullets und Absätze sichtbar. Todos bleiben eigenes Feld/eigene Liste. **Teilnehmer kommen NICHT vom LLM**, sondern aus der Diarization (Kap. 04) — das LLM hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 bei Aufnahme #69). ## Sektions-Konglomerat: was im Text landet Der Body der Zusammenfassung ist deterministisch aus den Chunk-Ergebnissen gebaut (kein LLM-Reduce): pro Abschnitt `thema`-Überschrift + Text, davor der Überblick-Absatz, am Ende eine Zeile „Genannte Namen: …". - Die pro Abschnitt extrahierten Eigennamen (`s.names`) werden seit 2026-07-27 **nicht mehr unter jeden Absatz gedruckt** („(Namen: Kai, Dennis, …)") — sie sind Arbeitsdaten für den Rolling Context zwischen den Chunks (halten die Schreibweise von Personen/Firmen stabil), kein Lesestoff. ## Namen nachträglich in eine fertige Analyse übernehmen (seit 2026-07-27) Die Zusammenfassung entsteht aus einem Dialog, in dem jeder Sprecher ein **Label** trägt: seinen Namen, falls beim Analysezeitpunkt bekannt, sonst „Sprecher N". Wer erst DANACH benannt wird (Sprecher-Popover, Namensmarker), stand vorher weiter als „Sprecher 5" im fertigen Text — und eine Neu-Analyse kostet auf diesem Board ~20–30 min. ```mermaid flowchart LR A["analyzeRecording()"] --> B["buildBasis() vergibt Labels
{speaker_09: 'Sprecher 5'}"] B --> C[("recordings.speaker_labels")] B --> D["LLM: Zusammenfassung/Todos
mit genau diesen Labels"] E["Sprecher umbenennen
(Popover / Marker)"] --> F["relabelAnalysis()"] C --> F F --> G["Whole-Word-Ersetzung in
summary · todos · transcript_clean"] F --> H["Teilnehmerliste NEU gebaut
(Reihenfolge des ersten Auftretens)"] F --> C ``` - `speaker_labels` (JSON, pro Aufnahme) hält genau das Label, das jeder Cluster im LLM-Text hatte — deshalb ist die Ersetzung eindeutig und braucht keine erneute Analyse (Sekunden statt Minuten). - Nach der Ersetzung wird das gespeicherte Label auf den Namen gesetzt, damit ein zweites Umbenennen wieder funktioniert. - **Teilnehmerliste** wird neu aufgebaut statt ersetzt (String-Swap ließ Sprecher stehen, die im Text nie vorkamen, und konnte Namen doppeln). - **Mehrdeutige Labels werden übersprungen:** trug dasselbe Label zwei Cluster (die Stimm-Erkennung hatte „Fabio" einmal auf zwei Cluster gelegt), lässt sich im Text nicht entscheiden, wer gemeint war — dann bleibt der Text unangetastet. Sauber wird das erst durch „Analyse neu (nur KI)" mit korrekten Namen. - GUI: Umbenennen → Toast „Name in Zusammenfassung übernommen", Detailansicht lädt neu. ## Robustheit gegen Gemma-Marotten (alle real aufgetreten) | Marotte | Gegenmaßnahme | |---|---| | Antwortet Prosa statt JSON bei großen Inputs | kleine Chunks (8 000), minimaler Chunk-Prompt, Reduce-Input gekappt + Retry, Prosa-Salvage | | Kaputtes JSON leakt in die Summary | `{...}`-Blobs werden vor Salvage entfernt | | Todos als Objekte (`{"aufgabe": …}`) statt Strings | `cleanList()` flacht Objekt-Werte zu Strings ab | | `"null"` / `"[object Object]"` / lose `?` in Listen | `cleanList()` filtert/trimmt | | Echot Feldbeschreibungen statt Inhalt | `echoed()`-Guard → separater JSON-Call | | Echot konkrete Beispiele aus dem Prompt (Phantom-Todos, „Kompressor“-Summary) | Korrektur-Regeln bewusst OHNE konkrete Beispiele (`CORRECTION_HINT` abstrakt) | | Erfindet Fantasie-Komposita („Audio-Dualeintragung“) | `NO_INVENT`-Regel in allen Summary-Prompts | | Chunk-Parse-Fehler variieren pro Lauf (Sampling-Münzwurf: 3/3 ok, nächster Lauf 1/3) | 1 Retry pro Chunk (P(fail) → P(fail²)), danach Salvage; bei schwachem Ergebnis „Analyse neu“ | | Typografische Anführungszeichen (“”„) als JSON-Delimiter, trailing commas | `extractJson()`-Sanitizer: erst strikt parsen, bei Fehler Quotes/Kommas reparieren und nochmal | | Whole-Pass > ~6k Token: englisches Geplauber, Recency-Bias, JSON-Zerfall | `WHOLE_MAX_CHARS` 20 000 hart; End-Instruktion; Repair-Call; Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback | | Kopiert `...`-Platzhalter aus Schema-Beispielen wörtlich | End-Instruktion nennt nur Feldnamen, nie ein Skelett mit Platzhaltern | | 8k-Chunk: Inhalt wird generisches namenloses Blabla (JSON trotzdem valide!) | Chunks à 4 200 Zeichen — dort bleibt der Inhalt geerdet mit echten Eigennamen | | Echot Feldbeschreibung INHALTS-abhängig bei JEDEM Versuch (Auto-Abschnitt #70, 2× identisch) | Echo-Guard + bis 3 Versuche mit VARIIERTEM Prompt (ohne Kontext / ohne Hint) | | `eigennamen`-Array degeneriert (Punkte-Loops, Junk-Tails), String-Felder intakt | Feld-Salvage per Regex holt zusammenfassung + Namen einzeln raus | | Cross-Section-Synthese: Regeln ignoriert, Namen→Rollen, Themen-Dubletten | LLM-Reduce ABGESCHAFFT — Konglomerat baut deterministischer Code | ## Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“) ```mermaid flowchart LR G[("glossary-Tabelle
GUI-Tab 'Glossar', /api/glossary")] --> Q{"Eintrag hat
Soundalikes?"} Q -- ja --> DET["DETERMINISTISCH: Ganzwort-Ersetzung
direkt auf rec.words VOR der Analyse
(applyGlossaryText / glossaryMap)"] Q -- nein --> HINT["nur als Hinweis in den LLM-Prompt
(glossaryHint)"] DET --> LLM2["LLM sieht schon den korrekten Begriff"] LLM2 --> BELT["Gürtel+Hosenträger: Ersetzung nochmal
auf Titel/Summary/Todos des LLM-Outputs"] ``` Wichtig: Begriffslisten **niemals** als Whisper-`--initial-prompt` (Echo-Loop, verifiziert) und dem 3–4B-Modell keine „ersetze X durch Y“-Anweisung anvertrauen (unzuverlässig) — deshalb die deterministische Text-Ersetzung. ## Zusammenspiel mit Whisper auf der NPU rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich als `rc=-6`). Kurzclips (≤ 30 s) nutzen deshalb den CPU-Whisper-Daemon ohne rkllm-Swap; lange Aufnahmen swappen seriell unter dem NPU-Lock. **Thermik:** `analyzeRecording()` läuft seit 2026-07-16 unter dem A76-Takt-Cap (`.bak-anacap`) — rkllm zieht neben der NPU ~1,7 CPU-Kerne, eine lange ungedeckelte Gemma-Generierung erreichte **95–97 °C** (lüfterlos, Schutzabschaltungs-Risiko). Gleicher Cap wie `transcribeWav`/`rediarize` (Kap. 03).