# 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 --> LEN{"> 9 000 Zeichen?
(SECTION_MIN_CHARS, seit 2026-07-29)"}
LEN -- "ja (Meeting-Länge)" --> LONG
LEN -- nein --> SPK{"labeled + ≥ 3 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 à 3 200 Zeichen (seit 2026-07-29;
bei 8k wird der Inhalt generisch/namenlos,
bei 4,2k konkurrieren zwei Themen pro Chunk);
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')
+ TAIL: die letzten 700 Zeichen des VORIGEN Chunks
WÖRTLICH, ausdrücklich 'nicht zusammenfassen'
(Bezugswörter für Sätze am Chunk-Anfang):
Mini-JSON thema (Überschrift ≤ 6 Wörter) +
zusammenfassung (JEDE berichtende Person, beruflich;
JEDES Thema mind. 1 Satz; nicht verallgemeinern;
Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)
+ teilnehmer + todos (nur beruflich, Verantwortlicher
in Klammern)"]
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 --> ABS
SALV -- ja --> ABS{"Entwurf behauptet etwas Grundsätzliches?
Skopus-Wort (nur/ausschließlich/statt/…)
UND kollektives Subjekt (das Team/wir/künftig/…)"}
ABS -- ja --> VERIFY["PRÜF-Aufruf NUR für diesen Abschnitt:
Abschnitt wörtlich + Entwurf →
'gilt das allgemein oder nur für EINEN Beitrag?'
Antwort < 60 % der Entwurfslänge wird verworfen"]
VERIFY --> COLLECT
ABS -- nein --> COLLECT["Sektionen einsammeln"]
COLLECT --> DROP{"letzter Abschnitt =
nur Verabschiedung?
(2 Floskeln ODER Überschrift 'Abschluss…')"}
DROP -- ja --> DROPPED["Abschnitt verwerfen
(nie ein innerer, nie der einzige)"]
DROPPED --> TITLE
DROP -- nein --> 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
```
### Länge schlägt beides: Sektions-Pfad ab 9 000 Zeichen (2026-07-29)
Aufnahme #79 (23-min-Marketing-Jour-fixe, 3 diarisierte Sprecher, 21k Zeichen im gelabelten Transkript)
erfüllte **keine** der beiden bisherigen Bedingungen — unter `WHOLE_MAX_CHARS` 32 000 und unter
`MANY_SPEAKERS` 5 — und lief deshalb als Whole-Pass. Ergebnis: **1 082 Zeichen** Zusammenfassung, in der
das erste Tagesordnungsthema (Timo/Sabbatical, die ersten 2,5 Minuten) **komplett fehlte**; MS Teams
lieferte für dasselbe Meeting 5 Themenblöcke. Die Sprecherzahl war also nie der eigentliche Treiber:
oberhalb von ~3k Token komprimiert das 4B den ANFANG des Prompts weg, egal wie viele Leute reden.
- Neu `SECTION_MIN_CHARS = 9 000` (`LLM_SECTION_MIN`): alles darüber geht in `analyzeLong`, das jeden
Abschnitt bauartbedingt abdeckt. `MANY_SPEAKERS` zusätzlich 5 → 3.
- Wirkung auf #79 (gleiches Transkript, gleiche Diarisierung): **1 082 → ~5 300 Zeichen**, 7 Themenblöcke,
alle Teams-Themen enthalten, 15–18 ToDos mit Verantwortlichen.
- Preis: der Sektions-Pfad braucht auf diesem Board ~20 statt ~8 Minuten.
### Zwei Fehlerbilder des Sektions-Pfads — und was wirklich half (2026-07-29)
Beide wurden über **zehn** Läufe derselben Aufnahme gemessen, nicht geraten.
**1. Verlorenes Thema am Chunk-Anfang.** Bei 4 200 Zeichen enthielt Chunk 1 von #79 zwei zusammenhanglose
Themen (Timo-Gespräch, dann Video-Review) — das Modell fasste nur das längere zusammen, das Timo-Thema
verschwand in 1 von 3 Läufen. Fix: `CHUNK_CHARS` 4 200 → **3 200** plus die Prompt-Zeile „Enthält der
Abschnitt mehrere verschiedene Themen, behandle JEDES in mindestens einem Satz — auch ein kurzes Thema
ganz am Anfang." Ein früherer Versuch mit dem Zusatz „…und lasse weg, was unklar bleibt" machte es
**schlimmer** (das war die Lizenz, kurze Themen zu streichen) und wurde zurückgenommen.
**2. Überdehnte Einzelaussagen.** „Achso, ja, dann lassen wir LinkedIn raus, dann kommen wir es nur auf
Instagram" — gesagt über EIN Ragebait-Video — wurde zu „das Team veröffentlicht nur noch auf Instagram",
also der Umkehrung der echten Entscheidung (LinkedIn soll fachlicher werden). In 5 von 8 Läufen falsch,
und **keine** Prompt-Formulierung hat das zuverlässig behoben (abstrakte Regel, dann Gegensatzpaar mit
Beispiel — beide blieben drin, beide reichten nicht). Was gemessen wurde und half:
- Ein Skript, das `buildBasis` + `chunkText` nachbaut, lokalisierte die Auslöser: „lustigen Sachen nur bei
Instagram" liegt **4 % in Chunk 6**, also ohne Bezugswort. → **`TAIL_CHARS` = 700**: jeder Chunk bekommt
die letzten 700 Zeichen des vorigen wörtlich als ausdrücklich gekennzeichneten Kontextblock. Das behob
genau diesen Abschnitt.
- Der zweite Auslöser sitzt mitten in Chunk 5 (51 %), hat also Kontext und wird trotzdem überdehnt. →
**gezielter Prüf-Aufruf**, wenn der Entwurf ein Skopus-Wort UND ein kollektives Subjekt enthält.
Erste Fassung des Triggers suchte wörtlich nach „nur noch"/„statt auch" und feuerte deshalb NIE auf den
echten Fehlsatz „Das Team entscheidet sich dafür, **nur** Instagram zu nutzen **statt** LinkedIn".
**Stand:** die harte Umkehrung („LinkedIn wird beendet") ist weg, der Rest-Effekt ist eine abgeschwächte
Formulierung („verstärkter Fokus auf Instagram") in einem von sieben Abschnitten, während Überblick und
LinkedIn-Abschnitt die Strategie korrekt wiedergeben. Ein vollständiger Verifikationsdurchlauf über ALLE
Abschnitte würde die Analyse verdoppeln — dafür ist der Rest-Fehler zu klein.
### 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.
## Kontextnotiz des Nutzers (seit 2026-08-17)
Nach dem Stop bietet die Detailansicht **ein Freitextfeld** an: „Kontext zum Gespräch“. Was dort steht,
geht als **Hintergrundwissen** in die Analyse — nicht als Gesprächsinhalt. Damit weiß das Modell, wer
dabei war, worum es ging und wie Eigennamen geschrieben werden, statt das alles aus fehlerbehaftetem
ASR-Text zu erraten.
**Bewusst EIN Feld, kein Formular** (Entscheidung des Nutzers): die Box benutzen später auch technisch
nicht versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als Teilnehmer-, Anlass- und
Begriffsfelder auszufüllen. Die Struktur zieht sich die Analyse selbst aus der Prosa. Geführt wird über
den **Platzhaltertext** („Wer war dabei, worum ging es, wichtige Namen oder Begriffe? … z. B.: Call mit
Herrn Meier von Firma Krämer wegen Angebot Halle 3 …“) statt über Pflichtfelder.
```mermaid
flowchart TB
STOP["Stop gedrückt"] --> BOX["Kontextbox klappt AUF und ist fokussiert
(state.ctxPromptFor, main.js)"]
BOX --> SAVE["PATCH /api/recordings/:id/context
db.setContextNote → recordings.context_note
(Deckel 4 000 Zeichen, leer = löschen)"]
SAVE --> WHEN{"Wie weit ist die
Verarbeitung?"}
WHEN -- "status=transcribing" --> AUTO["applies='auto':
Analyse liest die Notiz danach → fließt sicher ein"]
WHEN -- "analysis_status=pending" --> RUN["applies='running':
Analyse läuft schon, kann die Notiz verpasst haben
→ GUI verweist auf '↻ Analyse neu'"]
WHEN -- "done / error" --> RE["applies='reanalyze':
GUI fragt nach und startet reanalyze-llm
(Sprecher bleiben, nur LLM neu)"]
AUTO --> READ
RE --> READ
READ["analyzeRecording: db.getContextNote(id)
ERST kurz vor dem LLM-Aufruf gelesen
(Sprecher-Erkennung davor dauert Minuten —
was der Nutzer in der Zeit tippt, zählt noch)"] --> INJ["llm.analyze({context}) → contextBlock()"]
INJ --> P1["analyzeSingle: PROMPT_CLEAN(_LABELED) + PROMPT_JSON"]
INJ --> P2["analyzeWhole: PROMPT_WHOLE_ANALYZE"]
INJ --> P3["analyzeLong: JEDER Chunk-Prompt (gekürzt)
+ Titel- und Überblick-Mikro-Call"]
```
**Halluzinationsbremse.** Die Notiz steht in einem eigenen, mit `"""` abgegrenzten Block mit der
ausdrücklichen Auflage: nur für Schreibweisen, Zuordnung, Anlass und Gewichtung nutzen, **keine**
Aussagen, Ergebnisse, Entscheidungen oder Aufgaben daraus übernehmen, Notiz nicht erwähnen. Ohne diese
Klammer schreibt ein 4B-Modell die Notiz als Gesagtes in die Zusammenfassung zurück — dieselbe Falle wie
beim `CORRECTION_HINT` (siehe dessen STRENG-Satz).
**Zwei Deckel**, weil die Notiz je Pfad unterschiedlich oft im Prompt landet:
| Konstante | Wert | Gilt für |
|---|---|---|
| `db.CONTEXT_MAX` | 4 000 Zeichen | Speicherung (Spalte ist TEXT) |
| `LLM_CONTEXT_CHARS` | 1 200 Zeichen | Prompts, die die Notiz **einmal** sehen (Single/Whole/Titel/Überblick) |
| `LLM_CONTEXT_CHUNK_CHARS` | 600 Zeichen | Chunk-Prompts des Sektions-Pfads — dort geht die Notiz in **jeden** Chunk (`CHUNK_CHARS` = 3 200, eine lange Notiz wäre ein Drittel Ballast) |
Im Sektions-Pfad fällt die Notiz auf dem **letzten** von drei Versuchen zusammen mit dem `hint` weg: hat
das Modell zweimal die Feldbeschreibung nachgeplappert, ist jeder Zusatztext vor dem Abschnitt verdächtig.
**Grenze:** die Notiz verbessert Titel, Zusammenfassung, Teilnehmer und ToDos. Das **Transkript selbst**
verbessert sie nicht — Whisper ist zu dem Zeitpunkt schon gelaufen, und Begriffslisten dürfen ohnehin
nicht als `--initial-prompt` an Whisper (Echo-Loop, siehe Glossar-Abschnitt). Wer Schreibweisen dauerhaft
korrigiert haben will, nimmt das Glossar; die Notiz gilt nur für dieses eine Gespräch.
## 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).
## Speicherhaushalt: nur EIN großes Modell resident (seit 2026-08-07)
Bis dahin hat jeder Helfer für sich entschieden, wann er lädt und wer dafür weichen muss — und das ging
schief. **Gemessene Spitzen (`VmHWM`) auf 7,74 GB Gesamtspeicher:**
| Prozess | Spitze | Anmerkung |
|---|---|---|
| `rkllm_server` (Qwen3-4B w8a8) | **6,5 GB** | davon 4,79 GB `unevictable` NPU-Shmem — nicht auslagerbar |
| `embed_text.py` (e5-small, Suche) | 1,15 GB | faul, bleibt danach resident |
| `transcribe_daemon_cpu.py` (CT2) | 0,90 GB | Kurz-Clip-Pfad, bleibt per Design warm |
| `spk_embed` (TitaNet) | 0,70 GB | faul, bleibt danach resident |
**Zwei Ausfälle am 2026-08-07, beide aus derselben Wurzel** (Aufnahme #101, 34 s, 2 Personen):
1. Die 2-Sekunden-Aufnahme #100 davor hatte den warmen CT2-Daemon gestartet. Beim anschließenden
rkllm-Load war er noch resident → `rkllm_server` wurde in **allen 5 Startversuchen** OOM-getötet,
GUI: „KI-Analyse fehlgeschlagen". Ein Retry ohne Aufräumen stirbt zwangsläufig genauso wie der erste
Versuch.
2. Nach einer Analyse blieb rkllm **für immer** resident (belegt: der Prozess von 10:37 lebte noch um
13:27). Der Suchindex am Ende von `analyzeRecording()` startet aber das e5-Modell — 1,15 GB neben
6,5 GB → der Kernel erschlug rkllm (13:27:09, `dmesg`). Das war auch der Dauerzustand
„nur noch ~100 MB frei", in dem jeder weitere Helferstart Glückssache war.
⚠️ **zram rettet das nicht:** der komprimierte Swap liegt selbst im RAM. 0,9 GB ausgelagert kosten
weiter ~0,3–0,45 GB physisch — und rkllms 4,79 GB Shmem sind ohnehin nicht auslagerbar.
**Regel jetzt:** wer ein großes Modell lädt, räumt vorher auf. `gate.js` führt dazu eine Registry
(`registerReleaser` / `freeRam(keep)`, verdrahtet in `server.js` — dasselbe Muster wie `setProbe`, damit
`gate.js` keines der Module importieren muss). Alle Helfer starten faul neu; es geht nur Ladezeit
verloren, kein Zustand.
```mermaid
flowchart TB
subgraph REG["gate.js — Registry"]
R1["rkllm → llm.stopServer()"]
R2["cpu-transcriber → rec.stopTranscribeDaemon()"]
R3["text-embedder → emb.stop()"]
R4["spk-embedder → spk.stop()"]
end
A["NPU-Whisper startet
(recorder.js)"] --> FA["freeRam('npu-whisper')
→ beendet ALLE vier"]
B["rkllm lädt
(llm.startServer, vor JEDEM
der 5 Versuche)"] --> FB["freeRam('rkllm')
→ beendet die anderen drei"]
FA --> REG
FB --> REG
REG --> W["SIGTERM, auf Prozessende warten,
1,5 s Kernel-Reclaim
(gate.killAndWait)"]
W --> LOAD["großes Modell lädt allein"]
LOAD --> END["Analyse fertig →
llm.stopServer() VOR dem Suchindex
(analyzeRecording)"]
```
**Verifiziert am 2026-08-07** mit genau der Abfolge, die vorher scheiterte: 12-s-Clip → warmer
CT2-Daemon resident → Analyse von #101 → Journal
`ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in **22 s** (nicht
96 s: die Modelldatei lag noch im Seiten-Cache), Analyse in 221 s fertig, kein OOM. Danach **5,3 GB
frei** statt 100 MB.
**Preis:** die nächste Analyse lädt rkllm neu. Der Kurz-Clip-Pfad verliert damit seinen „rkllm bleibt
warm"-Vorteil (Kap. 03) — dafür ist die Analyse verlässlich statt schnell-aber-manchmal-tot.
## Modellwechsel und A/B-Tests
Das aktive Modell steht in **einer** systemd-Zeile:
```bash
cat /etc/systemd/system/callrec.service.d/gemma-model.conf
# [Service]
# Environment=RKLLM_MODEL=/opt/rkllm-models/qwen3-4b-w8a8g128-16k.rkllm
systemctl daemon-reload && systemctl restart callrec
```
`llm.js:10` liest `process.env.RKLLM_MODEL` (Fallback: das alte qwen2.5-3b). Ein Wechsel ist damit
umkehrbar, solange die alte `.rkllm` liegen bleibt. **Vor jedem Restart prüfen, ob eine Analyse läuft**
— ein Restart killt sie ersatzlos.
Gemessen wird mit `lab/ab-llm.mjs` (seit 2026-09-29). Es ruft `reanalyze-llm` auf, also **ohne**
Neu-Diarisierung: gleiches Transkript, gleiche Sprecher, gleiche Kontextnotiz — der einzige Unterschied
zwischen zwei Läufen ist das Modell. `snapshot` sichert die Originalanalysen vorher, `restore` schreibt
sie zurück, `report` legt Tabelle und Volltext-Vergleich nebeneinander.
```mermaid
flowchart LR
SNAP["snapshot <ids>
Originalanalysen sichern"] --> RUNA["run A
aktuelles Modell"]
RUNA --> SWAP["gemma-model.conf umstellen
daemon-reload + restart"]
SWAP --> RUNB["run B
neues Modell"]
RUNB --> REP["report A B
Dauer / Laenge / Todos + Volltext"]
REP --> DEC{"besser?"}
DEC -->|ja| KEEP["Drop-in bleibt
altes .rkllm liegt als Rueckweg"]
DEC -->|nein| BACK["Drop-in zurueck
restore schreibt Analysen zurueck"]
```
Die Konvertierung eines neuen Modells passiert **nicht** auf dem Board (kein CUDA), sondern auf dem PC
mit `tools/rkllm-qwen35/` — rkllm-toolkit-Version muss exakt zur Runtime auf dem Board passen.