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
+1
View File
@@ -21,6 +21,7 @@ KI-Zusammenfassung (Gemma-3-4B auf der NPU) — **komplett offline**, keine Clou
| [06 – iPhone-Bluetooth](docs/06-iphone-bt.md) | HFP-Pfad, Routing-Watcher, Echo-Fix | | [06 – iPhone-Bluetooth](docs/06-iphone-bt.md) | HFP-Pfad, Routing-Watcher, Echo-Fix |
| [07 – Betrieb](docs/07-betrieb.md) | Services, Zugänge, Backups, Troubleshooting | | [07 – Betrieb](docs/07-betrieb.md) | Services, Zugänge, Backups, Troubleshooting |
| [08 – Changelog](docs/08-changelog.md) | Chronik aller Entwicklungsschritte | | [08 – Changelog](docs/08-changelog.md) | Chronik aller Entwicklungsschritte |
| [09 – Suche & Verwaltung](docs/09-suche-und-verwaltung.md) | Inhaltssuche mit Zeitstempel-Verweis, Papierkorb, Titel umbenennen |
## System-Überblick ## System-Überblick
+82 -4
View File
@@ -55,6 +55,32 @@ export async function ensureSchema() {
conf VARCHAR(8) NULL, conf VARCHAR(8) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX rec_idx (recording_id))`); INDEX rec_idx (recording_id))`);
// Trash instead of hard delete: `deleted_at` set = in the bin, rows and audio
// files stay untouched until purgeExpired() passes TRASH_DAYS. Every listing
// filters on it, so a binned recording is invisible everywhere else.
// `title_locked` marks a title the USER typed — a later re-analysis must not
// overwrite it with a fresh LLM guess (see updateAnalysis).
await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS title_locked TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS doc_vec MEDIUMTEXT NULL`);
// Search index. One row per passage (a few sentences of ONE speaker, see
// search.js/buildPassages), carrying the time window so a hit can name the
// exact minute and the player can jump there. `vec` = optional text embedding
// for semantic ranking; NULL when the embedding model is not installed, in
// which case search degrades to full-text only.
await pool.query(`CREATE TABLE IF NOT EXISTS passages (
id INT AUTO_INCREMENT PRIMARY KEY,
recording_id INT NOT NULL,
idx INT NOT NULL,
speaker VARCHAR(32) NULL,
speaker_name VARCHAR(128) NULL,
t_start_ms INT NOT NULL,
t_end_ms INT NOT NULL,
text TEXT NOT NULL,
vec MEDIUMTEXT NULL,
INDEX rec_idx (recording_id),
FULLTEXT KEY ft_text (text)) ENGINE=InnoDB`);
} }
// --- live speaker markers --------------------------------------------------- // --- live speaker markers ---------------------------------------------------
@@ -209,10 +235,13 @@ export async function mergeSpeakerNames(id, map) {
} }
// Store the LLM result. `todos` is an array → persisted as JSON text. // Store the LLM result. `todos` is an array → persisted as JSON text.
// A user-typed title (title_locked=1) survives every re-analysis: without the
// CASE the LLM would silently rename the recording back on the next run.
export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) { export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) {
const st = status || 'done'; const st = status || 'done';
await pool.execute( await pool.execute(
`UPDATE recordings SET title=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?, `UPDATE recordings SET title=CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END,
summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
WHERE id=?`, WHERE id=?`,
[title || null, summary || null, todos ? JSON.stringify(todos) : null, [title || null, summary || null, todos ? JSON.stringify(todos) : null,
@@ -220,6 +249,15 @@ export async function updateAnalysis(id, { title, summary, todos, participants,
st, Date.now(), id]); st, Date.now(), id]);
} }
// --- title rename ------------------------------------------------------------
// Empty/blank title unlocks again: the next analysis is then free to propose one.
export async function renameRecording(id, title) {
const t = (title || '').trim().slice(0, 255);
await pool.execute('UPDATE recordings SET title=?, title_locked=? WHERE id=?', [t || null, t ? 1 : 0, id]);
const [rows] = await pool.query('SELECT id,title,title_locked FROM recordings WHERE id=?', [id]);
return rows[0] || null;
}
function parseJsonCols(row) { function parseJsonCols(row) {
for (const c of ['todos', 'participants']) { for (const c of ['todos', 'participants']) {
if (row && typeof row[c] === 'string') { if (row && typeof row[c] === 'string') {
@@ -230,9 +268,11 @@ function parseJsonCols(row) {
} }
const parseTodos = parseJsonCols; const parseTodos = parseJsonCols;
const LIST_COLS = 'id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at';
export async function listRecordings() { export async function listRecordings() {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at FROM recordings ORDER BY id DESC'); `SELECT ${LIST_COLS} FROM recordings WHERE deleted_at IS NULL ORDER BY id DESC`);
return rows.map(parseTodos); return rows.map(parseTodos);
} }
@@ -248,7 +288,45 @@ export async function getRecording(id) {
return { ...parseTodos(rec), speaker_names, words, markers }; return { ...parseTodos(rec), speaker_names, words, markers };
} }
// --- trash -------------------------------------------------------------------
// Deleting only stamps `deleted_at`; nothing is destroyed. Everything (words,
// markers, passages, wav/mp3) stays put so a restore is complete, not partial.
export const TRASH_DAYS = +(process.env.AIME_TRASH_DAYS || 30);
export async function deleteRecording(id) { export async function deleteRecording(id) {
await pool.query('DELETE FROM markers WHERE recording_id=?', [id]).catch(() => {}); await pool.execute('UPDATE recordings SET deleted_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL', [id]);
await pool.query('DELETE FROM recordings WHERE id=?', [id]); }
export async function restoreRecording(id) {
await pool.execute('UPDATE recordings SET deleted_at=NULL WHERE id=?', [id]);
}
// Binned recordings, newest first, with the days left before auto-purge.
export async function listTrash() {
const [rows] = await pool.query(
`SELECT ${LIST_COLS}, deleted_at,
GREATEST(0, ? - TIMESTAMPDIFF(DAY, deleted_at, CURRENT_TIMESTAMP)) AS days_left
FROM recordings WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC`, [TRASH_DAYS]);
return rows.map(parseTodos);
}
// Hard delete. Returns the audio file names so the caller can unlink them —
// db.js deliberately does no filesystem work. Note this also clears the `words`
// rows, which the pre-trash delete leaked (they outlived their recording).
export async function purgeRecording(id) {
const [rows] = await pool.query('SELECT mp3_file, wav_file FROM recordings WHERE id=?', [id]);
const files = rows[0] ? [rows[0].mp3_file, rows[0].wav_file].filter(Boolean) : [];
for (const t of ['markers', 'words', 'passages'])
await pool.query(`DELETE FROM ${t} WHERE recording_id=?`, [id]).catch(() => {});
await pool.query('DELETE FROM recordings WHERE id=?', [id]);
return files;
}
// Ids whose retention window has run out. The caller purges them one by one so
// a single bad row cannot abort the whole sweep.
export async function expiredTrashIds() {
const [rows] = await pool.query(
'SELECT id FROM recordings WHERE deleted_at IS NOT NULL AND deleted_at < (CURRENT_TIMESTAMP - INTERVAL ? DAY)',
[TRASH_DAYS]);
return rows.map(r => r.id);
} }
+94
View File
@@ -0,0 +1,94 @@
// Text embeddings for semantic search (multilingual-e5-small via onnxruntime).
//
// Entirely optional: if the model directory is missing, available() is false,
// every call resolves to null and search.js silently falls back to full-text.
// That keeps the search working on a fresh install before anything is
// downloaded, instead of 500-ing.
//
// Same daemon pattern as spk.js: one long-lived python process on the A55 cores
// (4-7 are reserved for whisper/sherpa), line-delimited JSON over stdio, so the
// ~2 s model load is paid once instead of per query.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import readline from 'node:readline';
import path from 'node:path';
const PY = process.env.AIME_PY || '/opt/aime-venv/bin/python';
const DIR = process.env.AIME_EMB_DIR || '/opt/aime-models/e5-small';
const SCRIPT = '/opt/callrec/inference/embed_text.py';
// e5 models are trained with these prefixes and lose a lot of accuracy without
// them — the asymmetric pair is what makes a short question match a long passage.
const Q_PREFIX = 'query: ';
const P_PREFIX = 'passage: ';
const BATCH = +(process.env.AIME_EMB_BATCH || 16);
let proc = null, ready = null, rl = null, pending = null, chain = Promise.resolve();
export function available() {
return fs.existsSync(path.join(DIR, 'model.onnx')) && fs.existsSync(path.join(DIR, 'tokenizer.json'));
}
export function status() {
return { available: available(), dir: DIR, running: !!proc };
}
function start() {
if (proc) return ready;
if (!available()) return Promise.reject(new Error('Embedding-Modell fehlt: ' + DIR));
// Cores 0-3 (A55): the big cores stay free for whisper and sherpa, which are
// the actual bottleneck. Embedding a query is a few ms either way.
proc = spawn('taskset', ['-c', '0-3', PY, SCRIPT, DIR], { stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => process.stderr.write('[emb] ' + d));
proc.on('exit', () => { proc = null; ready = null; rl = null; if (pending) { pending.reject(new Error('emb-Daemon beendet')); pending = null; } });
ready = new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error('emb READY timeout')), 120000);
rl = readline.createInterface({ input: proc.stdout });
rl.on('line', line => {
if (line.startsWith('READY')) { clearTimeout(to); res(); return; }
if (!pending) return;
const p = pending; pending = null;
try {
const msg = JSON.parse(line);
if (msg.error) p.reject(new Error(msg.error)); else p.resolve(msg.vecs);
} catch (e) { p.reject(new Error('emb: unlesbare Antwort — ' + line.slice(0, 120))); }
});
});
return ready;
}
// Serialised through `chain`: the daemon handles one request at a time, and a
// concurrent write would interleave two JSON lines on the same stdin.
function request(texts) {
const run = async () => {
await start();
return new Promise((resolve, reject) => {
pending = { resolve, reject };
const to = setTimeout(() => { if (pending) { pending = null; reject(new Error('emb timeout')); } }, 120000);
const done = fn => v => { clearTimeout(to); fn(v); };
pending = { resolve: done(resolve), reject: done(reject) };
proc.stdin.write(JSON.stringify({ texts }) + '\n');
});
};
chain = chain.then(run, run);
return chain;
}
export async function embedQuery(text) {
if (!available()) return null;
const v = await request([Q_PREFIX + String(text || '').slice(0, 2000)]);
return v && v[0];
}
// Returns null (not a throw) when the model is absent, so indexing just stores
// passages without vectors.
export async function embedMany(texts) {
if (!available() || !texts || !texts.length) return null;
const out = [];
for (let i = 0; i < texts.length; i += BATCH) {
const batch = texts.slice(i, i + BATCH).map(t => P_PREFIX + String(t || '').slice(0, 2000));
out.push(...await request(batch));
}
return out;
}
export function stop() { if (proc) { try { proc.kill(); } catch {} proc = null; ready = null; } }
+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())
+420
View File
@@ -0,0 +1,420 @@
// Content search across all meetings: "which project did X present last week?"
//
// Retrieval is deliberately split from the answer. The passage hits come from
// SQL and are on screen in milliseconds; the written answer is a separate,
// slow (~5 tok/s on this NPU) LLM call the GUI loads afterwards. Asking the LLM
// first would mean staring at a spinner for a minute before seeing anything.
//
// Ranking is hybrid:
// full-text — MariaDB FULLTEXT over the passage text. Nails proper nouns
// ("Nordlicht", "Jeckel") that an embedding blurs away.
// semantic — cosine over text embeddings, so a question worded differently
// than the meeting still hits. Optional: without the model
// (see emb.js) `vec` stays NULL and search runs full-text only.
// boosts — speaker match, exact phrase, recency.
// Neither alone is enough: full-text misses paraphrases, embeddings miss names.
import { pool } from './db.js';
import * as emb from './emb.js';
import * as llm from './llm.js';
// A passage is the retrieval unit. Too short and a hit has no context to show
// or to answer from; too long and the timestamp stops meaning anything and the
// embedding averages several topics into mush. ~2-4 sentences of one speaker.
const PASSAGE_CHARS = +(process.env.AIME_PASSAGE_CHARS || 420);
const PASSAGE_MAX_CHARS = +(process.env.AIME_PASSAGE_MAX_CHARS || 700);
// A speaker change always ends a passage, but a long monologue is still split.
const TOPK = +(process.env.AIME_SEARCH_TOPK || 20);
const ANSWER_PASSAGES = +(process.env.AIME_ANSWER_PASSAGES || 8);
// German stop words. Natural-language FULLTEXT would rank on "welches"/"hat"
// and drown the two words that carry the question. MariaDB's built-in stopword
// list is English only, so the filtering has to happen here.
const STOP = new Set(`aber alle allem allen aller alles als also am an ander andere anderem anderen anderer anderes
auch auf aus bei beim bin bis bist da damit dann der den des dem die das dass daß derselbe derselben denn dazu dein
deine dem den der des dessen deshalb dies diese diesem diesen dieser dieses doch dort du durch ein eine einem einen
einer eines einig einige einigem einigen einiger einiges einmal er ihn ihm es etwas euer eure für gegen gewesen hab
habe haben hat hatte hatten hier hin hinter ich mich mir ihr ihre ihrem ihren ihrer ihres euch im in indem ins ist
jede jedem jeden jeder jedes jene jenem jenen jener jenes jetzt kann kannst können könnt machen man manche manchem
manchen mancher manches mein meine mit muss musst müssen nach nicht nichts noch nun nur ob oder ohne sehr sein seine
seinem seinen seiner seines selbst sich sie sind so solche solchem solchen solcher solches soll sollte sondern sonst
über um und uns unse unsem unsen unser unses unter viel vom von vor während war waren warst weg weil weiter
wenn werde werden wieder will wir wird wirst wollen wollte würde würden zu zum zur zwar zwischen mal schon ja nein
okay halt eben eigentlich quasi irgendwie
was wer wen wem wessen wann wo wohin woher wie warum wieso weshalb welche welchem welchen welcher welches
wurde wurden worden gibt gab geht ging steht stand macht gemacht gesagt sagt sagte bitte danke`.split(/\s+/));
// Query words worth searching for.
//
// Capitalisation is NOT a licence to keep a stop word: German capitalises every
// noun, and a question starts with a capitalised "Welches"/"Was"/"Wer". Keeping
// those was what made "Welches Projekt hat X letzte Woche vorgestellt" search
// for "Welches" and "Woche" and rank the wrong passages to the top.
function contentTerms(q) {
const raw = (q || '').split(/[^\p{L}\p{N}äöüßÄÖÜ-]+/u).filter(Boolean);
const out = [];
for (const w of raw) {
if (w.length < 3) continue; // below innodb_ft_min_token_size
if (STOP.has(w.toLowerCase())) continue;
out.push(w);
}
return [...new Set(out)];
}
// --- relative dates ----------------------------------------------------------
// "letzte Woche" is answered deterministically, not by the LLM: a wrong week is
// worse than no filter at all, and date arithmetic is exactly what a 4B model
// gets subtly wrong. Weeks are Mon-Sun (ISO), matching how people say it here.
function startOfDay(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }
function mondayOf(d) {
const x = startOfDay(d);
x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); // getDay(): 0=Sun
return x;
}
const MONTHS = ['januar', 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember'];
// Returns {from, to, label, match} — `match` is the text that expressed the date
// so search() can strip it before extracting search terms. Leaving "letzte
// Woche" in the terms makes every passage containing "Woche" a candidate.
export function parseDateRange(q, now = new Date()) {
const s = (q || '').toLowerCase();
const day = 86400000;
const rules = [
[/\bvorgestern\b/, () => [new Date(+startOfDay(now) - 2 * day), new Date(+startOfDay(now) - day), 'vorgestern']],
[/\bgestern\b/, () => [new Date(+startOfDay(now) - day), startOfDay(now), 'gestern']],
[/\bheute\b/, () => [startOfDay(now), new Date(+startOfDay(now) + day), 'heute']],
[/\b(diese|dieser)\s+woche\b/, () => [mondayOf(now), new Date(+mondayOf(now) + 7 * day), 'diese Woche']],
[/\b(letzte|letzter|letzten|vergangene|vergangener|vergangenen|vorige|voriger|vorigen)\s+woche\b/, () => {
const from = new Date(+mondayOf(now) - 7 * day);
return [from, new Date(+from + 7 * day), 'letzte Woche'];
}],
[/\b(diesen|diesem)\s+monat\b/, () =>
[new Date(now.getFullYear(), now.getMonth(), 1), new Date(now.getFullYear(), now.getMonth() + 1, 1), 'diesen Monat']],
[/\b(letzten|letztem|vergangenen|vorigen)\s+monat\b/, () =>
[new Date(now.getFullYear(), now.getMonth() - 1, 1), new Date(now.getFullYear(), now.getMonth(), 1), 'letzten Monat']],
[new RegExp(`\\b(im\\s+)?(${MONTHS.join('|')})\\b`), (m) => {
const mi = MONTHS.indexOf(m[2]);
// A month still ahead of us this year must mean last year's.
const year = mi > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear();
return [new Date(year, mi, 1), new Date(year, mi + 1, 1), MONTHS[mi][0].toUpperCase() + MONTHS[mi].slice(1)];
}],
];
for (const [re, fn] of rules) {
const m = re.exec(s);
if (!m) continue;
const [from, to, label] = fn(m);
return { from, to, label, match: m[0] };
}
return null;
}
// --- indexing ----------------------------------------------------------------
// Passages are cut from the `words` rows, not from transcript_clean: only the
// words carry per-word timestamps, and the timestamp is the whole point of a
// hit. A passage never spans two speakers.
export function buildPassages(words, speakerNames = {}) {
const out = [];
let cur = null;
const flush = () => {
if (cur && cur.text.trim().length >= 25) { // drop "Ja." / "Mhm." fragments
cur.text = cur.text.trim();
out.push(cur);
}
cur = null;
};
for (const w of words) {
const sp = w.speaker || null;
if (cur && cur.speaker !== sp) flush();
if (!cur) cur = { speaker: sp, speaker_name: speakerNames[sp] || null, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms, text: '' };
cur.text += (cur.text ? ' ' : '') + w.word;
cur.t_end_ms = w.t_end_ms;
// Prefer to break on a sentence end once the target length is reached;
// force a break at the hard cap so one monologue is not a single passage.
const endsSentence = /[.!?…]["»)]?$/.test(w.word);
if ((cur.text.length >= PASSAGE_CHARS && endsSentence) || cur.text.length >= PASSAGE_MAX_CHARS) flush();
}
flush();
return out.map((p, i) => ({ ...p, idx: i }));
}
// Rebuild one recording's passages. Called after transcription/diarization and
// after a speaker rename (the speaker name is denormalised into the row so a
// search can filter by person without joining back).
export async function indexRecording(recId) {
const [recRows] = await pool.query('SELECT speaker_names, title, summary FROM recordings WHERE id=?', [recId]);
if (!recRows.length) return 0;
let names = {};
try { names = JSON.parse(recRows[0].speaker_names || '{}') || {}; } catch {}
await indexDoc(recId, recRows[0]);
const [words] = await pool.query(
'SELECT word,t_start_ms,t_end_ms,speaker FROM words WHERE recording_id=? ORDER BY idx', [recId]);
const passages = buildPassages(words, names);
await pool.query('DELETE FROM passages WHERE recording_id=?', [recId]);
if (!passages.length) return 0;
const vecs = await emb.embedMany(passages.map(p => p.text)).catch(() => null);
const rows = passages.map((p, i) => [
recId, p.idx, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text,
vecs && vecs[i] ? JSON.stringify(vecs[i]) : null]);
await pool.query(
'INSERT INTO passages (recording_id, idx, speaker, speaker_name, t_start_ms, t_end_ms, text, vec) VALUES ?', [rows]);
console.log(`search: #${recId} indexiert — ${passages.length} Passagen${vecs ? ' (mit Embeddings)' : ' (nur Volltext)'}`);
return passages.length;
}
// Document-level embedding over title + summary.
//
// Measured on this corpus: passage-level cosine over raw ASR text separates
// badly — for "wie werden unsere Daten gesichert" the whole candidate field sat
// between 0.809 and 0.856, and an off-topic passage outranked the two passages
// that literally describe the backup job. The transcript is conversational and
// the ASR breaks compounds ("Backup -Job"), which is exactly what an embedding
// handles worst. Title and summary are clean LLM prose about the MEETING'S
// topic, so a second similarity on that level says "this recording is about
// your question" even when no single passage embeds well. Cheap: one vector per
// recording, 22 of them.
async function indexDoc(recId, row) {
const doc = [row.title || '', row.summary || ''].filter(Boolean).join('. ').slice(0, 4000);
if (!doc.trim()) return;
const v = await emb.embedMany([doc]).catch(() => null);
await pool.execute('UPDATE recordings SET doc_vec=? WHERE id=?',
[v && v[0] ? JSON.stringify(v[0]) : null, recId]);
}
// Refresh only the document vectors (seconds, vs ~9 min for a full passage
// re-embed). Used after installing the model or changing titles/summaries.
export async function reindexDocs() {
const [rows] = await pool.query(
'SELECT id, title, summary FROM recordings WHERE deleted_at IS NULL');
let n = 0;
for (const r of rows) {
try { await indexDoc(r.id, r); n++; } catch (e) { console.error(`search: doc #${r.id} — ${e.message}`); }
}
console.log(`search: ${n} Dokument-Vektoren aktualisiert`);
return n;
}
// Index everything that has words but no passages (first run after the update),
// or everything when force=true. Sequential on purpose — this competes with
// transcription for the same cores.
export async function reindexAll({ force = false, onProgress = null } = {}) {
const [rows] = await pool.query(
force
? 'SELECT id FROM recordings WHERE deleted_at IS NULL ORDER BY id'
: `SELECT r.id FROM recordings r
WHERE r.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM words w WHERE w.recording_id=r.id)
AND NOT EXISTS (SELECT 1 FROM passages p WHERE p.recording_id=r.id)
ORDER BY r.id`);
let done = 0;
for (const r of rows) {
try { await indexRecording(r.id); } catch (e) { console.error(`search: #${r.id} fehlgeschlagen — ${e.message}`); }
if (onProgress) onProgress(++done, rows.length);
}
return { indexed: done, total: rows.length };
}
// --- retrieval ---------------------------------------------------------------
const norm = s => (s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function cosine(a, b) {
let s = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { s += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return s / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}
export async function search(q, { limit = TOPK, recordingId = null } = {}) {
const range = parseDateRange(q);
// The date phrase is a FILTER, not a search term — strip it before tokenising.
// Case-insensitively but WITHOUT lowercasing the rest: the capitalisation of
// the remaining words is what identifies names for the speaker boost below.
const terms = contentTerms(
range ? q.replace(new RegExp(range.match.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'), ' ') : q);
const where = ['r.deleted_at IS NULL'];
const args = [];
if (recordingId) { where.push('p.recording_id=?'); args.push(recordingId); }
if (range) { where.push('r.created_at >= ? AND r.created_at < ?'); args.push(range.from, range.to); }
// "Was lief letzte Woche?" — a date with nothing else to match on. Returning
// "no hits" would be misleading (there ARE meetings), so show that week's
// recordings from their opening passages instead of an empty page.
if (!terms.length && range) {
const [rows] = await pool.query(
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text,
r.title, r.created_at, r.duration_ms
FROM passages p JOIN recordings r ON r.id=p.recording_id
WHERE ${where.join(' AND ')} AND p.idx < 3
ORDER BY r.created_at DESC, p.idx ASC LIMIT ?`, [...args, limit]);
return {
hits: rows.map(r => ({ ...r, score: 0, ft: 0, vec: 0 })),
terms, range: { label: range.label, from: range.from, to: range.to },
semantic: false, total: rows.length, dateOnly: true,
};
}
// Full-text candidates. BOOLEAN MODE with OR'd terms: a passage matching only
// "Jeckel" must still be a candidate, the ranking sorts it out afterwards.
let ftRows = [];
if (terms.length) {
const clean = terms.map(t => t.replace(/[+\-><()~*"@]/g, ''));
const boolExpr = clean.map(t => `${t}*`).join(' '); // candidate selection (prefix, OR)
// Scored in NATURAL LANGUAGE mode, which is IDF-weighted; BOOLEAN MODE only
// counts matches, so a passage repeating a common word like "Daten" beat the
// passage that actually contained the rare, meaning-carrying term.
const nlExpr = clean.join(' ');
const [rows] = await pool.query(
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text, p.vec,
r.title, r.created_at, r.duration_ms, r.doc_vec,
MATCH(p.text) AGAINST(?) AS ft
FROM passages p JOIN recordings r ON r.id=p.recording_id
WHERE ${where.join(' AND ')} AND MATCH(p.text) AGAINST(? IN BOOLEAN MODE)
ORDER BY ft DESC LIMIT 200`,
[nlExpr, ...args, boolExpr]);
ftRows = rows;
}
// Semantic candidates. Only worth loading when the model is there AND some
// passage actually carries a vector.
let vecRows = [];
let qvec = null;
if (emb.available()) {
qvec = await emb.embedQuery(q).catch(() => null);
if (qvec) {
const [rows] = await pool.query(
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text, p.vec,
r.title, r.created_at, r.duration_ms, r.doc_vec
FROM passages p JOIN recordings r ON r.id=p.recording_id
WHERE ${where.join(' AND ')} AND p.vec IS NOT NULL`, args);
vecRows = rows;
}
}
// Merge both candidate sets, then score every candidate on both axes.
const byId = new Map();
for (const r of [...ftRows, ...vecRows]) if (!byId.has(r.id)) byId.set(r.id, r);
const ftMax = Math.max(1e-9, ...ftRows.map(r => r.ft || 0));
const ftById = new Map(ftRows.map(r => [r.id, (r.ft || 0) / ftMax]));
const qn = norm(q);
const nameTerms = terms.filter(t => t[0] === t[0].toUpperCase());
const now = Date.now();
// Raw e5 cosines sit in a narrow band (measured here: 0.82-0.86 across ALL
// passages, relevant or not) because the model puts everything in the same
// cone. Feeding that raw into the score makes the semantic axis worth ~0.01
// while full-text is worth up to 0.55 — the ranking then ignores semantics
// entirely except when full-text finds nothing. So spread the band: rank by
// how far above the field a passage is, not by its absolute cosine.
const cand = [...byId.values()];
const cosById = new Map();
if (qvec) {
for (const r of cand) {
if (!r.vec) continue;
try { cosById.set(r.id, cosine(qvec, JSON.parse(r.vec))); } catch {}
}
}
const cosVals = [...cosById.values()];
const cosMean = cosVals.length ? cosVals.reduce((a, b) => a + b, 0) / cosVals.length : 0;
const cosSd = cosVals.length
? Math.sqrt(cosVals.reduce((a, b) => a + (b - cosMean) ** 2, 0) / cosVals.length) : 0;
const cosMin = cosVals.length ? Math.min(...cosVals) : 0;
const cosMax = cosVals.length ? Math.max(...cosVals) : 0;
// Min-max, not standard deviations: with a 0.047-wide band an sd-based squash
// saturates at 1.0 for dozens of passages and throws away the ordering
// exactly where it matters — at the top.
const vecNorm = id => {
if (!cosById.has(id) || cosMax - cosMin < 1e-9) return 0;
return (cosById.get(id) - cosMin) / (cosMax - cosMin);
};
// Document level: how close is the RECORDING (title + summary) to the
// question. One value per recording, normalised over the recordings actually
// in play.
const docCos = new Map();
if (qvec) {
for (const r of cand) {
if (docCos.has(r.recording_id) || !r.doc_vec) continue;
try { docCos.set(r.recording_id, cosine(qvec, JSON.parse(r.doc_vec))); } catch {}
}
}
const dVals = [...docCos.values()];
const dMin = dVals.length ? Math.min(...dVals) : 0;
const dMax = dVals.length ? Math.max(...dVals) : 0;
const docNorm = recId => {
if (!docCos.has(recId) || dMax - dMin < 1e-9) return 0;
return (docCos.get(recId) - dMin) / (dMax - dMin);
};
// Relevance floor for semantic-only candidates, still in sd terms (a scale
// that means "stands out from the field" rather than "is the best of a bad
// field", which is what min-max would say).
const semFloor = cosMean + cosSd;
const hits = [];
for (const r of cand) {
const ft = ftById.get(r.id) || 0;
const vec = vecNorm(r.id);
const doc = docNorm(r.recording_id);
if (!ft && (cosById.get(r.id) || 0) < semFloor) continue;
// Speaker boost: the person asked about is usually the one TALKING, and
// their own words never contain their name — without this the passage that
// actually answers the question ranks below every passage mentioning them.
const spk = nameTerms.some(t => norm(r.speaker_name).includes(norm(t))) ? 0.25 : 0;
const phrase = qn.length > 8 && norm(r.text).includes(qn) ? 0.35 : 0;
// Mild recency tilt — "last week" questions are the common case. Half a
// point of decay per year; never enough to beat a clearly better match.
const ageDays = (now - new Date(r.created_at).getTime()) / 86400000;
const recency = 0.1 / (1 + ageDays / 180);
// Passage semantics get less weight than the document level because the
// measurement above showed them to be the noisier of the two signals.
const score = 0.45 * ft + 0.25 * vec + 0.30 * doc + spk + phrase + recency;
hits.push({
id: r.id, recording_id: r.recording_id, title: r.title, created_at: r.created_at,
duration_ms: r.duration_ms, speaker: r.speaker, speaker_name: r.speaker_name,
t_start_ms: r.t_start_ms, t_end_ms: r.t_end_ms, text: r.text,
score, ft, vec: +vec.toFixed(4), doc: +doc.toFixed(4),
cos: +(cosById.get(r.id) || 0).toFixed(4),
});
}
hits.sort((a, b) => b.score - a.score);
return {
hits: hits.slice(0, limit),
terms,
range: range ? { label: range.label, from: range.from, to: range.to } : null,
semantic: !!qvec,
total: hits.length,
};
}
// --- answer ------------------------------------------------------------------
const ts = ms => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
const dateDe = d => new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
// Writes the prose answer over the top hits. Every passage is numbered and the
// model is told to cite those numbers, so each claim can be traced back to a
// recording and a minute — an uncited sentence is the tell for a hallucination.
export async function answer(q, hits) {
const use = hits.slice(0, ANSWER_PASSAGES);
if (!use.length) return { answer: 'Dazu finde ich in den Aufnahmen nichts.', sources: [] };
const ctx = use.map((h, i) =>
`[${i + 1}] Meeting "${h.title || 'Aufnahme #' + h.recording_id}" (#${h.recording_id}) vom ${dateDe(h.created_at)}, `
+ `${h.speaker_name || h.speaker || 'unbekannt'} bei ${ts(h.t_start_ms)}:\n${h.text}`).join('\n\n');
const prompt = `Du beantwortest eine Frage AUSSCHLIESSLICH aus den folgenden Ausschnitten aus Meeting-Mitschriften.
${ctx}
Frage: ${q}
Regeln:
- Antworte kurz und konkret auf Deutsch, in höchstens 4 Sätzen.
- Belege JEDE Aussage mit der Nummer des Ausschnitts, z.B. [2].
- Steht die Antwort nicht in den Ausschnitten, schreibe genau: "Dazu finde ich in den Aufnahmen nichts."
- Rate nicht und ergänze nichts aus eigenem Wissen.
Antwort:`;
const text = (await llm.askRaw(prompt) || '').trim();
// Map the [n] the model used back to real recordings + timestamps.
const cited = new Set((text.match(/\[(\d+)\]/g) || []).map(m => +m.slice(1, -1)));
const sources = use
.map((h, i) => ({ n: i + 1, ...h }))
.filter(h => cited.size === 0 || cited.has(h.n));
return { answer: text, sources };
}
+99 -2
View File
@@ -10,6 +10,8 @@ import * as db from './db.js';
import * as llm from './llm.js'; import * as llm from './llm.js';
import * as diar from './diar.js'; import * as diar from './diar.js';
import * as spk from './spk.js'; import * as spk from './spk.js';
import * as search from './search.js';
import * as emb from './emb.js';
const pexec = promisify(execFile); const pexec = promisify(execFile);
// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH // Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH
@@ -315,6 +317,9 @@ async function analyzeRecording(id) {
// still carries the label from analysis start ("Sprecher 1"), so patch it // still carries the label from analysis start ("Sprecher 1"), so patch it
// once at the end — same mechanism as renaming a speaker by hand. // once at the end — same mechanism as renaming a speaker by hand.
await relabelAnalysis(id).catch(() => {}); await relabelAnalysis(id).catch(() => {});
// Rebuild the search index last: it needs the final speaker names, which
// relabelAnalysis may only just have settled.
await search.indexRecording(id).catch(e => console.error('search index:', e.message));
console.log(`analysis done for #${id}: ${a.title}`); console.log(`analysis done for #${id}: ${a.title}`);
} catch (e) { } catch (e) {
console.error(`analysis failed for #${id}:`, e.message); console.error(`analysis failed for #${id}:`, e.message);
@@ -428,7 +433,85 @@ app.get('/api/recordings/:id', wrap(async (req, res) => {
const r = await db.getRecording(+req.params.id); const r = await db.getRecording(+req.params.id);
r ? res.json(r) : res.status(404).json({ error: 'not found' }); r ? res.json(r) : res.status(404).json({ error: 'not found' });
})); }));
app.delete('/api/recordings/:id', wrap(async (req, res) => { await db.deleteRecording(+req.params.id); res.json({ ok: true }); })); // Rename. An empty title clears it AND unlocks, so the next analysis may
// propose one again (see db.renameRecording / updateAnalysis).
app.patch('/api/recordings/:id/title', wrap(async (req, res) => {
const id = +req.params.id;
const row = await db.renameRecording(id, String((req.body && req.body.title) || ''));
if (!row) return res.status(404).json({ error: 'not found' });
res.json({ ok: true, id, title: row.title, title_locked: !!row.title_locked });
}));
// --- Trash -------------------------------------------------------------------
// Delete = move to the bin. Nothing on disk is touched until the retention
// window expires, so a restore brings back audio, transcript and analysis.
app.delete('/api/recordings/:id', wrap(async (req, res) => {
await db.deleteRecording(+req.params.id);
res.json({ ok: true, trashed: true, days: db.TRASH_DAYS });
}));
app.get('/api/trash', wrap(async (req, res) => res.json({ days: db.TRASH_DAYS, items: await db.listTrash() })));
app.post('/api/trash/:id/restore', wrap(async (req, res) => {
await db.restoreRecording(+req.params.id);
res.json({ ok: true });
}));
// Irreversible: drops the row, its words/markers/passages and the audio files.
app.delete('/api/trash/:id', wrap(async (req, res) => {
res.json({ ok: true, purged: await purgeRecording(+req.params.id) });
}));
// Shared by the manual purge route and the retention sweep.
async function purgeRecording(id) {
const files = await db.purgeRecording(id);
let removed = 0;
for (const f of files) {
try { fs.unlinkSync(path.join(REC_DIR, f)); removed++; }
catch (e) { if (e.code !== 'ENOENT') console.error(`purge #${id}: ${f} — ${e.message}`); }
}
return { id, files: removed };
}
// Retention sweep: once at boot (the box is often off for days, so a timer
// alone would keep missing the window) and then daily.
async function sweepTrash() {
const ids = await db.expiredTrashIds().catch(() => []);
for (const id of ids) {
try { await purgeRecording(id); console.log(`trash: #${id} nach ${db.TRASH_DAYS} Tagen endgültig gelöscht`); }
catch (e) { console.error(`trash sweep #${id}:`, e.message); }
}
}
// --- Search ------------------------------------------------------------------
// Hits are pure SQL and come back immediately; the written answer is a separate
// call because the LLM needs ~30-90 s on this hardware (see search.js).
app.get('/api/search', wrap(async (req, res) => {
const q = String(req.query.q || '').trim();
if (q.length < 2) return res.json({ hits: [], terms: [], range: null, semantic: false, total: 0 });
res.json(await search.search(q, { limit: Math.min(+req.query.limit || 20, 50) }));
}));
// Runs through the NPU queue: rkllm and the whisper encoder cannot share the NPU.
app.post('/api/search/answer', wrap(async (req, res) => {
const q = String((req.body && req.body.q) || '').trim();
if (!q) return res.status(400).json({ error: 'q fehlt' });
const { hits } = await search.search(q, { limit: 20 });
res.json(await withNpuLock('search-answer', () => search.answer(q, hits)));
}));
app.get('/api/search/status', wrap(async (req, res) => {
const [[{ n }]] = await db.pool.query('SELECT COUNT(*) AS n FROM passages');
const [[{ v }]] = await db.pool.query('SELECT COUNT(*) AS v FROM passages WHERE vec IS NOT NULL');
res.json({ passages: n, embedded: v, ...emb.status(), indexing: !!indexJob });
}));
// Background reindex; `force` rebuilds everything, otherwise only recordings
// that have no passages yet (the normal case after this update).
let indexJob = null;
app.post('/api/search/reindex', wrap(async (req, res) => {
if (indexJob) return res.json({ ok: true, already: true });
const force = !!(req.body && req.body.force);
indexJob = search.reindexAll({ force, onProgress: (d, t) => console.log(`search: reindex ${d}/${t}`) })
.then(r => { console.log(`search: reindex fertig — ${r.indexed}/${r.total}`); return r; })
.catch(e => { console.error('search: reindex:', e.message); })
.finally(() => { indexJob = null; });
res.json({ ok: true, started: true });
}));
// --- Voiceprints / enrollment --- // --- Voiceprints / enrollment ---
app.get('/api/voiceprints', wrap(async (req, res) => { app.get('/api/voiceprints', wrap(async (req, res) => {
@@ -623,6 +706,9 @@ app.post('/api/recordings/:id/speaker', wrap(async (req, res) => {
} }
} }
const r2 = await db.getRecording(id); const r2 = await db.getRecording(id);
// The passage rows carry the speaker NAME denormalised (so search can filter
// by person without a join) — a rename has to be written through.
search.indexRecording(id).catch(e => console.error('search index:', e.message));
res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg, res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg,
relabeled: relabel.changed }); relabeled: relabel.changed });
})); }));
@@ -632,4 +718,15 @@ app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname,
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
db.ensureSchema() db.ensureSchema()
.catch(e => console.error('ensureSchema:', e.message)) .catch(e => console.error('ensureSchema:', e.message))
.finally(() => app.listen(PORT, '0.0.0.0', () => console.log(`callrec listening on :${PORT}`))); .finally(() => app.listen(PORT, '0.0.0.0', () => {
console.log(`callrec listening on :${PORT}`);
// Retention sweep at boot + daily. unref() so it never holds the process open.
sweepTrash().catch(e => console.error('trash sweep:', e.message));
setInterval(() => sweepTrash().catch(e => console.error('trash sweep:', e.message)), 24 * 3600 * 1000).unref();
// Backfill the search index for everything recorded before this update.
// Deliberately not under the NPU lock: it is CPU/SQL only and must not
// delay a transcription that is already queued.
search.reindexAll({ onProgress: (d, t) => { if (d === 1 || d === t) console.log(`search: backfill ${d}/${t}`); } })
.then(r => { if (r.total) console.log(`search: backfill fertig — ${r.indexed}/${r.total} Aufnahmen`); })
.catch(e => console.error('search backfill:', e.message));
}));
+260 -10
View File
@@ -4,11 +4,15 @@ const api = {
async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }, async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); },
async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); } async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
async patch(u, b) { const r = await fetch(u, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }
}; };
const app = document.querySelector('#app'); const app = document.querySelector('#app');
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '' }; const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '',
// search: `q` is what was actually submitted (not what is being typed), so a
// tab switch and back restores the result page instead of an empty form.
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false };
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
@@ -39,8 +43,8 @@ function render() {
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''} <span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
</div> </div>
<nav> <nav>
${['recordings', 'voices', 'glossary', 'bluetooth'].map(t => ${['recordings', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')} `<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
</nav> </nav>
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button> <button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
</header> </header>
@@ -51,7 +55,7 @@ function render() {
app.querySelector('#recbtn').onclick = toggleRecord; app.querySelector('#recbtn').onclick = toggleRecord;
bindMarkerInput(); bindMarkerInput();
if (state.recording) startTimerUI(); if (state.recording) startTimerUI();
({ bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab](); ({ bluetooth: viewBluetooth, recordings: viewRecordings, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
} }
// Header record button: start = defaults only (default source = CallRecMix, // Header record button: start = defaults only (default source = CallRecMix,
@@ -289,6 +293,171 @@ async function viewGlossary() {
}; };
} }
// ---------- Search ----------
// Two-stage on purpose (see search.js): the hit list is SQL and lands in
// milliseconds, the written answer is a ~30-90 s LLM call that streams in
// afterwards. Never make the user wait on the slow half to see the fast half.
const SEARCH_EXAMPLES = [
'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
'Was wurde über die Preiserhöhung besprochen?',
'Welche Aufgaben hat Markus im Juli übernommen?',
];
function viewSearch() {
const v = document.querySelector('#view');
const hasResults = !!state.searchRes;
v.innerHTML = `
<div class="search-page ${hasResults ? 'has-results' : 'landing'}">
<form class="search-box" id="searchform" autocomplete="off">
<span class="search-ico">🔍</span>
<input id="searchq" class="search-input" type="text" placeholder="Frage zu deinen Meetings…"
value="${escapeHtml(state.searchQ)}" />
${state.searchQ ? '<button type="button" class="search-clear" id="searchclear" title="Leeren">✕</button>' : ''}
<button type="submit" class="search-go">Suchen</button>
</form>
${hasResults ? '' : `
<div class="search-hints">
<div class="muted small">Durchsucht alle Mitschriften. Beispiele:</div>
${SEARCH_EXAMPLES.map(e => `<button class="example" data-ex="${escapeHtml(e)}">${escapeHtml(e)}</button>`).join('')}
</div>`}
<div id="searchout"></div>
</div>`;
const input = v.querySelector('#searchq');
v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); };
const clr = v.querySelector('#searchclear');
if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); };
v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); });
if (!hasResults) input.focus();
if (hasResults) renderSearchResults();
}
async function runSearch(q) {
if (!q || q.length < 2) return;
state.searchQ = q;
state.searchAnswer = null;
state.searchBusy = true;
const out = document.querySelector('#searchout');
if (out) out.innerHTML = '<div class="muted searching">Suche…</div>';
document.querySelector('.search-page').classList.add('has-results');
try {
state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q));
} catch (e) {
state.searchRes = null;
if (out) out.innerHTML = '<div class="err">' + escapeHtml(e.message) + '</div>';
state.searchBusy = false;
return;
}
state.searchBusy = false;
renderSearchResults();
// Only bother the NPU when there is something to summarise.
if (state.searchRes.hits.length) askSearchAnswer(q);
}
// The written answer. Fired after the hits are already on screen; the box shows
// a live elapsed counter because on this hardware it genuinely takes a while.
async function askSearchAnswer(q) {
const box = document.querySelector('#answerbox');
if (!box) return;
const t0 = Date.now();
const tick = setInterval(() => {
const el = box.querySelector('.answer-elapsed');
if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
}, 1000);
try {
const a = await api.post('/api/search/answer', { q });
// A late answer to a query the user has already replaced must not overwrite
// the newer result.
if (state.searchQ !== q) return;
state.searchAnswer = a;
renderAnswer();
} catch (e) {
if (state.searchQ !== q) return;
box.innerHTML = `<div class="answer-head">Antwort</div><div class="err">${escapeHtml(e.message)}</div>`;
} finally { clearInterval(tick); }
}
// Highlight the query's content words inside a snippet, the way a search engine
// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
function highlight(text, terms) {
let html = escapeHtml(text);
for (const t of (terms || [])) {
if (t.length < 3) continue;
const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
html = html.replace(new RegExp(`(${safe})`, 'gi'), '<mark>$1</mark>');
}
return html;
}
function renderSearchResults() {
const out = document.querySelector('#searchout');
if (!out || !state.searchRes) return;
const { hits, terms, range, semantic, total } = state.searchRes;
if (!hits.length) {
out.innerHTML = `<div class="search-empty">
<b>Keine Treffer für „${escapeHtml(state.searchQ)}".</b>
<div class="muted small">${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
Andere Formulierung oder weniger Wörter versuchen.</div></div>`;
return;
}
out.innerHTML = `
<div id="answerbox" class="answer-box">
<div class="answer-head">Antwort <span class="muted small answer-elapsed">…</span></div>
<div class="answer-body skeleton"><span></span><span></span><span></span></div>
</div>
<div class="result-meta muted small">
${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''}
· ${semantic ? 'Volltext + semantisch' : 'nur Volltext'}
</div>
<ol class="results">
${hits.map(h => `
<li class="result" data-rec="${h.recording_id}" data-t="${h.t_start_ms}">
<div class="result-src">${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))}
· Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}</div>
<a class="result-title" href="#">${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id}
<span class="result-at">bei ${fmt(h.t_start_ms)}</span></a>
<div class="result-snip">${highlight(h.text, terms)}</div>
</li>`).join('')}
</ol>`;
out.querySelectorAll('.result').forEach(li => li.onclick = (e) => {
e.preventDefault();
openFromSearch(+li.dataset.rec, +li.dataset.t);
});
}
function renderAnswer() {
const box = document.querySelector('#answerbox');
if (!box || !state.searchAnswer) return;
const { answer, sources } = state.searchAnswer;
box.innerHTML = `
<div class="answer-head">Antwort</div>
<div class="answer-body">${escapeHtml(answer)}</div>
${sources && sources.length ? `<div class="answer-src">
${sources.map(s => `<button class="src-chip" data-rec="${s.recording_id}" data-t="${s.t_start_ms}">
[${s.n}] ${escapeHtml(s.title) || '#' + s.recording_id} · ${fmt(s.t_start_ms)}</button>`).join('')}
</div>` : ''}`;
box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t));
}
// Jump from a hit into the recording, positioned at the spoken minute. The
// detail pane lives in the recordings tab, so switch there first and wait for
// the player element to exist before seeking.
async function openFromSearch(recId, tMs) {
state.tab = 'recordings';
state.selectedId = recId;
state.trashOpen = false;
render();
for (let i = 0; i < 40; i++) {
const p = document.querySelector('#player');
if (p) {
const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} };
if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true });
return;
}
await new Promise(r => setTimeout(r, 100));
}
}
// ---------- Recordings ---------- // ---------- Recordings ----------
// Analysis-duration counter. Shows the total wall time of a processing run // Analysis-duration counter. Shows the total wall time of a processing run
// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), // (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
@@ -322,10 +491,22 @@ function viewRecordings() {
state.resetCollapse = true; state.resetCollapse = true;
v.innerHTML = ` v.innerHTML = `
<div class="rec-layout"> <div class="rec-layout">
<aside class="rec-list-pane"><div id="reclist" class="list">Lade…</div></aside> <aside class="rec-list-pane">
<div class="list-bar">
<button id="trashtoggle" class="btn small ghost ${state.trashOpen ? 'active' : ''}"
title="Gelöschte Aufnahmen — bleiben 30 Tage wiederherstellbar">🗑 Papierkorb</button>
</div>
<div id="reclist" class="list">Lade…</div>
</aside>
<div class="rec-splitter" title="Ziehen: Listenbreite ändern"></div> <div class="rec-splitter" title="Ziehen: Listenbreite ändern"></div>
<section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section> <section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section>
</div>`; </div>`;
v.querySelector('#trashtoggle').onclick = () => {
state.trashOpen = !state.trashOpen;
state.lastListSig = null;
v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
refreshRecList();
};
// Draggable splitter: list width between LIST_MIN (the old fixed width) and // Draggable splitter: list width between LIST_MIN (the old fixed width) and
// twice that; persisted for all sessions. // twice that; persisted for all sessions.
const LIST_MIN = 340, LIST_MAX = 680; const LIST_MIN = 340, LIST_MAX = 680;
@@ -355,6 +536,7 @@ function viewRecordings() {
async function refreshRecList() { async function refreshRecList() {
const listEl = document.querySelector('#reclist'); const listEl = document.querySelector('#reclist');
if (!listEl) return; // left the tab if (!listEl) return; // left the tab
if (state.trashOpen) return refreshTrashList(listEl);
let recs; let recs;
try { recs = await api.get('/api/recordings'); } try { recs = await api.get('/api/recordings'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; } catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
@@ -404,7 +586,8 @@ async function refreshRecList() {
<div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div> <div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div>
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''} ${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
</div> </div>
<button class="btn danger icon" data-del="${r.id}" title="Löschen">🗑</button> <button class="btn icon" data-ren="${r.id}" title="Titel umbenennen">✏️</button>
<button class="btn danger icon" data-del="${r.id}" title="In den Papierkorb">🗑</button>
</div>`; }).join(''); </div>`; }).join('');
return ` return `
<div class="day-group ${collapsed ? 'collapsed' : ''}"> <div class="day-group ${collapsed ? 'collapsed' : ''}">
@@ -412,18 +595,24 @@ async function refreshRecList() {
<div class="day-items">${items}</div> <div class="day-items">${items}</div>
</div>`; </div>`;
}).join(''); }).join('');
// Click a row (not the trash button) → open in the right pane. // Click a row (not one of its buttons) → open in the right pane.
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
if (e.target.closest('[data-del]')) return; if (e.target.closest('[data-del]') || e.target.closest('[data-ren]')) return;
openRecording(+it.dataset.open); openRecording(+it.dataset.open);
}); });
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.ren);
await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
});
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
e.stopPropagation(); e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.del); const rec = recs.find(r => r.id == b.dataset.del);
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return; if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return;
await api.del('/api/recordings/' + b.dataset.del); await api.del('/api/recordings/' + b.dataset.del);
if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; } if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; }
toast('In den Papierkorb verschoben');
state.lastListSig = null; refreshRecList(); state.lastListSig = null; refreshRecList();
}); });
bindMarkerChips(); bindMarkerChips();
@@ -438,6 +627,64 @@ async function refreshRecList() {
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; } } catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
} }
// Rename a recording's title. An empty answer clears the title and hands
// naming back to the LLM on the next analysis (see db.renameRecording).
async function renameRecording(id, current) {
const t = prompt('Titel der Aufnahme:', current || '');
if (t === null) return false; // cancelled
try {
const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
state.lastListSig = null;
refreshRecList();
// Keep the open detail pane in sync without rebuilding it (audio keeps playing).
const h = document.querySelector('#detail h2');
if (h && state.selectedId == id) {
const badge = h.querySelector('.badge');
h.textContent = r.title || 'Aufnahme #' + id;
if (badge) h.appendChild(badge);
}
toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
return true;
} catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
}
// Trash pane: same list slot, different contents. Deliberately flat (no day
// grouping) — the interesting order here is "when did I delete it", and the
// remaining days matter more than the recording date.
async function refreshTrashList(listEl) {
let data;
try { data = await api.get('/api/trash'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
const items = data.items || [];
if (!items.length) {
listEl.innerHTML = `<div class="muted trash-empty">Papierkorb ist leer.<br><span class="small">Gelöschte Aufnahmen bleiben ${data.days} Tage hier.</span></div>`;
return;
}
listEl.innerHTML = `<div class="trash-hint muted small">Wird nach ${data.days} Tagen automatisch endgültig gelöscht.</div>` + items.map(r => `
<div class="item rec trashed">
<div class="grow">
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b>
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
· <span class="days-left ${r.days_left <= 3 ? 'urgent' : ''}">noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}</span></div>
</div>
<button class="btn small" data-restore="${r.id}" title="Wiederherstellen">↩︎</button>
<button class="btn danger icon" data-purge="${r.id}" title="Endgültig löschen">✕</button>
</div>`).join('');
listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
await api.post(`/api/trash/${b.dataset.restore}/restore`, {});
toast('Wiederhergestellt');
refreshTrashList(listEl);
});
listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => {
const rec = items.find(r => r.id == b.dataset.purge);
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge;
if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return;
await api.del('/api/trash/' + b.dataset.purge);
toast('Endgültig gelöscht');
refreshTrashList(listEl);
});
}
async function openRecording(id) { async function openRecording(id) {
state.selectedId = id; state.selectedId = id;
const detail = document.querySelector('#detail'); const detail = document.querySelector('#detail');
@@ -545,6 +792,7 @@ async function openRecording(id) {
detail.innerHTML = ` detail.innerHTML = `
<div class="card"> <div class="card">
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2> <h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio> <audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
${vizHtml} ${vizHtml}
<div class="status-line"> <div class="status-line">
@@ -594,6 +842,8 @@ async function openRecording(id) {
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged); if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
markEmpty(); markEmpty();
})); }));
const renBtn = detail.querySelector('#renametitle');
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
const reBtn = detail.querySelector('#reanalyze'); const reBtn = detail.querySelector('#reanalyze');
if (reBtn) reBtn.onclick = async () => { if (reBtn) reBtn.onclick = async () => {
// Re-runs speaker separation every time: empty = automatic detection (now // Re-runs speaker separation every time: empty = automatic detection (now
+98
View File
@@ -395,3 +395,101 @@ body.resizing { cursor: col-resize; user-select: none; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; } * { transition: none !important; animation: none !important; }
} }
/* ==========================================================================
Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine
dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen.
========================================================================== */
.search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; }
/* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */
.search-page.landing { padding-top: 12vh; }
.search-page.has-results { padding-top: 1.25rem; }
.search-box {
display: flex; align-items: center; gap: .5rem;
border: 1px solid var(--line); border-radius: 2rem;
background: var(--surface); box-shadow: var(--shadow);
padding: .35rem .35rem .35rem 1rem;
transition: box-shadow .15s, border-color .15s;
}
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search-ico { opacity: .55; font-size: 1rem; }
.search-input {
flex: 1; border: 0; outline: 0; background: transparent; color: var(--fg);
font: inherit; font-size: 1.05rem; padding: .55rem 0;
}
.search-clear { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: .95rem; padding: .25rem .4rem; }
.search-clear:hover { color: var(--fg); }
.search-go {
border: 0; border-radius: 1.6rem; cursor: pointer;
background: var(--accent); color: var(--accent-fg);
font: inherit; font-weight: 600; padding: .55rem 1.1rem;
}
.search-go:hover { filter: brightness(1.07); }
.search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.search-hints .example {
border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2);
color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left;
}
.search-hints .example:hover { border-color: var(--accent); color: var(--accent); }
.searching { padding: 1.5rem 0; }
.search-empty { padding: 1.5rem 0; }
/* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */
.answer-box {
margin: 1.25rem 0 .5rem; padding: .9rem 1rem;
background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem;
}
.answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; }
.answer-body { line-height: 1.55; white-space: pre-wrap; }
/* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */
.answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; }
.answer-body.skeleton span {
height: .7rem; border-radius: .35rem; background: var(--line); opacity: .55;
animation: pulse 1.4s ease-in-out infinite;
}
.answer-body.skeleton span:nth-child(1) { width: 92%; }
.answer-body.skeleton span:nth-child(2) { width: 78%; animation-delay: .18s; }
.answer-body.skeleton span:nth-child(3) { width: 45%; animation-delay: .36s; }
@keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: .7; } }
.answer-src { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .4rem; }
.src-chip {
border: 1px solid var(--line); background: var(--surface); color: var(--fg);
border-radius: 1rem; font: inherit; font-size: .8rem; padding: .2rem .6rem; cursor: pointer;
}
.src-chip:hover { border-color: var(--accent); color: var(--accent); }
.result-meta { margin: .9rem 0 .3rem; }
.results { list-style: none; margin: 0; padding: 0; }
.result { padding: .85rem 0; cursor: pointer; border-bottom: 1px solid var(--line); }
.result:hover .result-title { text-decoration: underline; }
.result-src { color: var(--muted); font-size: .78rem; margin-bottom: .15rem; }
.result-title { display: block; color: var(--accent); font-size: 1.05rem; text-decoration: none; }
.result-at { color: var(--muted); font-size: .8rem; font-weight: 400; margin-left: .4rem; }
.result-snip { color: var(--fg); font-size: .9rem; line-height: 1.5; margin-top: .2rem; }
.result-snip mark { background: var(--warn-soft); color: inherit; font-weight: 600; padding: 0 .1em; border-radius: .15em; }
/* ==========================================================================
Papierkorb + Umbenennen
========================================================================== */
.list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--fg); border-color: var(--line); }
.btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.trash-hint, .trash-empty { padding: .6rem .75rem; }
.item.rec.trashed { opacity: .8; }
.item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); }
.days-left { color: var(--muted); }
.days-left.urgent { color: var(--danger); font-weight: 600; }
/* Der Stift sitzt neben der H2 des Detailkopfs, ohne die Zeile umzubrechen. */
.rename-btn { vertical-align: middle; margin-left: -.25rem; opacity: .55; }
.rename-btn:hover { opacity: 1; }
.item.rec [data-ren] { opacity: .45; }
.item.rec:hover [data-ren] { opacity: 1; }
@media (max-width: 640px) {
.search-page.landing { padding-top: 6vh; }
.search-go { padding: .55rem .8rem; }
}
+72
View File
@@ -3,6 +3,78 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups). 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 ## 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 **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`.