Files
Aime/docs/05-llm-analyse.md
SaschaandClaude Opus 5 3a2e8144f7 Drei weitere Kandidaten geprueft, alle drei abgelehnt
Ministral-3-3B: Architektur mistral3 steht nicht in der Liste des
rkllm-Toolkits, dazu FP8-Gewichte -- nie konvertiert. Config auf Llama
umbiegen waere moeglich gewesen, aber ein falsch eingelesenes Modell
haette plausiblen Unsinn produziert.

Phi-4-mini: konvertiert (5,06 GB), laedt aber nicht auf der NPU.
partial_rotary_factor 0.75 ergibt 48 LongRoPE-Faktoren, die Runtime
erwartet starr head_dim/2 = 64. Rockchips "Phi3" meint Phi-3-mini.

SmolLM3-3B: laeuft, in 2 von 3 Faellen schneller als Qwen3, scheitert
aber inhaltlich -- 13 statt 61 Todos, JSON-Schluessel als
Listeneintraege, Englisch-Drift im deutschen Text.

rkllm_server.cpp: Chat-Template kommt jetzt aus der Umgebung
(RKLLM_TMPL_SYSTEM / _USER_PRE / _USER_POST), Defaults = bisheriges
ChatML-Verhalten, mit Qwen3 gegengeprueft. Damit ist der naechste
Kandidat eine Drop-in-Zeile statt einer Neuuebersetzung.

Doku: Changelog 2026-09-29 (4) mit Messwerten und allen Fallstricken,
Kapitel 05 um Architektur-Vorpruefung und Template-Konfiguration
erweitert. Messstrecke: Zeitlimit 45 -> 90 Minuten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A91sWU5aWrqZ2DWNTLVph
2026-09-29 19:59:04 +02:00

30 KiB
Raw Permalink 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 --> 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.

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.

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.

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.

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:

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.

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.

Bevor ein Kandidat geladen wird: Architektur prüfen

Das rkllm-Toolkit 1.3.0 kennt nur diese Architekturen (aus loader.so/common.so extrahiert):

Baichuan, ChatGLM, Deepseek, DeepseekOCR, Gemma/2/3/3n/4, InternLM2, Llama,
MiniCPM/3, Phi/Phi3, Qwen2/2VL/2.5VL/3/3VL/3.5, RWKV7, SmolLM3, TeleChat/2

curl -sL https://huggingface.co/<repo>/raw/main/config.json und architectures dagegenhalten — Sekunden statt Gigabyte. Gelistet heißt aber nicht lauffähig: Phi-4-mini trägt Phi3ForCausalLM, scheitert aber an der Geometrie (partielle Rotary Embeddings, 48 statt 64 LongRoPE-Faktoren), weil Rockchips Phi3-Pfad auf Phi-3-mini zugeschnitten ist. Ebenso zu prüfen: quantization_config — FP8-Gewichte (z. B. Ministral 3) kann das Toolkit nicht lesen.

Chat-Template aus der Umgebung

rkllm wendet das HF-Template nicht selbst an. Ohne passende Marker bekommt das Modell den nackten Prompt und faselt generisches Füllmaterial. Seit 2026-09-29 kommen die Marker aus der Umgebung: RKLLM_TMPL_SYSTEM, RKLLM_TMPL_USER_PRE, RKLLM_TMPL_USER_POST (Defaults = ChatML, also unverändert für Qwen und Gemma3). \n/\t werden im Server dekodiert, weil systemd-Environment= keine echten Umbrüche kann — und aus demselben Grund beim Schreiben des Drop-ins zitiertes Heredoc statt printf.

Denk-Modelle (Qwen3.5 und jünger)

Modelle mit Denk-Modus liefern sonst Gedankengang statt JSON. RKLLMInput.enable_thinking = false genügt nicht — der Assistenten-Zug muss mit einem leeren Denkblock vorbefüllt werden. Dafür gibt es RKLLM_SUPPRESS_THINK=1: dann hängt rkllm_server.cpp <think>\n\n</think>\n\n an das Chat-Template. Ohne die Variable bleibt alles wie bisher.

Ergebnis des ersten Modellvergleichs (2026-09-29)

Qwen3.5-4B gegen das produktive Qwen3-4B, gleiche Konvertierungsparameter, drei Aufnahmen: 21-45 % langsamer, Zusammenfassungen auf ein Drittel bis Viertel gekürzt, und von 61 Todos blieben 2 — das Modell hält die JSON-Vorgabe nicht durch (13 Parse-Fehler mit Wiederholungen). Die Prosa selbst war nicht schlechter. Qwen3-4B bleibt produktiv. Lehre: bei diesem Aufbau entscheidet nicht der Benchmark, sondern ob ein Modell über lange deutsche Transkripte hinweg sauberes JSON liefert.

Am selben Tag zusätzlich geprüft: Ministral-3-3B (Architektur mistral3 nicht unterstützt, dazu FP8-Gewichte — nie konvertiert), Phi-4-mini (konvertiert, aber rkllm_init failed wegen partieller Rotary Embeddings) und SmolLM3-3B (läuft, stellenweise schneller als Qwen3, scheitert aber inhaltlich: 13 statt 61 Todos, JSON-Schlüssel als Listeneinträge, Englisch-Drift). Drei Kandidaten, drei Absagen — Details im Changelog 2026-09-29 (4).