Files
Aime/docs/05-llm-analyse.md
T
Claude 448e2b0726 Namen nachtraeglich in fertige Zusammenfassung uebernehmen
speaker_labels speichert das Label jedes Clusters im LLM-Text; relabelAnalysis()
ersetzt beim Umbenennen wortgenau in summary/todos/transcript_clean und baut die
Teilnehmerliste neu. #74: keine 'Sprecher N' mehr.
2026-07-27 23:52:28 +02:00

14 KiB
Raw Blame History

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

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 --> SPK{"labeled + ≥ 5 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 à 4 200 Zeichen<br/>(bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4k bleibt das Modell geerdet);<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/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern) — bewusst minimaler Prompt"]
    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 --> COLLECT
    SALV -- ja --> COLLECT["Sektionen einsammeln"]
    COLLECT --> 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

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).

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.

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“)

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.

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).