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:
@@ -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`.
|
||||
Reference in New Issue
Block a user