Files
Sascha d5b9733e78 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
2026-07-31 13:20:20 +00:00

421 lines
21 KiB
JavaScript

// 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 };
}