1. Aufnahmenliste: bei schmaler Spalte schob ein langer Titel die Knoepfe aus der Zeile, der Papierkorb-Knopf war nicht mehr erreichbar. Flex-Kinder haben min-width: auto, der Textblock schrumpft also nie unter seine Inhaltsbreite. Mit der dritten Schaltflaeche (Stecknadel) reichte der Platz nicht mehr. 2. 23 sichtbare Aufnahmen = 46 Dateien, im Ordner lagen 152: 106 verwaiste mit 242 MB, Rueckstand des alten deleteRecording, das nur die DB-Zeile entfernte. Einmalig geraeumt (Gegenprobe: keine der 106 stand in einer Zeile, danach fehlt keine benoetigte Datei) und sweepOrphans haelt es sauber -- laeuft hinter sweepTrash beim Start und taeglich, mit 2-h-Altersgrenze, weil eine Aufnahme erst verschoben und dann in die DB geschrieben wird. Die Papierkorb-Kette selbst war korrekt, mit einer Wegwerf-Zeile nachgewiesen: loeschen -> Papierkorb (Dateien bleiben), endgueltig -> Zeile und beide Dateien weg. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.4 KiB
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.
-- 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.
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.
Verwaiste Dateien (Aufräumlauf, 2026-08-02)
Der Altfehler hatte über Monate Spuren hinterlassen: 106 Dateien mit 242 MB, auf die keine Zeile mehr zeigte — bei 23 echten Aufnahmen (46 Dateien) lag also mehr Müll als Nutzdaten auf der Karte.
sweepOrphans in server.js räumt das jetzt automatisch: alles in recordings/, was dem Muster
rec_*.mp3|wav entspricht und in keiner Zeile vorkommt (Papierkorb eingeschlossen — dessen Dateien
bleiben ja absichtlich liegen), wird gelöscht. Läuft direkt hinter sweepTrash, beim Start und danach
täglich.
Altersgrenze 2 h als Sicherheitsnetz: beim Beenden einer Aufnahme wird die Datei erst nach
recordings/ verschoben und danach die Zeile geschrieben. In diesem Fenster sähe eine völlig gültige
Datei verwaist aus. Nachgemessen: eine drei Stunden alte Attrappe wird geräumt, eine frische bleibt liegen.
Damit gilt durchgehend: löschen ⇒ Papierkorb (Dateien bleiben), dort endgültig löschen ⇒ Zeile und WAV/MP3 sind weg. End-to-end geprüft.
Retention über AIME_TRASH_DAYS einstellbar (Vorgabe 30).
Inhaltssuche
Aufbau
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:
- 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. - IDF-Wertung: Kandidaten per
BOOLEAN MODEauswählen, aber mitNATURAL LANGUAGE MODEbewerten. 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.