Files
Aime/docs/05-llm-analyse.md
T
SaschaandClaude Opus 5 e9ba48224e A/B-Aufbau fuer Qwen3.5-4B: Messstrecke und Konvertierungs-Kit
lab/ab-llm.mjs vergleicht zwei LLMs ueber reanalyze-llm (ohne
Neu-Diarisierung), mit snapshot/restore der Originalanalysen und einem
Report aus Tabelle plus Volltext-Vergleich.

tools/rkllm-qwen35/ konvertiert Qwen3.5-4B auf dem PC mit
rkllm-toolkit 1.3.0 (muss zur Board-Runtime passen), Parameter
identisch zum produktiven Modell: w8a8_g128, 3 NPU-Kerne,
max_context 16384.

Doku: Changelog 2026-09-29 (2) mit Recherchestand, Sicherung und
GOTCHAs, plus neuer Abschnitt "Modellwechsel und A/B-Tests" in
Kapitel 05 samt Mermaid-Ablauf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 10:55:47 +02:00

393 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
A -- nein --> LEN{"> 9 000 Zeichen?<br/>(SECTION_MIN_CHARS, seit 2026-07-29)"}
LEN -- "ja (Meeting-Länge)" --> LONG
LEN -- nein --> SPK{"labeled + ≥ 3 Sprecher?<br/>(MANY_SPEAKERS, countSpeakers)"}
SPK -- "ja (Round-Robin-Meeting)" --> LONG
SPK -- nein --> B{"≤ 32 000 Zeichen?"}
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)<br/>Instruktion VOR und NACH dem Transkript"]
WHOLE --> WCHECK{"JSON parsebar +<br/>Zusammenfassung ≥ 20 Zeichen?"}
WCHECK -- "JSON kaputt" --> REPAIR["Repair-Call: NUR der kaputte<br/>Output (klein) → gültiges JSON"]
REPAIR --> WCHECK
WCHECK -- "weiterhin leer/Junk" --> LONG
WCHECK -- ja --> OUT
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 3 200 Zeichen (seit 2026-07-29;<br/>bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4,2k konkurrieren zwei Themen pro Chunk);<br/>chunkText splittet an Zeilen = Turn-Grenzen"]
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y')<br/>+ TAIL: die letzten 700 Zeichen des VORIGEN Chunks<br/>WÖRTLICH, ausdrücklich 'nicht zusammenfassen'<br/>(Bezugswörter für Sätze am Chunk-Anfang):<br/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>JEDES Thema mind. 1 Satz; nicht verallgemeinern;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern)"]
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung/thema-Strings überleben fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
RETRY --> ABS
SALV -- ja --> ABS{"Entwurf behauptet etwas Grundsätzliches?<br/>Skopus-Wort (nur/ausschließlich/statt/…)<br/>UND kollektives Subjekt (das Team/wir/künftig/…)"}
ABS -- ja --> VERIFY["PRÜF-Aufruf NUR für diesen Abschnitt:<br/>Abschnitt wörtlich + Entwurf →<br/>'gilt das allgemein oder nur für EINEN Beitrag?'<br/>Antwort < 60 % der Entwurfslänge wird verworfen"]
VERIFY --> COLLECT
ABS -- nein --> COLLECT["Sektionen einsammeln"]
COLLECT --> DROP{"letzter Abschnitt =<br/>nur Verabschiedung?<br/>(2 Floskeln ODER Überschrift 'Abschluss…')"}
DROP -- ja --> DROPPED["Abschnitt verwerfen<br/>(nie ein innerer, nie der einzige)"]
DROPPED --> TITLE
DROP -- nein --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen<br/>→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet<br/>(Key-tolerant parsen: auch 'überblick' oder<br/>irgendein langer String-Value; leer = weglassen)"]
OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):<br/>Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'<br/>chronologisch + 'Genannte Namen: …' am Ende<br/>→ 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:
• <Punkt>
• <Punkt>
Entscheidungen:
• <Entscheidung>
```
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<br/>{speaker_09: 'Sprecher 5'}"]
B --> C[("recordings.speaker_labels")]
B --> D["LLM: Zusammenfassung/Todos<br/>mit genau diesen Labels"]
E["Sprecher umbenennen<br/>(Popover / Marker)"] --> F["relabelAnalysis()"]
C --> F
F --> G["Whole-Word-Ersetzung in<br/>summary · todos · transcript_clean"]
F --> H["Teilnehmerliste NEU gebaut<br/>(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<br/>GUI-Tab 'Glossar', /api/glossary")] --> Q{"Eintrag hat<br/>Soundalikes?"}
Q -- ja --> DET["DETERMINISTISCH: Ganzwort-Ersetzung<br/>direkt auf rec.words VOR der Analyse<br/>(applyGlossaryText / glossaryMap)"]
Q -- nein --> HINT["nur als Hinweis in den LLM-Prompt<br/>(glossaryHint)"]
DET --> LLM2["LLM sieht schon den korrekten Begriff"]
LLM2 --> BELT["Gürtel+Hosenträger: Ersetzung nochmal<br/>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<br/>(state.ctxPromptFor, main.js)"]
BOX --> SAVE["PATCH /api/recordings/:id/context<br/>db.setContextNote → recordings.context_note<br/>(Deckel 4 000 Zeichen, leer = löschen)"]
SAVE --> WHEN{"Wie weit ist die<br/>Verarbeitung?"}
WHEN -- "status=transcribing" --> AUTO["applies='auto':<br/>Analyse liest die Notiz danach → fließt sicher ein"]
WHEN -- "analysis_status=pending" --> RUN["applies='running':<br/>Analyse läuft schon, kann die Notiz verpasst haben<br/>→ GUI verweist auf '↻ Analyse neu'"]
WHEN -- "done / error" --> RE["applies='reanalyze':<br/>GUI fragt nach und startet reanalyze-llm<br/>(Sprecher bleiben, nur LLM neu)"]
AUTO --> READ
RE --> READ
READ["analyzeRecording: db.getContextNote(id)<br/>ERST kurz vor dem LLM-Aufruf gelesen<br/>(Sprecher-Erkennung davor dauert Minuten —<br/>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)<br/>+ 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<br/>(recorder.js)"] --> FA["freeRam('npu-whisper')<br/>→ beendet ALLE vier"]
B["rkllm lädt<br/>(llm.startServer, vor JEDEM<br/>der 5 Versuche)"] --> FB["freeRam('rkllm')<br/>→ beendet die anderen drei"]
FA --> REG
FB --> REG
REG --> W["SIGTERM, auf Prozessende warten,<br/>1,5 s Kernel-Reclaim<br/>(gate.killAndWait)"]
W --> LOAD["großes Modell lädt allein"]
LOAD --> END["Analyse fertig →<br/>llm.stopServer() VOR dem Suchindex<br/>(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 &lt;ids&gt;<br/>Originalanalysen sichern"] --> RUNA["run A<br/>aktuelles Modell"]
RUNA --> SWAP["gemma-model.conf umstellen<br/>daemon-reload + restart"]
SWAP --> RUNB["run B<br/>neues Modell"]
RUNB --> REP["report A B<br/>Dauer / Laenge / Todos + Volltext"]
REP --> DEC{"besser?"}
DEC -->|ja| KEEP["Drop-in bleibt<br/>altes .rkllm liegt als Rueckweg"]
DEC -->|nein| BACK["Drop-in zurueck<br/>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.