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
+75
View File
@@ -0,0 +1,75 @@
"""Text-Embedding-Daemon für die Inhaltssuche (multilingual-e5-small, ONNX/CPU).
Warum kein sentence-transformers: das zöge torch + transformers ins venv, ~2 GB
auf einer Karte, auf der schon whisper, sherpa und ein 5-GB-LLM liegen. Hier
reichen `tokenizers` (Rust, schon installiert) und `onnxruntime` — beides ist
bereits für whisper/NPU da, es kommt keine neue Abhängigkeit dazu.
Protokoll wie spk_embed: eine JSON-Zeile rein, eine JSON-Zeile raus.
{"texts": ["query: ...", "passage: ..."]} -> {"vecs": [[...], [...]]}
Fehler: {"error": "..."}
Aufruf: python embed_text.py /opt/aime-models/e5-small
"""
import json
import sys
import os
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
MAX_LEN = 512
def log(msg):
print(msg, file=sys.stderr, flush=True)
def main():
if len(sys.argv) < 2:
log("usage: embed_text.py <model-dir>")
return 1
d = sys.argv[1]
tok = Tokenizer.from_file(os.path.join(d, "tokenizer.json"))
tok.enable_truncation(max_length=MAX_LEN)
tok.enable_padding()
so = ort.SessionOptions()
# Vier A55-Kerne, passend zum taskset in emb.js. Mehr Threads als Kerne
# kosten hier nur Kontextwechsel.
so.intra_op_num_threads = 4
so.inter_op_num_threads = 1
sess = ort.InferenceSession(os.path.join(d, "model.onnx"), so, providers=["CPUExecutionProvider"])
want = {i.name for i in sess.get_inputs()}
print("READY", flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
texts = json.loads(line)["texts"]
encs = tok.encode_batch(texts)
ids = np.array([e.ids for e in encs], dtype=np.int64)
mask = np.array([e.attention_mask for e in encs], dtype=np.int64)
feed = {"input_ids": ids, "attention_mask": mask}
# Manche ONNX-Exporte verlangen token_type_ids, andere kennen es nicht.
if "token_type_ids" in want:
feed["token_type_ids"] = np.zeros_like(ids)
feed = {k: v for k, v in feed.items() if k in want}
out = sess.run(None, feed)[0] # [B, T, H] last_hidden_state
# Mean-Pooling über die echten Token (Padding darf nicht mitzählen),
# danach L2 — so ist das Skalarprodukt direkt der Kosinus.
m = mask[..., None].astype(np.float32)
vec = (out * m).sum(axis=1) / np.clip(m.sum(axis=1), 1e-9, None)
vec = vec / np.clip(np.linalg.norm(vec, axis=1, keepdims=True), 1e-9, None)
print(json.dumps({"vecs": [[round(float(x), 6) for x in v] for v in vec]}), flush=True)
except Exception as e: # noqa: BLE001 - Daemon darf nie sterben
print(json.dumps({"error": f"{type(e).__name__}: {e}"}), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())