feat(gui+search): Inhaltssuche mit Zeitstempel-Verweis, Papierkorb (30 Tage), Titel umbenennen

Drei vom Nutzer angeforderte Funktionen.

Titel umbenennen: PATCH /api/recordings/:id/title. Neue Spalte recordings.title_locked,
damit eine spaetere KI-Analyse einen von Hand gesetzten Titel nicht wieder ueberschreibt.

Papierkorb: DELETE setzt nur noch deleted_at, nichts wird angefasst. Wiederherstellen,
endgueltig loeschen, Aufraeumlauf beim Boot und taeglich (Boot-Lauf, weil das Board oft
tagelang aus ist). Behebt nebenbei einen Altfehler: das bisherige Loeschen liess die
words-Zeilen verwaist in der DB und die WAV/MP3-Dateien auf der Karte zurueck.

Inhaltssuche: neue Tabelle passages (1689 Zeilen aus 22 Aufnahmen) mit Sprecher und
Zeitfenster je Passage. Trefferliste kommt aus SQL in Millisekunden, die formulierte
Antwort mit Belegen [1][2] wird nachgereicht (Qwen3-4B, ~140 s) - deshalb zweistufig.
Hybrid: MariaDB FULLTEXT + Passage-Embedding + Dokument-Embedding aus Titel/Zusammenfassung
(multilingual-e5-small, ONNX/CPU, taskset A55). Ohne Modell degradiert die Suche sauber
auf Volltext statt auszufallen.

Gemessene Grenzen sind dokumentiert statt geschoent: e5-small trennt auf verrauschtem
ASR-Text schwach (Kosinus-Spanne aller Passagen nur 0,8085-0,8560; eine irrelevante
Passage schlug die richtige Antwort). Dokument-Ebene und IDF-Wertung heben die richtige
Passage auf Rang 11 von 296, aber nicht in die Top-8, die das LLM liest. Naechster Hebel
waere multilingual-e5-base.

Relative Datumsangaben werden deterministisch aufgeloest, nicht vom LLM - ein falsches
Kalenderfenster waere schlimmer als gar keins.

Neu: app/search.js, app/emb.js, app/inference/embed_text.py, docs/09-suche-und-verwaltung.md
Board-Backups: .bak-search
This commit is contained in:
2026-07-31 13:20:20 +00:00
parent c09827bf86
commit d5b9733e78
10 changed files with 3651 additions and 2286 deletions
+72
View File
@@ -3,6 +3,78 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-07-31 — Inhaltssuche, Papierkorb, Titel umbenennen (+ WLAN-Ausfall behoben)
Drei Funktionen auf Wunsch des Nutzers, dazu die Störung vom Vormittag. Details:
[09 – Suche & Verwaltung](09-suche-und-verwaltung.md).
### Vorab: Board war nicht erreichbar — SSID-Rename killt den PSK-Hash
Das Board lief weiter als USB-Soundkarte, war aber im Netz unsichtbar. `wpa_supplicant` hing auf
`ssid="RupuWolf5"`; das WLAN heißt seit dem Zusammenlegen der Bänder nur noch **`RupuWolf`** (4 BSSIDs,
2437/2462/5300/5500 MHz). Nach dem SSID-Fix scheiterte die Anmeldung **trotz unverändertem Passwort**:
```
wlP4p65s0: WPA: 4-Way Handshake failed - pre-shared key may be incorrect
CTRL-EVENT-SSID-TEMP-DISABLED ssid="RupuWolf" auth_failures=1 reason=WRONG_KEY
```
Ursache: die Conf speichert `psk=<64 Hex>`, und **WPA-PSK leitet diesen Hash per PBKDF2 mit der SSID als
Salt ab** — ein SSID-Rename entwertet jeden vorberechneten Hash. Fix: Hash aus dem Klartext der
`#psk="…"`-Kommentarzeile neu rechnen (`wpa_passphrase "RupuWolf" "$PLAIN"`).
Backups: `.bak-rupuwolf5` (alte SSID), `.bak-oldhash`.
Außerdem: Hostname auf **`AIme`** gesetzt, `avahi-daemon` installiert → **`AIme.local`** löst per mDNS auf
und ersetzt das IP-Suchen. Die FritzBox zeigt weiter `armbian-sd`, weil sie den Gerätenamen beim ersten
DHCP-Kontakt einfriert; ein Mitschnitt belegt, dass das Board korrekt `Hostname (12): "AIme"` sendet.
### Titel umbenennen
`PATCH /api/recordings/:id/title` + Stift in Liste und Detailkopf. Neue Spalte
**`recordings.title_locked`**: ein von Hand gesetzter Titel überlebt jede spätere KI-Analyse. Leeres Feld
löscht Titel und Sperre.
### Papierkorb, 30 Tage
`DELETE /api/recordings/:id` setzt nur noch `deleted_at`; Audio, Transkript und Analyse bleiben vollständig
liegen. Neue Ansicht über der Liste mit Resttagen (rot ab 3 Tagen), Wiederherstellen, endgültig Löschen.
Aufräumlauf beim Boot **und** täglich — der Boot-Lauf, weil das Board oft tagelang aus ist.
**Altfehler mitbehoben:** das bisherige Löschen ließ die `words`-Zeilen verwaist in der Datenbank und die
WAV/MP3-Dateien für immer auf der Karte zurück. `purgeRecording` räumt beides ab.
### Inhaltssuche mit Zeitstempel-Verweis
Neue Tabelle `passages` (1689 Zeilen aus 22 Aufnahmen), neuer Tab „Suche" in Google-Optik: Trefferliste
sofort aus SQL, formulierte Antwort mit Belegen `[1] [2]` wird nachgereicht (Qwen3-4B, **~140 s**). Jeder
Treffer nennt Meeting, Sprecher und Gesprächsminute und springt beim Klick genau dorthin.
Hybrid aus Volltext (MariaDB FULLTEXT), Passage-Embedding und Dokument-Embedding
(`multilingual-e5-small`, ONNX/CPU auf den A55-Kernen). **Ohne das Modell degradiert die Suche sauber auf
Volltext statt auszufallen.**
**Gemessene Grenzen — bewusst dokumentiert statt geschönt:**
- Volltext allein findet „Steueroptimierung" **nicht** (im Transkript steht eine andere Wortform) → genau
dafür ist die semantische Ebene da, die findet #71 mit `vec=0,92`.
- `e5-small` trennt auf verrauschtem ASR-Text schwach: für „Wie werden unsere Daten gesichert" lag die
Kosinus-Spanne **aller** 1689 Passagen bei nur 0,8085–0,8560, und eine irrelevante Passage („Agenda
zusammenpacken", 0,8560) schlug die richtige Antwort („Backup-Job startet um 11 Uhr", 0,8419).
- Gegenmaßnahmen: Dokument-Ebene aus Titel + Zusammenfassung (sauberer LLM-Text statt ASR-Rauschen) und
IDF-Wertung (Auswahl per `BOOLEAN MODE`, Wertung per `NATURAL LANGUAGE MODE` — Boolean gewichtet nicht
nach Seltenheit, weshalb „Daten"-Wiederholung „gesichert" schlug).
- Ergebnis: richtige Passage auf **Rang 11 von 296** statt gar nicht. Auffindbar, aber nicht in den Top-8,
die das LLM liest. **Nächster Hebel: `multilingual-e5-base`.**
Relative Datumsangaben („letzte Woche") werden **deterministisch** aufgelöst, nicht vom LLM — ein falsches
Kalenderfenster wäre schlimmer als keins. Der Datumsausdruck wird aus den Suchbegriffen entfernt.
### Dateien
Neu: `app/search.js`, `app/emb.js`, `app/inference/embed_text.py`, `docs/09-suche-und-verwaltung.md`.
Geändert: `app/db.js`, `app/server.js`, `app/web/src/main.js`, `app/web/src/style.css`, `README.md`.
Board-Backups: `.bak-search`. Nicht im Repo: `/opt/aime-models/e5-small/` (470 MB Modell).
## 2026-07-30 (nachmittags) — v2 machte den Uplink stumm, v3 behebt es
**Symptom (Nutzer, Testanruf 13:51):** „Die Gegenseite hat mich nicht mehr gehört." Regression aus v2 vom
+180
View File
@@ -0,0 +1,180 @@
# 09 — Inhaltssuche, Papierkorb, Titel umbenennen
Drei Funktionen, die am 2026-07-31 dazukamen. Die Suche ist der große Teil, die anderen beiden sind
kleine Ergänzungen an derselben Datenschicht.
## Titel nachträglich umbenennen
`PATCH /api/recordings/:id/title`. Der Stift steht in der Aufnahmeliste und im Detailkopf.
Der eigentliche Punkt ist die Spalte **`recordings.title_locked`**: sobald ein Titel von Hand gesetzt
wurde, darf die nächste KI-Analyse ihn nicht mehr überschreiben. Ohne diese Sperre hätte ein
„↻ Analyse neu" den selbst vergebenen Namen still wieder gegen einen LLM-Vorschlag getauscht.
```sql
-- db.js/updateAnalysis
UPDATE recordings SET title = CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END, ...
```
Ein leeres Feld löscht den Titel **und** die Sperre — danach darf die KI wieder benennen.
## Papierkorb (30 Tage)
Löschen setzt nur noch `recordings.deleted_at`. Nichts wird angefasst: Wörter, Marker, Passagen und die
WAV/MP3-Dateien bleiben liegen, damit eine Wiederherstellung vollständig ist und nicht halb.
```mermaid
flowchart LR
A[Aufnahme sichtbar<br/>deleted_at IS NULL] -->|"🗑 Löschen"| B[Papierkorb<br/>deleted_at = jetzt]
B -->|"↩︎ Wiederherstellen"| A
B -->|"✕ Endgültig (manuell)"| C[purgeRecording]
B -->|"Aufräumlauf: älter als 30 Tage"| C
C --> D["DELETE markers + words + passages<br/>DELETE recordings<br/>unlink wav + mp3"]
```
Der Aufräumlauf (`sweepTrash` in `server.js`) startet **beim Boot und danach täglich**. Der Boot-Lauf ist
nötig, weil das Board oft tagelang aus ist — ein reiner 24-Stunden-Timer würde das Fenster immer wieder
verpassen.
**Altfehler mitbehoben:** das frühere `deleteRecording` löschte nur `markers` und die `recordings`-Zeile.
Die `words`-Zeilen blieben verwaist in der Datenbank zurück und die Audiodateien für immer auf der Karte.
`purgeRecording` räumt jetzt beides mit ab.
Retention über `AIME_TRASH_DAYS` einstellbar (Vorgabe 30).
## Inhaltssuche
### Aufbau
```mermaid
flowchart TD
Q["Frage:<br/>„Welches Projekt hat X letzte Woche vorgestellt?“"] --> DR[parseDateRange<br/>deterministisch, kein LLM]
Q --> CT[contentTerms<br/>Stoppwörter raus]
DR -->|"Filter: 20.–27.07."| SEL
CT -->|"Projekt, X, vorgestellt"| SEL
subgraph SEL["Kandidaten + Bewertung (SQL, Millisekunden)"]
FT["Volltext<br/>BOOLEAN = Auswahl<br/>NATURAL LANGUAGE = Wertung (IDF)"]
VP["Passage-Embedding<br/>Kosinus, min-max"]
VD["Dokument-Embedding<br/>Titel + Zusammenfassung"]
end
SEL --> SC["score = 0,45·ft + 0,25·vec + 0,30·doc<br/>+ Sprecher + Wortgruppe + Aktualität"]
SC --> HITS["Trefferliste sofort<br/>Meeting · Sprecher · mm:ss"]
HITS --> LLM["Qwen3-4B über die Top-8<br/>~140 s, wird nachgereicht"]
LLM --> ANS["Antwort mit Belegen [1] [2]"]
```
Die Zweiteilung ist Absicht: die Trefferliste kommt aus SQL und steht sofort, die formulierte Antwort
braucht auf dieser NPU **~140 s**. Erst antworten und dann anzeigen hieße eine Minute Leerlauf.
### Passagen
Eine Passage ist die Sucheinheit: 2–4 Sätze **eines** Sprechers, ~420 Zeichen, harte Grenze 700. Sie wird
aus den `words`-Zeilen geschnitten, nicht aus `transcript_clean` — nur die Wörter tragen Zeitstempel, und
der Zeitstempel ist der ganze Zweck eines Treffers. Ein Sprecherwechsel beendet eine Passage immer.
Stand 2026-07-31: 22 Aufnahmen → **1689 Passagen** (Neuaufbau in ~1 s ohne, ~9 min mit Embeddings).
### Warum hybrid — mit Messwerten
Keine der beiden Ebenen genügt allein, und beide Grenzen sind an diesem Korpus **gemessen**:
| Frage | Volltext | Semantisch |
|---|---|---|
| `Steueroptimierung` | **0 Treffer** — im Transkript steht eine andere Wortform | findet #71, `vec=0,92` |
| `Wie können wir Steuern sparen` | trifft über „Steuern" | bestätigt #71 |
| `OneDrive` | 21 Treffer, exakt | Eigennamen verwischen im Embedding |
### Bekannte Grenze der semantischen Ebene
`multilingual-e5-small` trennt auf **verrauschtem ASR-Text** schwach. Gemessen für „Wie werden unsere Daten
gesichert" über alle 1689 Passagen:
```
Kosinus-Spanne der Kandidaten: 0,8085 .. 0,8560 (Breite nur 0,047)
cos=0,8560 #77 „Vielleicht packen wir nochmal eine Agenda zusammen…“ ← irrelevant, Rang 1
cos=0,8419 #85 „…jeden Abend um 11 Uhr der Backup-Job startet.“ ← die Antwort, Rang 5
```
Das Embedding selbst rankt also falsch; keine Normalisierung repariert eine falsche Reihenfolge. Grund ist
vermutlich, dass die ASR Komposita zerbricht („Backup -Job", „KI -Binnenstrich") — genau das, womit ein
Embedding am schlechtesten umgeht.
Zwei Gegenmaßnahmen sind drin und messbar wirksam, lösen den Fall aber nicht ganz:
1. **Dokument-Ebene** (`recordings.doc_vec`): Titel + Zusammenfassung sind saubere LLM-Prosa statt
ASR-Rauschen. Sagt „diese *Aufnahme* passt zur Frage", auch wenn keine einzelne Passage gut einbettet.
2. **IDF-Wertung**: Kandidaten per `BOOLEAN MODE` auswählen, aber mit `NATURAL LANGUAGE MODE` bewerten.
Boolean gewichtet nicht nach Seltenheit, weshalb eine Passage, die „Daten" mehrfach wiederholt, die
Passage schlug, die „gesichert" tatsächlich beschreibt.
Ergebnis für den schwierigen Fall: die richtige Passage steht auf **Rang 11 von 296** statt gar nicht — sie
ist auffindbar, erreicht aber die Top-8 nicht, die das LLM zu sehen bekommt. **Nächster Hebel wäre
`multilingual-e5-base`** (~1,1 GB statt 470 MB, Neuindexierung ~25–30 min).
### Relative Datumsangaben
„letzte Woche" wird **deterministisch** aufgelöst, nicht vom LLM: ein falsches Kalenderfenster ist
schlimmer als gar kein Filter, und Datumsarithmetik ist genau das, was ein 4B-Modell subtil falsch macht.
Wochen laufen Montag–Sonntag. Der erkannte Datumsausdruck wird aus den Suchbegriffen **entfernt** — sonst
sucht die Anfrage nach dem Wort „Woche".
Wenn nur ein Zeitraum und kein Suchbegriff übrig bleibt („Was lief letzte Woche?"), zeigt die Suche die
Aufnahmen dieses Zeitraums ab ihren Eröffnungspassagen statt einer leeren Seite.
### Embedding-Modell
`/opt/aime-models/e5-small/` — `model.onnx` (470 MB, fp32), `tokenizer.json`, `config.json`, geladen von
`intfloat/multilingual-e5-small`. Läuft über `inference/embed_text.py` als Daemon auf onnxruntime/CPU,
**`taskset -c 0-3`** (A55), damit die A76-Kerne für whisper und sherpa frei bleiben.
Bewusst **ohne** sentence-transformers: das zöge torch + transformers (~2 GB) auf eine Karte, auf der
bereits whisper, sherpa und ein 5-GB-LLM liegen. `tokenizers` und `onnxruntime` waren für den NPU-Pfad
ohnehin schon im venv.
Die e5-Präfixe `query: ` und `passage: ` sind Pflicht — ohne sie verliert das Modell deutlich an
Genauigkeit, weil genau dieses asymmetrische Paar kurze Fragen auf lange Passagen abbildet.
**Ohne das Modell degradiert die Suche sauber auf reinen Volltext** (`emb.available()` ist dann `false`),
sie fällt nicht aus. Das war die Voraussetzung dafür, die Suche vor dem Download in Betrieb zu nehmen.
### Wann neu indexiert wird
| Auslöser | Umfang |
|---|---|
| Analyse einer Aufnahme fertig | diese Aufnahme (nach `relabelAnalysis`, damit die Sprechernamen stehen) |
| Sprecher umbenannt | diese Aufnahme (`speaker_name` liegt denormalisiert in `passages`) |
| Dienststart | alle Aufnahmen **ohne** Passagen (Nachzügler) |
| `POST /api/search/reindex {"force":true}` | alles |
## API
| Route | Zweck |
|---|---|
| `PATCH /api/recordings/:id/title` | Titel setzen/löschen |
| `DELETE /api/recordings/:id` | in den Papierkorb |
| `GET /api/trash` | Inhalt + Resttage |
| `POST /api/trash/:id/restore` | wiederherstellen |
| `DELETE /api/trash/:id` | endgültig löschen (inkl. Dateien) |
| `GET /api/search?q=` | Trefferliste, sofort |
| `POST /api/search/answer` | formulierte Antwort mit Belegen (NPU-Warteschlange) |
| `GET /api/search/status` | Passagen, davon eingebettet, Modell da? |
| `POST /api/search/reindex` | Index neu bauen (`{"force":true}` = alles) |
## Stellschrauben
| Variable | Vorgabe | Wirkung |
|---|---|---|
| `AIME_TRASH_DAYS` | 30 | Aufbewahrung im Papierkorb |
| `AIME_PASSAGE_CHARS` | 420 | Ziellänge einer Passage |
| `AIME_SEARCH_TOPK` | 20 | Treffer je Suche |
| `AIME_ANSWER_PASSAGES` | 8 | Passagen für die LLM-Antwort (5 ≈ 90 s statt 140 s) |
| `AIME_EMB_DIR` | `/opt/aime-models/e5-small` | Embedding-Modell |
## Dateien
`app/search.js` (neu), `app/emb.js` (neu), `app/inference/embed_text.py` (neu), `app/db.js`,
`app/server.js`, `app/web/src/main.js`, `app/web/src/style.css`.
Backups auf dem Board: `.bak-search`.