# 08 — Changelog Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). ## 2026-09-29 (3) — A/B-Ergebnis: Qwen3.5-4B verliert, Qwen3-4B bleibt produktiv **Durchgeführt:** Qwen3.5-4B selbst konvertiert (RTX 3090, rkllm-toolkit 1.3.0 im Container, `w8a8_g128`, `max_context 16384`, 3 NPU-Kerne — identisch zum produktiven Modell), 5,53 GB, Konvertierung 308 s. Übertragung aufs Board per md5 verifiziert. Danach beide Modelle über `lab/ab-llm.mjs` gegen dieselben drei Aufnahmen. | Aufnahme | Zeichen | Qwen3-4B | Qwen3.5-4B | Tempo | Summary | Todos | |---|---|---|---|---|---|---| | #86 | 8 390 | 10,5 min | 13,1 min | **+25 %** | 2 896 → 848 | 6 → **0** | | #123 | 19 275 | 24,5 min | 29,7 min | **+21 %** | 6 071 → 3 064 | 18 → **2** | | #114 | 34 843 | 41,8 min | 60,8 min | **+45 %** | 9 288 → 2 292 | 37 → **0** | **Urteil: Qwen3-4B-Instruct-2507 bleibt.** Qwen3.5 ist auf unserer NPU durchgängig langsamer, und der Ausschlag ist nicht das Tempo, sondern die **Todos**: von 61 extrahierten Aufgaben über die drei Gespräche bleiben 2 übrig. Die Aufgabenliste ist eine Kernfunktion — damit ist das Modell für uns unbrauchbar, unabhängig von Benchmark-Werten (Artificial-Analysis-Index 27 vs. 18). **Fair gegenüber Qwen3.5:** die *Prosa* ist nicht schlechter, eher dichter. Bei #123 nennt Qwen3.5 Philipp korrekt als neues Mitglied, während Qwen3 ihn zur „neuen Teilnehmerin" macht. Das Modell scheitert nicht am Verstehen, sondern an der **Formatdisziplin**: 13 JSON-Parse-Fehler mit Wiederholungen während der Läufe (`chunk 12 unparseable -> retry 3`), im Ergebnis leere `todos`-Arrays, und die Zeilenumbrüche landen teils als literales `\n` im Text. ### Neu: `RKLLM_SUPPRESS_THINK` in `rkllm_server.cpp` Erster Versuch scheiterte komplett: Qwen3.5 antwortete mit Gedankengang statt JSON (`llm: combined JSON unparseable`, Auszug: „Wait, looking at line …"), Analyse brach ab. **`RKLLMInput.enable_thinking = false` reicht bei diesem Modell NICHT.** Das Denken schaltet erst ab, wenn der Assistenten-Zug mit einem **leeren Denkblock** vorbefüllt wird — so macht es das `chat_template.jinja` des Modells (Zeile 149-152): `{{- '\n\n\n\n' }}`. `rkllm_server.cpp` hängt diesen Block jetzt an das Chat-Template an, wenn `RKLLM_SUPPRESS_THINK=1` in der Umgebung steht; ohne die Variable bleibt das Template unverändert (Qwen3, Gemma3, Qwen2.5 brauchen es nicht). Neu übersetzt auf dem Board: `g++ -O2 -std=c++17 -o rkllm_server rkllm_server.cpp -I/usr/include -L/lib -lrkllmrt`. Backups: `rkllm_server.cpp.bak-think`, `rkllm_server.bak-think`. **Diese Änderung bleibt** — sie kostet nichts und macht jeden künftigen Denk-Modell-Test möglich. ### Zur Frage rknn-llm v1.3.1 (erschienen am selben Tag) Enthält u. a. eine *linear attention cache reuse strategy*, die auf Issue #519 zielt (unter 1.3.0 meldet RKLLM bei Qwen3.5 „Current model does not support kv cache reuse"). **Für uns wirkungslos:** unser Server fährt bewusst `keep_history = 0` und ruft `rkllm_clear_kv_cache()` vor jedem Lauf, damit kein Kontext zwischen Aufnahmen durchsickert — es gibt nichts wiederzuverwenden, und die Warnung taucht in unserem Journal gar nicht auf. Der gemessene Rückstand liegt zudem beim Decode (weniger Text in mehr Zeit), nicht beim Prefill, den ein Cache-Reuse entlasten würde. Update daher nicht durchgeführt. ### Zustand nach dem Test Modell-Drop-in auf `qwen3-4b-w8a8g128-16k.rkllm` zurückgesetzt, `restore` hat die Originalanalysen von #86, #112, #114 und #123 zurückgeschrieben (geprüft: 19,4 min / 7 Todos, 58,5 min / 32 Todos, 38,4 min / 16 Todos). Das konvertierte `qwen35-4b-w8a8g128-16k.rkllm` (5,9 GB) bleibt unter `/opt/rkllm-models/` liegen — für einen späteren Versuch mit angepasstem Prompt oder neuerer Runtime. **GOTCHA für künftige Läufe:** `lab/ab-llm.mjs` hat ein Zeitlimit von 45 Min pro Aufnahme; #114 brauchte 60,8 Min und wurde deshalb in `run-qwen35.json` als `pending` eingefroren, obwohl die Analyse später sauber durchlief. Die echten Werte standen in der DB. Bei langen Gesprächen `TIMEOUT_MS` hochsetzen. ## 2026-09-29 (2) — A/B-Aufbau Qwen3.5-4B (Sicherung, Messstrecke, Konvertierungs-Kit) **Anlass:** Recherche zur Frage, ob es inzwischen ein besseres LLM gibt als das produktive Qwen3-4B-Instruct-2507. Ergebnis: **Qwen3.5** (02.03.2026, Apache 2.0, Größen 0,8/2/4/9B, hybride Gated-DeltaNet-Architektur, 262k Kontext, 201 Sprachen) ist der ernsthafte Kandidat — im Artificial-Analysis-Index springt die 4B von 18 auf 27. **rknn-llm v1.3.0** (17.06.2026) hat die Unterstützung dafür gebracht, und genau diese Version läuft bereits auf dem Board (`RKLLM SDK 1.3.0 | build 82bf4aa8e 2026-06-16`, NPU-Treiber v0.9.8) — **kein Runtime-Upgrade nötig.** ### Sicherung vor dem Test Gezielte Sicherung nach `D:\Vibe Coding\_Spielplatz\Aime-Backup-2026-09-29` (nicht im Repo): `/opt/callrec` ohne `recordings/`+`models/` (14,7 MB), `/etc/systemd/system` + `sudoers.d` + Netz/Host (7,4 KB), WirePlumber/PipeWire-Drop-ins (1,5 KB), MariaDB-Dump (8,0 MB), Zustandsbericht mit den md5 aller `.rkllm`-Modelle. Alle Prüfsummen nach der Übertragung verifiziert. Aufnahmen (5,4 GB) und Modelle (14 GB) bewusst nicht mitgezogen — die liegen unverändert auf dem Board, die Vollsicherung des rootfs ist die vom 2026-08-03. ### Warum der Wechsel billig rückgängig zu machen ist Das aktive Modell hängt an einer einzigen systemd-Zeile, `/etc/systemd/system/callrec.service.d/gemma-model.conf` → `Environment=RKLLM_MODEL=...` (`llm.js:10` liest `process.env.RKLLM_MODEL`). Zurückschalten = Pfad tauschen, `daemon-reload`, `systemctl restart callrec`. Die alten `.rkllm` bleiben liegen. ### Neu: Messstrecke `lab/ab-llm.mjs` Der Vergleich läuft über `POST /api/recordings/:id/reanalyze-llm` — **ohne** Neu-Diarisierung, also gleiches Transkript, gleiche Sprecher, gleiche Kontextnotiz. Gemessen wird nur das Modell. ``` node lab/ab-llm.mjs snapshot 86 123 114 # Originalanalysen sichern (PFLICHT, reanalyze ueberschreibt) node lab/ab-llm.mjs run qwen3 86 123 114 # Baseline mit dem aktuellen Modell # ... Modell umstellen, callrec neu starten ... node lab/ab-llm.mjs run qwen35 86 123 114 node lab/ab-llm.mjs report qwen3 qwen35 # Tabelle + Volltext-Vergleich als Markdown node lab/ab-llm.mjs restore # Originalanalysen zurueckschreiben ``` Testfälle bewusst über die Pfadgrenzen gelegt: **#86** (8,4k Zeichen, kurzer Whole-Pass), **#123** (19,3k — die Größenordnung, an der Gemma im Juli-A/B die Gewichtung verlor), **#114** (34,8k, also über `WHOLE_MAX_CHARS`=32000 → gestückelter Sektions-Pfad). ### Neu: `tools/rkllm-qwen35/` (Konvertierung auf dem PC) `Dockerfile` (CUDA 12.4 + torch 2.6.0-cu124 + rkllm-toolkit **1.3.0**, Version muss zur Board-Runtime passen), `convert_qwen35.py` und `run_convert.ps1`. Parameter identisch zum produktiven Modell, damit der A/B das Modell misst und nicht die Konvertierung: `w8a8_g128`, 3 NPU-Kerne, `max_context=16384` (Toolkit-Obergrenze; die Runtime nutzt davon 12288, `rkllm_server.cpp:52`). **GOTCHAs, festgehalten:** - `run_convert.ps1` gehört in die **PowerShell**, nicht in die Git-Bash — die schreibt Docker-Mount-Pfade um (`/data` → `C:/Program Files/Git/data`). Gleiche Falle wie bei der Qwen3-Konvertierung im Juli. - torch wird im Dockerfile **vor** den übrigen Requirements und explizit als cu124-Rad installiert, und `torch`/`torchvision` werden aus `requirements.txt` gefiltert — sonst zieht pip die CPU-Räder nach und die Quantisierung läuft stundenlang auf der CPU. - `auto_gptq` darf fehlschlagen (nur für den GPTQ-Pfad nötig, nicht für w8a8). - Thinking ist kein Problem: Qwen3.5 denkt per Default, aber `rkllm_server.cpp:89` setzt bereits `in.enable_thinking = false`. **Kalibrierung:** `make_calib.py` baut aus echten deutschen Transkripten einen Kalibriersatz (`[{"input":..,"target":..}]`, 64 Ausschnitte à ~1400 Zeichen). Der Satz bleibt bewusst **auf dem Board** (`/tmp/calib-de.json`) — Gesprächsinhalte wandern nicht auf den PC. Wer ihn nutzen will, konvertiert auf dem Board oder kopiert ihn bewusst; ohne `--dataset` nimmt das Toolkit seinen eingebauten Datensatz. **Stand:** Baseline-Lauf `qwen3` gestartet, Modell noch nicht konvertiert — Ergebnis folgt in einem eigenen Eintrag. ## 2026-09-29 — Aufnahmezeitpunkt in der Detailansicht **Wunsch:** „Wenn man eine Aufzeichnung geöffnet hat, soll neben der Analysezeit auch das Datum und Uhrzeit der Aufzeichnung stehen. Denn wenn man z. B. aus der Suche heraus eine Aufzeichnung öffnet, sieht man links im Menü nicht wann diese aufgenommen wurde, da diese dort nicht ausgewählt wurde.“ **Warum es fehlte:** der Zeitpunkt stand bisher nur in der linken Aufnahmenliste (Tagesgruppe + Uhrzeit, `main.js:1134/1156`). Wer über die Inhaltssuche (Kap. 09), über einen Projekt-Pin (Kap. 10) oder über einen Direktlink in die Detailansicht springt, hat dort keine markierte Zeile — der Kopf der Detailansicht zeigte Titel, Player und höchstens „⏱ Analysezeit“, aber nie, wann das Gespräch stattfand. **Was geändert wurde** (`web/src/main.js`, Backup `main.js.bak-rectime`) - Neuer Helfer `recStamp(r)` neben `anaTimeSpan(r)`: formatiert `r.created_at` als `Di., 29.09.2026, 14:23 Uhr` (`toLocaleDateString`/`toLocaleTimeString` mit `de-DE`, Wochentag kurz). Leerer String bei fehlendem oder unparsbarem Datum → die Zeile fällt dann einfach weg. - In der `.status-line` der Detailansicht steht die Zeile **vor** der Analysezeit: `🗓 ` / `⏱ Analysezeit: …`. - Keine Server- oder DB-Änderung nötig: `GET /api/recordings/:id` liefert `created_at` bereits mit. **Gegenprobe:** `npx vite build` → Bundle `index-DtL7bqdT.js` (enthält `rec-time`), `curl http://127.0.0.1/` liefert genau dieses Bundle. `md5sum` von `web/src/main.js` auf dem Board und `app/web/src/main.js` im Repo identisch. Kein `systemctl restart` (reiner Frontend-Build). ## 2026-09-09 — GUI-Hinweis „A76-Takt gegen Überhitzung gedrosselt“ entfernt **Wunsch:** „Kannst du bitte den Hinweis nicht mehr anzeigen lassen“ (Screenshot des Klammerzusatzes `(A76-Takt gegen Überhitzung gedrosselt)`). **Was geändert wurde** - `web/src/main.js:1378`: der Wartetext während `status=transcribing` lautet jetzt nur noch „📝 Transkription läuft… lange Aufnahmen dauern.“ — der Klammerzusatz zum Takt-Cap ist weg. Der Hinweis selbst bleibt stehen, weil er die Wartezeit erklärt. - **Nur Anzeige, keine Technik:** das Takt-Cap (`aime-cpucap.sh`, 2256 → 1608 MHz) läuft unverändert für alle drei schweren Pfade weiter (Kap. 01 und 03) — es wird nur nicht mehr in der GUI erwähnt. - Backup auf dem Board: `web/src/main.js.bak-throttlehint`. **Build-GOTCHA:** `npx vite build` als `callrec` scheiterte mit `EACCES: permission denied, unlink '/opt/callrec/web/dist/assets/index-DAWZGY3G.js'` — `dist/assets` gehörte **root** (ein früherer Build lief als root), und vite leert `outDir` vor dem Schreiben. Fix: `chown -R callrec:callrec /opt/callrec/web/dist`, danach Build als `callrec` → neues Bundle `index-C_J2NgHg.js`. **Gegenprobe:** `grep -c 'A76-Takt'` im neuen Bundle = 0, `curl http://127.0.0.1/` liefert `index-C_J2NgHg.js`. Kein `systemctl restart` nötig (reiner Frontend-Build). ## 2026-08-26 — Web-GUI auf Port 80 (Aufruf ohne Portnummer) **Wunsch:** „Kann man es nicht über Port 80 laufen lassen? Dann müsste man nur die IP bzw. Aime in den Browser eingeben ohne die Portnummer." **Warum es vorher 3000 war:** die Unit startet Node als `User=callrec` (uid 1000). Ports unter 1024 sind privilegiert (`net.ipv4.ip_unprivileged_port_start = 1024`), ein unprivilegierter Prozess darf sie nicht binden. 3000 war also reine Bequemlichkeit, kein technischer Zwang. Port 80 war frei — kein nginx/Apache auf dem Board (`ss -tlnp`: nur 22, 3000, 3306, 53/5355). **Was geändert wurde** - `/etc/systemd/system/callrec.service`: `Environment=PORT=80` statt `3000`, dazu **`AmbientCapabilities=CAP_NET_BIND_SERVICE`** — damit darf der `callrec`-Prozess den privilegierten Port binden, ohne als root zu laufen. Kein Reverse-Proxy: nginx hätte 20–40 MB RAM gekostet, die neben dem residenten rkllm (~6,5 GB von 7,74 GB) nicht frei sind (siehe OOM-Historie in Kapitel 05). - Kein Code-Eingriff nötig: `server.js:941` liest `process.env.PORT`, und das Frontend spricht die API ausschließlich über relative Pfade (`/api/...`) an. Einziger `:3000`-Rest im Code ist der Dev-Proxy in `web/vite.config.js` (betrifft nur `npm run dev`). **Gegenprobe:** `callrec listening on :80`, `ss -tlnp` zeigt `0.0.0.0:80 node`, vom PC aus liefern `http://192.168.1.48/` und `http://aime.local/` je **200**, `/api/health` antwortet. `:3000` ist bewusst tot (Entscheidung des Nutzers: keine Parallel-Weiterleitung) — **alte Lesezeichen mit `:3000` müssen angepasst werden.** **Nebenbefund (nicht gefixt):** die Doku führte das Board noch unter `192.168.1.200`; aktuell hat es per DHCP `192.168.1.48` (WLAN), LAN-Kabel steckt nicht. README und Kapitel 07 nennen jetzt `aime.local` als stabilen Weg. **Dateien/Backups:** `/etc/systemd/system/callrec.service.bak-port3000` auf dem Board. `systemctl daemon-reload` + `restart callrec`; vorher `analysis_status='pending'` geprüft (leer) und `/api/health` auf `recording:false` (Regel: kein Neustart während einer Analyse). ## 2026-08-26 — Diagnose: „Aufnahme läuft seit tausenden Minuten" war eine Geisteranzeige **Meldung:** Morgens stand in der GUI eine laufende Aufnahme mit vierstelliger Minutenzahl. **Befund: es lief nichts.** `/api/health` → `recording:false`, kein `arecord`/`ffmpeg`-Prozess, letzter Journal-Eintrag von `callrec` war 25.08. 10:00:53, neueste Datei in `recordings/` war `rec_1787648427247.wav` vom 25.08. 09:21, neueste DB-Zeile #123 (25.08., 21,5 min, `done/done`). Kein Datenverlust, kein Plattenverbrauch. **Ursache, zwei Teile in `web/src/main.js`:** 1. Die Aufnahmeuhr läuft rein im Browser (`setInterval` auf `Date.now() - state.recStart`, Zeile ~304). Der Serverzustand wird **nur einmal beim Laden** geprüft (`rehydrateRecording()`, Zeile ~1951); während „Aufnahme läuft" pollt die GUI `/api/health` nie und es gibt keinen Abgleich zwischen Tabs. Ein Reiter, der den Stop nicht selbst ausgelöst hat (zweites Gerät, zweiter Tab), zählt darum ewig weiter. 2. `fmt()` (Zeile 33) kennt nur `MM:SS`, keine Stunden — 21 h Geisterlauf erscheinen als `1280:34` und lesen sich wie „1280 Minuten". **Offener Vorschlag (vom Nutzer noch nicht beauftragt):** `recording` auf dem bestehenden 5-s-`sysmon`- Tick mitliefern, damit ein verwaister Reiter sich selbst aufräumt, und `fmt()` um Stunden erweitern. ## 2026-08-20 — Stop-Knopf reagiert sofort (MP3-Encode in den Hintergrund) **Meldung:** „Wenn ich eine laufende Aufnahme stoppen will, passiert auf der GUI nichts. Ich muss den Reiter wechseln und zurück, dann kann ich nochmal auf Stopp klicken und dann geht's." **Gemessene Ursache:** `POST /api/record/stop` wartete auf den kompletten `loudnorm`-MP3-Encode — **~1,4 s pro aufgenommener Minute** (#114: 35,5 min → 50,9 s; #118: 30,8 min → 43,8 s; berechnet aus `wav_file`-Zeitstempel + `duration_ms` gegen `created_at`). Die GUI zeigte dabei nur einen ausgegrauten Knopf. Der Reiterwechsel löst `render()` aus, das den Knopf frisch und klickbar aufbaut, obwohl `state.recording` noch `true` ist → der zweite Klick schickte einen zweiten Stop, der an `not recording` scheiterte (`stopAndSave()` setzt `current` in seiner ersten Zeile auf `null`). **Nicht** die Kontextnotiz vom 2026-08-17: die Wartezeit gab es vorher genauso (#113 am 14.08.: 44,9 s). **Was geändert wurde** - `recorder.js`: `stopAndSave()` verschiebt das WAV und startet `encodeMp3()` **ohne `await`**. Der Encode schreibt nach `.mp3.part` und benennt erst danach um (nie eine halbe Datei im Player). `MP3_ARGS` bekam **`-f mp3`** — ohne das leitet ffmpeg den Muxer aus `.part` ab und bricht mit `Error initializing the muxer … Invalid argument` ab (erster Testlauf: MP3 fehlte komplett, beide Encode-Zweige waren still gescheitert). - `web/src/main.js`: `state.stopping` überlebt `render()` → Knopf bleibt über Reiterwechsel hinweg gesperrt (⏳ + „speichert…"), Uhr friert ein, Live-Karte sagt „Aufnahme wird gespeichert…", Toast nennt die geschätzte Wartezeit, zweiter Klick wird verworfen, `not recording` ist kein roter Fehler mehr. Dazu lädt der Player nach (`#mp3wait`, `error`-Ereignis → `audio.load()` alle 5 s, Abbruch nach 5 min). - `server.js`: `sweepOrphans` erkennt `rec_*.mp3.part`, damit eine beim Absturz liegengebliebene Halbdatei nach `ORPHAN_MIN_AGE_MS` mit weggeräumt wird. **Gegenprobe:** Stop antwortet in **0,033 s / 0,044 s** (12- und 15-s-Testclips #119/#120, danach in den Papierkorb), `mp3 fertig: …` erscheint ~2 s später im Journal, Datei ist 64 kbit CBR / 48 kHz / mono ohne Xing-Tabelle, `/media/…` liefert 200. **Dateien/Backups:** `recorder.js.bak-stopfast`, `server.js.bak-stopfast`, `main.js.bak-stopfix`, `main.js.bak-mp3wait`. Backend geändert ⇒ Neustart, vorher `analysis_status='pending'` geprüft (leer). Der Vite-Build wurde bis zum Ende der laufenden Analyse #118 aufgeschoben: bei 816 MB freiem RAM neben dem residenten rkllm (~6,5 GB) hätte er den OOM-Killer auf genau diese Analyse gezogen. Kapitel 03 aktualisiert (neuer Abschnitt „Der Stop-Knopf" mit Vorher/Nachher-Sequenzdiagramm). ## 2026-08-17 (Nachtrag 6) — Diagramme zur Systemanzeige Kapitel 07 nachgerüstet: ein Sequenzdiagramm für den Abfrageweg (GUI-Poll → `/api/sysstat` → `sysstat.js` → `/proc`/`statfs`, dazu der 60-s-Puffer des `recordings/`-Scans und der 2-s-Ticker für die CPU-Differenz) und ein Flussdiagramm für die beiden Rechenwege (Ring = Aufnahmen / (frei + Aufnahmen); Ventilator = Drehstufe aus CPU-%, Ampel aus dem schlimmsten Wert von CPU, RAM, CPU-Temperatur). Damit erfüllt die Änderung die Hausregel „neue Abläufe als Mermaid-Diagramm“. ## 2026-08-17 (Nachtrag 5) — „CPU-Temperatur“ statt Zonenname in Klammern Die Zeile heißt jetzt „CPU-Temperatur“ und zeigt nur noch `46,2 °C`; der Zonenname in Klammern (`littlecore-thermal` o. ä.) ist weg. Damit die Beschriftung auch stimmt, filtert `sysstat.js` die Zonen enger: nur `soc-thermal`, `bigcore0/1-thermal`, `littlecore-thermal` — GPU-, NPU- und Center-Zone fielen vorher unter „wärmste Zone“ und hätten den Wert stellen können, obwohl daran „CPU“ steht. Feld `temp.zone` bleibt in der API (fürs Debuggen), die GUI zeigt es nicht mehr. Backend geändert ⇒ Neustart nach Prüfung `pending=0`. ## 2026-08-17 (Nachtrag 4) — Erklärtext und Partitionszeile aus der Speicherkarte Endstand der Klappkarte: Titel, „Aufnahmen“ (GB + Dateizahl) mit Balken, „Noch frei“, „Gesamt für Aufnahmen“. Der Erklärtext zur 100-%-Bezugsgröße und die Fußzeile „Partition gesamt“ sind gestrichen — die Bezugsgröße ergibt sich aus den drei Zeilen selbst. `/api/sysstat` liefert `partPct`/`totalBytes` unverändert weiter, die GUI zeigt sie nur nicht mehr. Nur Frontend + Vite-Build. ## 2026-08-17 (Nachtrag 3) — Karte wieder vollständig, dafür umbruchfrei Der vermeintlich „fehlende Wert“ war ein **Zeilenumbruch**: bei „Gesamt für Aufnahmen 27,5 GB“ rutschte die Einheit allein in die nächste Zeile. Also alles wieder rein (Balken, „Noch frei“, „Gesamt für Aufnahmen“, Erklärtext, Partitions-Fußzeile) und stattdessen der Umbruch abgestellt: `.sys-row { white-space: nowrap }` + `.sys-pop-disk { min-width: 285px }`, der Erklärtext darf als einziger weiter umbrechen. Die in Nachtrag 2 entfernte Media-Query bleibt entfernt — die Prozentzahl am Ring war unter 900 px Fensterbreite ausgeblendet, das soll sie nicht sein. Nur Frontend (`main.js`, `style.css` + Vite-Build), kein Dienstneustart. ## 2026-08-17 (Nachtrag 2) — Speicherkarte kurzzeitig auf eine Zeile gekürzt Auf Zuruf alles unterhalb der GB-Zeile entfernt (Balken, „Noch frei“, „Gesamt für Aufnahmen“, Erklärtext, Partitions-Fußzeile) und die Media-Query `@media (max-width: 900px) { .sys-val { display: none } }` gestrichen, die bei schmalem Fenster die Prozentzahl neben dem Ring ausblendete. Die Kürzung wurde in Nachtrag 3 wieder zurückgenommen, das Entfernen der Media-Query bleibt. ## 2026-08-17 (Nachtrag) — Ventilator statt Turbine, Speicher relativ zum Aufnahme-Vorrat **Zwei Korrekturen des Nutzers am selben Tag:** 1. **Icon soll wie ein Ventilator aussehen.** Der erste Entwurf (fünf schmale Tropfen ohne Gehäuse) las sich als Kleeblatt. Jetzt: vier gebogene Pinwheel-Flügel in einem feststehenden Gehäusering; nur die Flügelgruppe dreht. Die Form wurde vor dem Ausrollen ohne Browser geprüft — die Bézier-Segmente der Kandidaten wurden mit einem kleinen Python/Pillow-Skript abgetastet, rotiert und als PNG gerendert (`lab/preview_fan.py`), weil die Chrome-Anbindung an diesem Tag nicht verfügbar war. 2. **Prozentzahl soll den Aufnahme-Vorrat messen, nicht die Partition.** Neue Bezugsgröße: `100 % = freier Platz + Summe aller Audiodateien unter recordings/`, belegt = die Audiodateien. Das ist genau der Platz, den ein frisch aufgesetztes System dieser Ausbaustufe für Aufnahmen hätte; System, Modelle und `node_modules` fallen aus Zähler **und** Nenner heraus. Der `df`-Wert bleibt als Fußzeile („Partition gesamt“) in der Klappkarte. **Umsetzung:** `sysstat.js` scannt `recordings/` mit rekursivem `readdir` + `stat` (nur Audio-Endungen, kein `du`-Prozess) und puffert das Ergebnis 60 s; nur der allererste Aufruf wartet auf den Scan, sonst zeigte die Anzeige direkt nach dem Start einmal 0 %. Neue Felder `recBytes`, `recFiles`, `budgetBytes`, `partPct`; `usedPct` = `recBytes / budgetBytes`. `status()` ist dadurch `async` → die Route läuft jetzt über `wrap()`. GUI: Balken + Datei-Anzahl in der Karte, erklärende Fußnote, Fußzeile mit dem Partitionswert. Gegenprobe: Scan meldet 1 957 445 371 Bytes, `du -sb` sagt exakt dasselbe (103 Dateien, 52 mp3 + 51 wav) ⇒ 1,96 GB von 29,6 GB Vorrat = **6,6 %** (Partition dagegen 52 %). ## 2026-08-17 — Systemanzeige in der Kopfzeile: Speicherring + Turbine **Wunsch:** zwischen dem Bluetooth-Tab und dem Hell/Dunkel-Schalter eine Anzeige, die den belegten Speicherplatz in Prozent zeigt, dazu ein zweites Symbol, das bei Mauskontakt CPU-Last, RAM und Temperatur aufklappt — als Turbine, die sich mit steigender Last schneller dreht. **Umsetzung:** - Neues Modul `sysstat.js` mit `status()`: Platte über `fs.statfsSync` (gemessen wird die Partition mit den Aufnahmen, Prozentwert wie `df` inklusive root-Reserve), RAM aus `/proc/meminfo` (`MemAvailable`, nicht `MemFree`), CPU-% aus der Differenz zweier `/proc/stat`-Messpunkte, Temperatur als wärmste SoC-/Core-/Center-/GPU-/NPU-Zone (WLAN-Zone `iwlwifi_1` ausgeschlossen). Eigener 2-s-Ticker (`unref()`), sonst wäre die erste CPU-Antwort nach jedem Start 0 %. - `GET /api/sysstat` in `server.js` — ohne `wrap()`/Warteschlange, weil es reine `/proc`-Lesevorgänge sind und die Abfrage keinen Prozess startet. - GUI (`web/src/main.js`): `sysmonMarkup()` zeichnet beide Chips, `paintSysmon()` schreibt die Werte ins DOM, `tickSysmon()` pollt alle 5 s (im Hintergrund-Tab ausgesetzt). Ein einziger Ticker für alle `render()`-Läufe; direkt nach `render()` werden die letzten Werte zurückgemalt, weil `render()` die Kopfzeile komplett neu schreibt und die Chips sonst bis zum nächsten Poll leer stünden. - Turbine: fünf Flügel als SVG-Pfade, Drehdauer per CSS-Variable `--spin`. Die Dauer läuft bewusst in **Stufen** (7 s Leerlauf … 0,35 s ab 90 %) — eine stufenlos nachgeführte `animation-duration` setzt die laufende Drehung bei jedem Poll neu an und ruckt sichtbar. `prefers-reduced-motion` stoppt sie. - Speicherring: `` (Umfang exakt 100) ⇒ `stroke-dasharray` = Prozentwert. - Ampel: grün < 75 %, gelb ab 75 %, rot ab 90 %; bei der Turbine zählt der schlimmste Wert aus CPU, RAM und Temperatur (45 °C → 0 %, 90 °C → 100 %). - Karten öffnen rein über CSS (`.sys-chip:hover .sys-pop`, zusätzlich `:focus-within` für Tastatur) — kein Handler, der einen `render()`-Durchlauf überleben müsste. **Dateien:** neu `app/sysstat.js`; geändert `app/server.js` (`.bak-sysmon`), `app/web/src/main.js` (`.bak-sysmon`), `app/web/src/style.css` (`.bak-sysmon`). Vite-Build + `systemctl restart callrec` (vorher geprüft: keine Analyse `pending`). Verifiziert: `/api/sysstat` liefert 52,4 % Platte (`df` sagt 53 %), 46 °C, 8 Kerne. Doku: Kapitel 07. ## 2026-08-17 — Kontext zum Gespräch: ein Freitextfeld nach dem Stop **Wunsch:** nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten Call/Meeting zu ergänzen — das verbessert die Analyse nach dem Transkript erheblich, weil das Modell Personen, Firmen, Anlass und Eigennamen nicht mehr aus fehlerbehaftetem ASR-Text erraten muss. **Entscheidung des Nutzers: EIN Freitextfeld, kein Formular.** Ein Vorschlag mit Teilnehmer-, Anlass-, Projekt- und Begriffsfeldern wurde ausdrücklich verworfen: die Box benutzen später auch technisch nicht versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als vier Pflichtfelder. Geführt wird über den Platzhaltertext, die Struktur (Namen, Begriffe) zieht sich die Analyse selbst aus der Prosa. **Umsetzung:** - Neue Spalten `recordings.context_note` / `context_note_at` (`ensureSchema`), `db.setContextNote()` (Deckel 4 000 Zeichen, leerer Text löscht) und `db.getContextNote()`. - `PATCH /api/recordings/:id/context`. Die Antwort enthält `applies` und sagt damit **ehrlich**, ob die Notiz die laufende Verarbeitung noch erreicht: `auto` (wird noch transkribiert → fließt sicher ein), `running` (Analyse läuft schon, kann sie verpasst haben), `reanalyze` (fertig → nur ein neuer Lauf nutzt sie; die GUI fragt dann nach und startet `reanalyze-llm`). - `analyzeRecording()` liest die Notiz **erst kurz vor dem LLM-Aufruf** (`db.getContextNote`), nicht aus der Zeile vom Anfang: Sprecher-Erkennung und Transkription davor brauchen Minuten, und was der Nutzer in dieser Zeit tippt, soll noch mitzählen. - `llm.js`: neuer `contextBlock()` — die Notiz kommt in einem eigenen `"""`-Block mit der Auflage, sie **nur** für Schreibweisen, Zuordnung, Anlass und Gewichtung zu nutzen und daraus keine Aussagen, Ergebnisse oder Aufgaben abzuleiten. Eingespeist in alle Pfade: `analyzeSingle` (`PROMPT_CLEAN`, `PROMPT_CLEAN_LABELED`, `PROMPT_JSON`), `analyzeWhole` (`PROMPT_WHOLE_ANALYZE`) und `analyzeLong` (jeder Chunk-Prompt + Titel- und Überblick-Mikro-Call). Zwei Deckel: 1 200 Zeichen für die Prompts, die die Notiz einmal sehen, 600 für die Chunk-Prompts (dort landet sie in **jedem** Chunk von 3 200 Zeichen). Auf dem letzten von drei Chunk-Versuchen fällt sie mit dem `hint` weg. - GUI: Karte „📝 Kontext zum Gespräch“ über den Modulen der Detailansicht. Nach dem Stop steht sie offen und fokussiert (`state.ctxPromptFor`), sonst eingeklappt mit dem gespeicherten Text und „✏️ Bearbeiten“. Sie zeichnet sich **selbst** neu statt über `openRecording()` — ein Neuaufbau der Detailansicht setzt den `