# 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 `