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
+332 -254
View File
@@ -1,254 +1,332 @@
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
export const pool = mysql.createPool({ export const pool = mysql.createPool({
host: 'localhost', user: 'callrec', password: 'callrec123', host: 'localhost', user: 'callrec', password: 'callrec123',
database: 'callrec', connectionLimit: 5, waitForConnections: true database: 'callrec', connectionLimit: 5, waitForConnections: true
}); });
// Idempotent migration: the LLM analysis columns (auto title lives in `title`). // Idempotent migration: the LLM analysis columns (auto title lives in `title`).
// MariaDB supports ADD COLUMN IF NOT EXISTS. // MariaDB supports ADD COLUMN IF NOT EXISTS.
export async function ensureSchema() { export async function ensureSchema() {
await pool.query(`ALTER TABLE recordings await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS summary TEXT NULL, ADD COLUMN IF NOT EXISTS summary TEXT NULL,
ADD COLUMN IF NOT EXISTS todos TEXT NULL, ADD COLUMN IF NOT EXISTS todos TEXT NULL,
ADD COLUMN IF NOT EXISTS participants TEXT NULL, ADD COLUMN IF NOT EXISTS participants TEXT NULL,
ADD COLUMN IF NOT EXISTS transcript_clean TEXT NULL, ADD COLUMN IF NOT EXISTS transcript_clean TEXT NULL,
ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) NULL, ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS speaker_names TEXT NULL, ADD COLUMN IF NOT EXISTS speaker_names TEXT NULL,
ADD COLUMN IF NOT EXISTS speaker_labels TEXT NULL, ADD COLUMN IF NOT EXISTS speaker_labels TEXT NULL,
ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(16) NOT NULL DEFAULT 'pending', ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL, ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL,
ADD COLUMN IF NOT EXISTS analysis_ms INT NULL`); ADD COLUMN IF NOT EXISTS analysis_ms INT NULL`);
await pool.query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS speaker VARCHAR(32) NULL`); await pool.query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS speaker VARCHAR(32) NULL`);
await pool.query(`CREATE TABLE IF NOT EXISTS voiceprints ( await pool.query(`CREATE TABLE IF NOT EXISTS voiceprints (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL, name VARCHAR(128) NOT NULL,
dim INT NOT NULL, dim INT NOT NULL,
vec MEDIUMTEXT NOT NULL, vec MEDIUMTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// n = number of samples averaged into this voiceprint (running centroid). // n = number of samples averaged into this voiceprint (running centroid).
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS n INT NOT NULL DEFAULT 1`); await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS n INT NOT NULL DEFAULT 1`);
// Embedding model the vector came from: prints of different models are NOT // Embedding model the vector came from: prints of different models are NOT
// comparable (cosine across models is noise), so identification only ever // comparable (cosine across models is noise), so identification only ever
// uses the rows of the model currently loaded. NULL = pre-2026-07-27 TitaNet. // uses the rows of the model currently loaded. NULL = pre-2026-07-27 TitaNet.
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS model VARCHAR(64) NULL`); await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS model VARCHAR(64) NULL`);
await pool.query(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`); await pool.query(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`);
// Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime" // Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime"
// heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped // heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped
// to the correct spelling. `soundalikes` = optional comma-list of mis-hearings. // to the correct spelling. `soundalikes` = optional comma-list of mis-hearings.
await pool.query(`CREATE TABLE IF NOT EXISTS glossary ( await pool.query(`CREATE TABLE IF NOT EXISTS glossary (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
term VARCHAR(190) NOT NULL UNIQUE, term VARCHAR(190) NOT NULL UNIQUE,
soundalikes TEXT NULL, soundalikes TEXT NULL,
enabled TINYINT NOT NULL DEFAULT 1, enabled TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// Live speaker markers: names typed DURING the recording ("who is talking // Live speaker markers: names typed DURING the recording ("who is talking
// right now"). t_ms = offset from recording start. After diarization each // right now"). t_ms = offset from recording start. After diarization each
// marker is resolved to the acoustic cluster that was speaking around t_ms // marker is resolved to the acoustic cluster that was speaking around t_ms
// (`speaker`), which names that cluster — ground truth instead of guessing. // (`speaker`), which names that cluster — ground truth instead of guessing.
await pool.query(`CREATE TABLE IF NOT EXISTS markers ( await pool.query(`CREATE TABLE IF NOT EXISTS markers (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
recording_id INT NOT NULL, recording_id INT NOT NULL,
t_ms INT NOT NULL, t_ms INT NOT NULL,
name VARCHAR(128) NOT NULL, name VARCHAR(128) NOT NULL,
speaker VARCHAR(32) NULL, speaker VARCHAR(32) NULL,
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
// --- live speaker markers --------------------------------------------------- // filters on it, so a binned recording is invisible everywhere else.
export async function addMarkers(recId, markers) { // `title_locked` marks a title the USER typed — a later re-analysis must not
const list = (markers || []).filter(m => m && m.name); // overwrite it with a fresh LLM guess (see updateAnalysis).
if (!list.length) return; await pool.query(`ALTER TABLE recordings
await pool.query('INSERT INTO markers (recording_id, t_ms, name) VALUES ?', ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL,
[list.map(m => [recId, Math.max(0, Math.round(m.t_ms || 0)), String(m.name).slice(0, 128)])]); ADD COLUMN IF NOT EXISTS title_locked TINYINT(1) NOT NULL DEFAULT 0,
} ADD COLUMN IF NOT EXISTS doc_vec MEDIUMTEXT NULL`);
export async function listMarkers(recId) { // Search index. One row per passage (a few sentences of ONE speaker, see
const [rows] = await pool.query( // search.js/buildPassages), carrying the time window so a hit can name the
'SELECT id, t_ms, name, speaker, conf FROM markers WHERE recording_id=? ORDER BY t_ms', [recId]); // exact minute and the player can jump there. `vec` = optional text embedding
return rows; // for semantic ranking; NULL when the embedding model is not installed, in
} // which case search degrades to full-text only.
export async function setMarkerSpeaker(id, speaker, conf) { await pool.query(`CREATE TABLE IF NOT EXISTS passages (
await pool.execute('UPDATE markers SET speaker=?, conf=? WHERE id=?', [speaker || null, conf || null, id]); id INT AUTO_INCREMENT PRIMARY KEY,
} recording_id INT NOT NULL,
export async function deleteMarker(id) { await pool.execute('DELETE FROM markers WHERE id=?', [id]); } idx INT NOT NULL,
export async function addMarker(recId, tMs, name) { speaker VARCHAR(32) NULL,
const [res] = await pool.execute('INSERT INTO markers (recording_id, t_ms, name) VALUES (?,?,?)', speaker_name VARCHAR(128) NULL,
[recId, Math.max(0, Math.round(tMs || 0)), String(name).slice(0, 128)]); t_start_ms INT NOT NULL,
return res.insertId; t_end_ms INT NOT NULL,
} text TEXT NOT NULL,
vec MEDIUMTEXT NULL,
export async function listGlossary() { INDEX rec_idx (recording_id),
const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term'); FULLTEXT KEY ft_text (text)) ENGINE=InnoDB`);
return rows.map(r => ({ ...r, enabled: !!r.enabled })); }
}
export async function addGlossary(term, soundalikes) { // --- live speaker markers ---------------------------------------------------
await pool.execute( export async function addMarkers(recId, markers) {
'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1', const list = (markers || []).filter(m => m && m.name);
[term, soundalikes || null]); if (!list.length) return;
const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]); await pool.query('INSERT INTO markers (recording_id, t_ms, name) VALUES ?',
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null; [list.map(m => [recId, Math.max(0, Math.round(m.t_ms || 0)), String(m.name).slice(0, 128)])]);
} }
export async function deleteGlossary(id) { export async function listMarkers(recId) {
await pool.execute('DELETE FROM glossary WHERE id=?', [id]); const [rows] = await pool.query(
} 'SELECT id, t_ms, name, speaker, conf FROM markers WHERE recording_id=? ORDER BY t_ms', [recId]);
export async function updateGlossary(id, term, soundalikes) { return rows;
await pool.execute('UPDATE glossary SET term=?, soundalikes=? WHERE id=?', [term, soundalikes || null, id]); }
const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE id=?', [id]); export async function setMarkerSpeaker(id, speaker, conf) {
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null; await pool.execute('UPDATE markers SET speaker=?, conf=? WHERE id=?', [speaker || null, conf || null, id]);
} }
export async function deleteMarker(id) { await pool.execute('DELETE FROM markers WHERE id=?', [id]); }
export async function addVoiceprint(name, vec, model) { export async function addMarker(recId, tMs, name) {
const [res] = await pool.execute( const [res] = await pool.execute('INSERT INTO markers (recording_id, t_ms, name) VALUES (?,?,?)',
'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)', [recId, Math.max(0, Math.round(tMs || 0)), String(name).slice(0, 128)]);
[name, vec.length, JSON.stringify(vec), model || null]); return res.insertId;
return res.insertId; }
}
export async function listGlossary() {
// Add a sample to a named voiceprint, keeping ONE row per name as a running const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term');
// centroid (mean of all samples). This is how the app "learns who is who": every return rows.map(r => ({ ...r, enabled: !!r.enabled }));
// time a speaker is named in a recording, their voice is folded into the centroid, }
// so future recordings match them more reliably. New name -> fresh row. export async function addGlossary(term, soundalikes) {
export async function upsertVoiceprint(name, vec, model) { await pool.execute(
const [rows] = await pool.query( 'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1',
'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1', [term, soundalikes || null]);
[name, model || null, model || null]); const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]);
if (!rows.length) { return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n, model) VALUES (?,?,?,1,?)', }
[name, vec.length, JSON.stringify(vec), model || null]); export async function deleteGlossary(id) {
return res.insertId; await pool.execute('DELETE FROM glossary WHERE id=?', [id]);
} }
const old = rows[0]; export async function updateGlossary(id, term, soundalikes) {
const on = old.n || 1; await pool.execute('UPDATE glossary SET term=?, soundalikes=? WHERE id=?', [term, soundalikes || null, id]);
const ov = JSON.parse(old.vec); const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE id=?', [id]);
const mean = vec.map((x, i) => ((ov[i] || 0) * on + x) / (on + 1)); return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
await pool.execute('UPDATE voiceprints SET vec=?, n=?, dim=? WHERE id=?', }
[JSON.stringify(mean), on + 1, mean.length, old.id]);
return old.id; export async function addVoiceprint(name, vec, model) {
} const [res] = await pool.execute(
// model given → only the prints of that embedding model (what identification 'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)',
// must use); omitted → all rows (GUI listing). [name, vec.length, JSON.stringify(vec), model || null]);
export async function listVoiceprints(model) { return res.insertId;
const [rows] = model }
? await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints WHERE model=? ORDER BY name', [model])
: await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints ORDER BY name'); // Add a sample to a named voiceprint, keeping ONE row per name as a running
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) })); // centroid (mean of all samples). This is how the app "learns who is who": every
} // time a speaker is named in a recording, their voice is folded into the centroid,
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); } // so future recordings match them more reliably. New name -> fresh row.
export async function upsertVoiceprint(name, vec, model) {
export async function addRecording(r) { const [rows] = await pool.query(
const [res] = await pool.execute( 'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1',
'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)', [name, model || null, model || null]);
[r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']); if (!rows.length) {
return res.insertId; const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n, model) VALUES (?,?,?,1,?)',
} [name, vec.length, JSON.stringify(vec), model || null]);
return res.insertId;
export async function addWords(recId, words) { }
if (!words || !words.length) return; const old = rows[0];
const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]); const on = old.n || 1;
await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]); const ov = JSON.parse(old.vec);
} const mean = vec.map((x, i) => ((ov[i] || 0) * on + x) / (on + 1));
await pool.execute('UPDATE voiceprints SET vec=?, n=?, dim=? WHERE id=?',
// Replace a recording's words wholesale (used when re-diarizing: the text is [JSON.stringify(mean), on + 1, mean.length, old.id]);
// unchanged, only the speaker labels differ). Words are re-indexed by array order. return old.id;
export async function replaceWords(recId, words) { }
await pool.query('DELETE FROM words WHERE recording_id=?', [recId]); // model given → only the prints of that embedding model (what identification
await addWords(recId, words); // must use); omitted → all rows (GUI listing).
} export async function listVoiceprints(model) {
const [rows] = model
// Store the transcript + words after async (thermal-guarded) transcription and ? await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints WHERE model=? ORDER BY name', [model])
// flip the row out of 'transcribing'. The wav + row were saved before whisper : await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints ORDER BY name');
// ran, so this just fills them in. return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
export async function setTranscript(id, transcript, words) { }
await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]); export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
await replaceWords(id, words || []);
} export async function addRecording(r) {
const [res] = await pool.execute(
// Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The 'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)',
// GUI shows a live counter from here; updateAnalysis('done') freezes the total [r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']);
// into analysis_ms. Called at every trigger (record-stop, retranscribe, reanalyze). return res.insertId;
export async function markAnalysisStart(id) { }
await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]);
} export async function addWords(recId, words) {
if (!words || !words.length) return;
// Update just the coarse recording status ('transcribing' | 'done' | 'error'). const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]);
export async function setStatus(id, status) { await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]);
await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]); }
}
// Replace a recording's words wholesale (used when re-diarizing: the text is
// Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName) // unchanged, only the speaker labels differ). Words are re-indexed by array order.
// are authoritative; acoustic identification only fills blanks (mergeSpeakerNames). export async function replaceWords(recId, words) {
async function getSpeakerNames(id) { await pool.query('DELETE FROM words WHERE recording_id=?', [recId]);
const [rows] = await pool.query('SELECT speaker_names FROM recordings WHERE id=?', [id]); await addWords(recId, words);
try { return JSON.parse((rows[0] && rows[0].speaker_names) || '{}') || {}; } catch { return {}; } }
}
export async function setSpeakerName(id, speaker, name) { // Store the transcript + words after async (thermal-guarded) transcription and
const m = await getSpeakerNames(id); // flip the row out of 'transcribing'. The wav + row were saved before whisper
if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N" // ran, so this just fills them in.
await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]); export async function setTranscript(id, transcript, words) {
} await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]);
// The label EVERY speaker carried in the text the LLM produced ({speaker_07: await replaceWords(id, words || []);
// "Sprecher 5"}). Stored with the analysis so a later rename can swap the name }
// into the finished summary/todos instead of re-running the 20-minute analysis.
export async function setSpeakerLabels(id, labels) { // Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The
await pool.execute('UPDATE recordings SET speaker_labels=? WHERE id=?', [JSON.stringify(labels || {}), id]); // GUI shows a live counter from here; updateAnalysis('done') freezes the total
} // into analysis_ms. Called at every trigger (record-stop, retranscribe, reanalyze).
export async function getSpeakerLabels(id) { export async function markAnalysisStart(id) {
const [rows] = await pool.query('SELECT speaker_labels FROM recordings WHERE id=?', [id]); await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]);
try { return JSON.parse((rows[0] && rows[0].speaker_labels) || '{}') || {}; } catch { return {}; } }
}
// Update just the coarse recording status ('transcribing' | 'done' | 'error').
// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones. export async function setStatus(id, status) {
export async function mergeSpeakerNames(id, map) { await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]);
if (!map || !Object.keys(map).length) return; }
const m = await getSpeakerNames(id);
let changed = false; // Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName)
for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; } // are authoritative; acoustic identification only fills blanks (mergeSpeakerNames).
if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]); async function getSpeakerNames(id) {
} const [rows] = await pool.query('SELECT speaker_names FROM recordings WHERE id=?', [id]);
try { return JSON.parse((rows[0] && rows[0].speaker_names) || '{}') || {}; } catch { return {}; }
// Store the LLM result. `todos` is an array → persisted as JSON text. }
export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) { export async function setSpeakerName(id, speaker, name) {
const st = status || 'done'; const m = await getSpeakerNames(id);
await pool.execute( if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N"
`UPDATE recordings SET title=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?, await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END }
WHERE id=?`, // The label EVERY speaker carried in the text the LLM produced ({speaker_07:
[title || null, summary || null, todos ? JSON.stringify(todos) : null, // "Sprecher 5"}). Stored with the analysis so a later rename can swap the name
participants ? JSON.stringify(participants) : null, transcriptClean || null, st, // into the finished summary/todos instead of re-running the 20-minute analysis.
st, Date.now(), id]); export async function setSpeakerLabels(id, labels) {
} await pool.execute('UPDATE recordings SET speaker_labels=? WHERE id=?', [JSON.stringify(labels || {}), id]);
}
function parseJsonCols(row) { export async function getSpeakerLabels(id) {
for (const c of ['todos', 'participants']) { const [rows] = await pool.query('SELECT speaker_labels FROM recordings WHERE id=?', [id]);
if (row && typeof row[c] === 'string') { try { return JSON.parse((rows[0] && rows[0].speaker_labels) || '{}') || {}; } catch { return {}; }
try { row[c] = JSON.parse(row[c]); } catch { row[c] = []; } }
}
} // Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
return row; export async function mergeSpeakerNames(id, map) {
} if (!map || !Object.keys(map).length) return;
const parseTodos = parseJsonCols; const m = await getSpeakerNames(id);
let changed = false;
export async function listRecordings() { for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; }
const [rows] = await pool.query( if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
'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'); }
return rows.map(parseTodos);
} // 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
export async function getRecording(id) { // CASE the LLM would silently rename the recording back on the next run.
const [recRows] = await pool.query('SELECT * FROM recordings WHERE id=?', [id]); export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) {
const rec = recRows[0]; const st = status || 'done';
if (!rec) return null; await pool.execute(
const [words] = await pool.query( `UPDATE recordings SET title=CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END,
'SELECT word,t_start_ms,t_end_ms,idx,speaker FROM words WHERE recording_id=? ORDER BY idx', [id]); summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
let speaker_names = {}; analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
try { speaker_names = JSON.parse(rec.speaker_names || '{}') || {}; } catch {} WHERE id=?`,
const markers = await listMarkers(id).catch(() => []); [title || null, summary || null, todos ? JSON.stringify(todos) : null,
return { ...parseTodos(rec), speaker_names, words, markers }; participants ? JSON.stringify(participants) : null, transcriptClean || null, st,
} st, Date.now(), id]);
}
export async function deleteRecording(id) {
await pool.query('DELETE FROM markers WHERE recording_id=?', [id]).catch(() => {}); // --- title rename ------------------------------------------------------------
await pool.query('DELETE FROM recordings WHERE id=?', [id]); // 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) {
for (const c of ['todos', 'participants']) {
if (row && typeof row[c] === 'string') {
try { row[c] = JSON.parse(row[c]); } catch { row[c] = []; }
}
}
return row;
}
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() {
const [rows] = await pool.query(
`SELECT ${LIST_COLS} FROM recordings WHERE deleted_at IS NULL ORDER BY id DESC`);
return rows.map(parseTodos);
}
export async function getRecording(id) {
const [recRows] = await pool.query('SELECT * FROM recordings WHERE id=?', [id]);
const rec = recRows[0];
if (!rec) return null;
const [words] = await pool.query(
'SELECT word,t_start_ms,t_end_ms,idx,speaker FROM words WHERE recording_id=? ORDER BY idx', [id]);
let speaker_names = {};
try { speaker_names = JSON.parse(rec.speaker_names || '{}') || {}; } catch {}
const markers = await listMarkers(id).catch(() => []);
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) {
await pool.execute('UPDATE recordings SET deleted_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL', [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 };
}
+732 -635
View File
File diff suppressed because it is too large Load Diff
+1260 -1010
View File
File diff suppressed because it is too large Load Diff
+485 -387
View File
@@ -1,397 +1,495 @@
/* Aime — Anruf-Assistent. Zwei Themes über CSS-Variablen; data-theme setzt /* Aime — Anruf-Assistent. Zwei Themes über CSS-Variablen; data-theme setzt
index.html vor dem ersten Paint, der Header-Toggle schaltet um. index.html vor dem ersten Paint, der Header-Toggle schaltet um.
Akzent = warmes Kupfer (Tonband-Anmutung), Rot bleibt der Aufnahme reserviert. */ Akzent = warmes Kupfer (Tonband-Anmutung), Rot bleibt der Aufnahme reserviert. */
:root[data-theme="dark"] {
--bg: #14161b;
--surface: #1d222a;
--surface-2: #262c36;
--inset: #10141a;
--line: #303845;
--fg: #e9ecf1;
--muted: #98a2b3;
--accent: #e8804c;
--accent-fg: #17130f;
--accent-soft: rgba(232, 128, 76, .14);
--ok: #55b381;
--ok-soft: rgba(85, 179, 129, .14);
--warn: #e2b93b;
--warn-soft: rgba(226, 185, 59, .14);
--danger: #e5595e;
--danger-soft: rgba(229, 89, 94, .12);
--shadow: none;
--sp0: #5bc0eb; --sp1: #ef8fc0; --sp2: #a8cc4e; --sp3: #e6b84c;
--sp0-w: #8fd6f5; --sp1-w: #f5b5d6; --sp2-w: #c5e07e; --sp3-w: #f0d08a;
}
:root[data-theme="light"] {
--bg: #f4f2ee;
--surface: #ffffff;
--surface-2: #eceae4;
--inset: #f7f6f2;
--line: #ddd8cf;
--fg: #23272e;
--muted: #6d7683;
--accent: #c05f2d;
--accent-fg: #ffffff;
--accent-soft: rgba(192, 95, 45, .1);
--ok: #2f8a5d;
--ok-soft: rgba(47, 138, 93, .12);
--warn: #9a7514;
--warn-soft: rgba(154, 117, 20, .12);
--danger: #c73e43;
--danger-soft: rgba(199, 62, 67, .09);
--shadow: 0 1px 3px rgba(35, 39, 46, .07);
--sp0: #1179a8; --sp1: #c2417f; --sp2: #5f7d16; --sp3: #a4700d;
--sp0-w: #0e6b95; --sp1-w: #ad3a72; --sp2-w: #556f14; --sp3-w: #92640b;
}
* { box-sizing: border-box; }
/* Scrollbalken im Logo-Kupfer (Firefox: scrollbar-color; Chromium/WebKit: ::-webkit-*) */
* { scrollbar-width: thin; scrollbar-color: var(--accent) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--accent); border-radius: 5px;
border: 2px solid transparent; background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--accent); filter: brightness(1.15); }
::-webkit-scrollbar-corner { background: transparent; }
html { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif;
transition: background .2s, color .2s;
}
/* Volle Fensterbreite für ALLE Tabs. Früher war nur „Aufnahmen“ breit (.wide)
und der Rest auf 860px zentriert — dadurch sprang die Kopfzeile beim
Tabwechsel in die Mitte. */
#app { max-width: none; margin: 0 auto; padding: 16px 28px; }
/* ---- Topbar / Brand ---- */
.topbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 6px 0 14px; }
.brand { display: flex; align-items: center; gap: 10px; }
.brand-wave { width: 30px; height: 18px; color: var(--accent); flex: none; }
.topbar h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.01em; }
.topbar .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 6px; }
nav { display: flex; gap: 6px; flex-wrap: wrap; }
.topbar nav { margin-left: 0; }
/* Aufnahme-Steuerung in der Kopfzeile (zwischen Logo und Tabs) */
.rec-ctl { display: flex; align-items: center; gap: 10px; margin: 0 auto; }
.recbtn {
width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--danger);
background: var(--danger-soft); color: var(--danger); font-size: 15px; line-height: 1;
cursor: pointer; transition: background .15s, transform .1s;
}
.recbtn:hover { background: var(--danger); color: #fff; }
.recbtn.rec { background: var(--danger); color: #fff; animation: recpulse 1.4s ease-in-out infinite; }
@keyframes recpulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 89, 94, .45); } 50% { box-shadow: 0 0 0 9px rgba(229, 89, 94, 0); } }
.recbtn:disabled { opacity: .55; cursor: default; animation: none; }
.rec-ctl .rectime { font-size: 14px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 44px; }
/* Namensmarker während der Aufnahme (Kopfzeile) */
.rec-ctl .mkin { width: 210px; padding: 6px 10px; font-size: 13px; }
@media (max-width: 1100px) { .rec-ctl .mkin { width: 130px; } }
/* Live-Platzhalter in der Aufnahmen-Liste */
.item.rec-live { border-color: var(--danger); background: var(--danger-soft); }
.rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); flex: none; animation: recpulse 1.4s ease-in-out infinite; }
.item.rec-live .rectime { color: var(--danger); font-weight: 650; }
.mkchips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.mkchip {
display: inline-flex; align-items: center; gap: 5px; font-size: 12px;
background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 4px 2px 8px;
}
.mkchip .mono { color: var(--muted); }
.mkdel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; line-height: 1; padding: 2px 4px; }
.mkdel:hover { color: var(--danger); }
/* Namensmarker-Liste im Detail */
.markers { list-style: none; margin: 6px 0; padding: 0; }
.markers li { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 13px; }
.mkseek {
background: var(--surface-2); border: 1px solid var(--line); border-radius: 7px;
color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 7px;
}
.mkseek:hover { border-color: var(--accent); }
.mkadd-row { margin-top: 8px; }
.mkadd-row .input { flex: 1; min-width: 120px; }
.tab {
background: transparent; color: var(--muted); border: 1px solid transparent;
padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px;
transition: color .12s, background .12s;
}
.tab:hover { color: var(--fg); background: var(--surface-2); }
.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; }
/* ---- Bausteine ---- */
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 18px; margin-bottom: 16px; box-shadow: var(--shadow);
}
h2 { margin: 0 0 12px; font-size: 18px; letter-spacing: -.01em; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.grow { flex: 1; }
.btn {
background: var(--surface-2); color: var(--fg); border: 1px solid var(--line);
border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px;
transition: border-color .12s, background .12s;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.07); }
.btn.danger { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.btn.danger:hover { border-color: var(--danger); }
.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; }
.btn.small { font-size: .8rem; padding: 3px 8px; }
.btn.icon { padding: 6px 9px; flex: none; }
.btn:disabled { opacity: .55; cursor: default; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.input, select {
width: 100%; background: var(--inset); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px;
}
.input:focus, select:focus { border-color: var(--accent); outline: none; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item {
display: flex; gap: 10px; align-items: center;
background: var(--inset); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
}
.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: var(--surface-2); color: var(--muted); }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, monospace; font-size: 12px; }
.err { color: var(--danger); }
.err-inline { color: var(--danger); margin-bottom: 6px; }
.hint { font-size: .8rem; color: var(--muted); margin-bottom: 10px; }
/* ---- Aufnahme ---- */
.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; }
.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; }
audio { width: 100%; margin-bottom: 10px; }
/* ---- Transkript ---- */
.transcript { line-height: 2.1; font-size: 16px; }
.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; }
.transcript .w:hover { background: var(--surface-2); }
.transcript .w.active, .w.active { background: var(--accent); color: var(--accent-fg); }
.w.sp0 { color: var(--sp0-w); } .w.sp1 { color: var(--sp1-w); }
.w.sp2 { color: var(--sp2-w); } .w.sp3 { color: var(--sp3-w); }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--accent); color: var(--fg);
padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; box-shadow: var(--shadow);
}
.toast.err { border-color: var(--danger); }
/* ---- Analyse ---- */
.summary-snip {
font-size: .85rem; color: var(--muted); margin-top: 4px; overflow: hidden;
text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.analysis { margin: 10px 0; padding: 12px; background: var(--inset); border: 1px solid var(--line); border-radius: 10px; }
.analysis h3 { margin: 8px 0 4px; font-size: .9rem; color: var(--muted); }
.summary { margin: 0 0 6px; line-height: 1.55; white-space: pre-line; }
.todos { margin: 0; padding-left: 20px; }
.todos li { margin: 3px 0; }
.parts { margin: 0; padding-left: 20px; } .parts li { margin: 3px 0; }
.clean { line-height: 1.55; background: var(--inset); padding: 8px 10px; border-radius: 8px; margin: 0 0 4px; }
/* ---- Dialog-Turns / Sprecher ---- */
.dialog { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.turn { padding: 6px 10px; border-radius: 8px; background: var(--inset); line-height: 1.5; }
.turn .spk { margin-right: 6px; font-weight: 600; }
.turn.sp0 { border-left: 3px solid var(--sp0); } .turn.sp0 .spk { color: var(--sp0); }
.turn.sp1 { border-left: 3px solid var(--sp1); } .turn.sp1 .spk { color: var(--sp1); }
.turn.sp2 { border-left: 3px solid var(--sp2); } .turn.sp2 .spk { color: var(--sp2); }
.turn.sp3 { border-left: 3px solid var(--sp3); } .turn.sp3 .spk { color: var(--sp3); }
.spk-edit { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; }
.spk-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: var(--inset); border-left: 3px solid var(--line); }
.spk-row.sp0 { border-left-color: var(--sp0); } .spk-row.sp1 { border-left-color: var(--sp1); }
.spk-row.sp2 { border-left-color: var(--sp2); } .spk-row.sp3 { border-left-color: var(--sp3); }
.spk-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex: none; }
.spk-row.sp0 .spk-dot { background: var(--sp0); } .spk-row.sp1 .spk-dot { background: var(--sp1); }
.spk-row.sp2 .spk-dot { background: var(--sp2); } .spk-row.sp3 .spk-dot { background: var(--sp3); }
.spk-name { flex: 1; min-width: 80px; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: var(--fg); }
.spk-learn-l { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.ana-time { font-variant-numeric: tabular-nums; font-weight: 600; }
.ana-time.live { color: var(--accent); }
.ana-line { margin: 2px 0 8px; }
/* ---- Aufnahmen: Liste links, Detail rechts ---- */
.rec-layout { display: flex; gap: 8px; align-items: flex-start; }
.rec-splitter {
flex: 0 0 6px; align-self: stretch; cursor: col-resize; border-radius: 3px;
background: transparent; transition: background .12s;
}
.rec-splitter:hover, body.resizing .rec-splitter { background: var(--accent-soft); }
body.resizing { cursor: col-resize; user-select: none; }
.rec-list-pane {
flex: 0 0 340px; position: sticky; top: 12px; max-height: calc(100vh - 90px);
overflow-y: auto; background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 10px; box-shadow: var(--shadow);
}
.rec-detail-pane { flex: 1 1 auto; min-width: 0; }
.rec-detail-pane .card { margin-bottom: 0; }
.detail-empty { padding: 40px 16px; text-align: center; border: 1px dashed var(--line); border-radius: 14px; }
.day-group { margin-bottom: 6px; }
.day-head {
display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;
color: var(--muted); text-transform: uppercase; letter-spacing: .03em; padding: 6px 4px; user-select: none;
}
.day-head:hover { color: var(--fg); }
.day-caret { display: inline-block; transition: transform .15s; font-size: 10px; }
.day-group.collapsed .day-caret { transform: rotate(-90deg); }
.day-group.collapsed .day-items { display: none; }
.day-count { margin-left: auto; background: var(--accent-soft); color: var(--accent); border-radius: 20px; padding: 1px 8px; font-size: 11px; font-weight: 650; }
.day-items { display: flex; flex-direction: column; gap: 6px; }
.item.rec { cursor: pointer; transition: border-color .1s, background .1s; }
.item.rec:hover { border-color: var(--accent); }
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */
.status-line { margin: 4px 0 10px; }
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
.mod-col { min-width: 0; display: flex; flex-direction: column; gap: 10px; min-height: 60px; }
.mod-col[data-col="main"] { flex: 1 1 54%; }
.mod-col[data-col="side"] { flex: 1 1 46%; }
.mod-col.empty { flex: 0 0 26px; border: 1px dashed var(--line); border-radius: 12px; align-self: stretch; }
.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; position: sticky; top: 12px; }
.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; }
@media (max-width: 1100px) {
.detail-cols { flex-direction: column; }
.mod-col { width: 100%; }
.mod-col.empty { display: none; }
.transcript-panel { max-height: 60vh; position: static; }
}
.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.module.dragging { opacity: .45; border-style: dashed; }
.mod-head {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab;
background: var(--surface-2); user-select: none;
}
.mod-head:active { cursor: grabbing; }
.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; }
.mod-body { padding: 12px; }
.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); }
/* ---- Sprecher-Verlauf: fester Streifen unter dem Player, volle Breite ---- */
.viz-top {
position: relative; margin: 4px 0 10px; padding: 10px 12px; background: var(--inset);
border: 1px solid var(--line); border-radius: 12px;
}
.lane-label { cursor: pointer; }
.lane-label:hover { color: var(--fg); text-decoration: underline dotted; }
.spkpop {
position: absolute; left: 12px; z-index: 5; width: 270px;
background: var(--surface); border: 1px solid var(--accent); border-radius: 10px;
padding: 10px 12px; box-shadow: 0 6px 22px rgba(0, 0, 0, .35);
}
.spkpop .input { margin-bottom: 6px; }
.viz-wrap { display: flex; gap: 14px; align-items: stretch; }
.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
.lane-row { display: flex; align-items: center; gap: 8px; }
.lane-label {
flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px;
color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; }
.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; }
.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); }
.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); }
.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); }
.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); }
.lane-row.speaking .seg { opacity: 1; }
.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; }
.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } }
.playhead {
display: none; position: absolute; top: 0; bottom: 0; width: 2px;
background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2;
transition: left .25s linear;
}
.lanes { flex: 1 1 100%; }
@media (max-width: 800px) {
#app { padding: 16px; }
.rec-layout { flex-direction: column; }
.rec-list-pane { flex: none !important; width: 100%; position: static; max-height: 340px; }
.rec-splitter { display: none; }
.topbar nav { margin-left: 0; width: 100%; order: 3; }
}
/* ---- Konferenztisch-Ansicht ---- */
.hidden { display: none !important; }
.tablescene { position: relative; width: 100%; aspect-ratio: 4 / 3; max-height: 420px; }
.ttable {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 58%; height: 44%; border-radius: 18px;
background: var(--surface-2); border: 2px solid var(--line);
box-shadow: inset 0 0 0 6px var(--inset);
}
.tperson { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 2px; width: 76px; }
.thead {
position: relative; width: 30px; height: 30px; border-radius: 50%;
background: var(--muted); z-index: 2;
}
.tperson.sp0 .thead, .tperson.sp0 .tbody { background: var(--sp0); }
.tperson.sp1 .thead, .tperson.sp1 .tbody { background: var(--sp1); }
.tperson.sp2 .thead, .tperson.sp2 .tbody { background: var(--sp2); }
.tperson.sp3 .thead, .tperson.sp3 .tbody { background: var(--sp3); }
.teyes { position: absolute; left: 0; right: 0; top: 9px; display: flex; justify-content: center; gap: 7px; transition: transform .25s; }
.teye { width: 5px; height: 5px; border-radius: 50%; background: #17130f; }
:root[data-theme="light"] .teye { background: #fff; }
.tmouth {
position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%);
width: 9px; height: 3px; border-radius: 2px; background: rgba(0, 0, 0, .55); opacity: 0;
}
:root[data-theme="light"] .tmouth { background: rgba(255, 255, 255, .8); }
.tperson.talking .tmouth { opacity: 1; animation: talkmouth .35s ease-in-out infinite; }
@keyframes talkmouth { 0%, 100% { height: 2px; } 50% { height: 7px; } }
.tperson.talking .thead { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 10px var(--accent-soft); }
.tbody { width: 46px; height: 20px; border-radius: 12px 12px 5px 5px; background: var(--muted); margin-top: -6px; opacity: .85; }
.tname { font-size: 11px; color: var(--muted); max-width: 76px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tperson.talking .tname { color: var(--fg); font-weight: 650; }
.tbubble {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 54%; z-index: 3;
background: var(--surface); border: 2px solid var(--line); border-radius: 14px;
padding: 8px 10px; font-size: 13px; line-height: 1.55;
transition: border-color .2s;
}
.tb-scroll { max-height: 150px; overflow-y: auto; }
.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; }
.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; }
.tbubble .bw:hover { background: var(--surface-2); }
.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); }
.tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); }
.tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); }
.tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); }
.tbubble.sp3 { border-color: var(--sp3); } .tbubble.sp3 .tb-name { color: var(--sp3); }
.tbubble::after {
content: ''; position: absolute; width: 12px; height: 12px;
background: inherit; border: inherit; border-width: 0 2px 2px 0;
display: none;
}
.tbubble[class*="arrow-"]::after { display: block; }
.tbubble.arrow-top::after { top: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(-135deg); }
.tbubble.arrow-bottom::after { bottom: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(45deg); }
.tbubble.arrow-left::after { left: -7px; top: 50%; transform: translateY(-50%) rotate(135deg); }
.tbubble.arrow-right::after { right: -7px; top: 50%; transform: translateY(-50%) rotate(-45deg); }
.tidle { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: 13px; text-align: center; }
#viewswitch svg { display: block; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
:root[data-theme="dark"] { /* ==========================================================================
--bg: #14161b; Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine
--surface: #1d222a; dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen.
--surface-2: #262c36; ========================================================================== */
--inset: #10141a; .search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; }
--line: #303845; /* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */
--fg: #e9ecf1; .search-page.landing { padding-top: 12vh; }
--muted: #98a2b3; .search-page.has-results { padding-top: 1.25rem; }
--accent: #e8804c;
--accent-fg: #17130f;
--accent-soft: rgba(232, 128, 76, .14);
--ok: #55b381;
--ok-soft: rgba(85, 179, 129, .14);
--warn: #e2b93b;
--warn-soft: rgba(226, 185, 59, .14);
--danger: #e5595e;
--danger-soft: rgba(229, 89, 94, .12);
--shadow: none;
--sp0: #5bc0eb; --sp1: #ef8fc0; --sp2: #a8cc4e; --sp3: #e6b84c;
--sp0-w: #8fd6f5; --sp1-w: #f5b5d6; --sp2-w: #c5e07e; --sp3-w: #f0d08a;
}
:root[data-theme="light"] {
--bg: #f4f2ee;
--surface: #ffffff;
--surface-2: #eceae4;
--inset: #f7f6f2;
--line: #ddd8cf;
--fg: #23272e;
--muted: #6d7683;
--accent: #c05f2d;
--accent-fg: #ffffff;
--accent-soft: rgba(192, 95, 45, .1);
--ok: #2f8a5d;
--ok-soft: rgba(47, 138, 93, .12);
--warn: #9a7514;
--warn-soft: rgba(154, 117, 20, .12);
--danger: #c73e43;
--danger-soft: rgba(199, 62, 67, .09);
--shadow: 0 1px 3px rgba(35, 39, 46, .07);
--sp0: #1179a8; --sp1: #c2417f; --sp2: #5f7d16; --sp3: #a4700d;
--sp0-w: #0e6b95; --sp1-w: #ad3a72; --sp2-w: #556f14; --sp3-w: #92640b;
}
* { box-sizing: border-box; } .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); }
/* Scrollbalken im Logo-Kupfer (Firefox: scrollbar-color; Chromium/WebKit: ::-webkit-*) */ .search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
* { scrollbar-width: thin; scrollbar-color: var(--accent) transparent; } .search-hints .example {
::-webkit-scrollbar { width: 10px; height: 10px; } border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2);
::-webkit-scrollbar-track { background: transparent; } color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left;
::-webkit-scrollbar-thumb {
background: var(--accent); border-radius: 5px;
border: 2px solid transparent; background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { background: var(--accent); filter: brightness(1.15); } .search-hints .example:hover { border-color: var(--accent); color: var(--accent); }
::-webkit-scrollbar-corner { background: transparent; }
html { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif;
transition: background .2s, color .2s;
}
/* Volle Fensterbreite für ALLE Tabs. Früher war nur „Aufnahmen“ breit (.wide)
und der Rest auf 860px zentriert — dadurch sprang die Kopfzeile beim
Tabwechsel in die Mitte. */
#app { max-width: none; margin: 0 auto; padding: 16px 28px; }
/* ---- Topbar / Brand ---- */ .searching { padding: 1.5rem 0; }
.topbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 6px 0 14px; } .search-empty { padding: 1.5rem 0; }
.brand { display: flex; align-items: center; gap: 10px; }
.brand-wave { width: 30px; height: 18px; color: var(--accent); flex: none; }
.topbar h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.01em; }
.topbar .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 6px; }
nav { display: flex; gap: 6px; flex-wrap: wrap; }
.topbar nav { margin-left: 0; }
/* Aufnahme-Steuerung in der Kopfzeile (zwischen Logo und Tabs) */ /* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */
.rec-ctl { display: flex; align-items: center; gap: 10px; margin: 0 auto; } .answer-box {
.recbtn { margin: 1.25rem 0 .5rem; padding: .9rem 1rem;
width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--danger); background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem;
background: var(--danger-soft); color: var(--danger); font-size: 15px; line-height: 1;
cursor: pointer; transition: background .15s, transform .1s;
} }
.recbtn:hover { background: var(--danger); color: #fff; } .answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; }
.recbtn.rec { background: var(--danger); color: #fff; animation: recpulse 1.4s ease-in-out infinite; } .answer-body { line-height: 1.55; white-space: pre-wrap; }
@keyframes recpulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 89, 94, .45); } 50% { box-shadow: 0 0 0 9px rgba(229, 89, 94, 0); } } /* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */
.recbtn:disabled { opacity: .55; cursor: default; animation: none; } .answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; }
.rec-ctl .rectime { font-size: 14px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 44px; } .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); }
/* Namensmarker während der Aufnahme (Kopfzeile) */ .result-meta { margin: .9rem 0 .3rem; }
.rec-ctl .mkin { width: 210px; padding: 6px 10px; font-size: 13px; } .results { list-style: none; margin: 0; padding: 0; }
@media (max-width: 1100px) { .rec-ctl .mkin { width: 130px; } } .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; }
/* Live-Platzhalter in der Aufnahmen-Liste */ /* ==========================================================================
.item.rec-live { border-color: var(--danger); background: var(--danger-soft); } Papierkorb + Umbenennen
.rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); flex: none; animation: recpulse 1.4s ease-in-out infinite; } ========================================================================== */
.item.rec-live .rectime { color: var(--danger); font-weight: 650; } .list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
.mkchips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } .btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.mkchip { .btn.ghost:hover { color: var(--fg); border-color: var(--line); }
display: inline-flex; align-items: center; gap: 5px; font-size: 12px; .btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 4px 2px 8px; .trash-hint, .trash-empty { padding: .6rem .75rem; }
} .item.rec.trashed { opacity: .8; }
.mkchip .mono { color: var(--muted); } .item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); }
.mkdel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; line-height: 1; padding: 2px 4px; } .days-left { color: var(--muted); }
.mkdel:hover { color: var(--danger); } .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; }
/* Namensmarker-Liste im Detail */ @media (max-width: 640px) {
.markers { list-style: none; margin: 6px 0; padding: 0; } .search-page.landing { padding-top: 6vh; }
.markers li { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 13px; } .search-go { padding: .55rem .8rem; }
.mkseek { }
background: var(--surface-2); border: 1px solid var(--line); border-radius: 7px;
color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 7px;
}
.mkseek:hover { border-color: var(--accent); }
.mkadd-row { margin-top: 8px; }
.mkadd-row .input { flex: 1; min-width: 120px; }
.tab {
background: transparent; color: var(--muted); border: 1px solid transparent;
padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px;
transition: color .12s, background .12s;
}
.tab:hover { color: var(--fg); background: var(--surface-2); }
.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; }
/* ---- Bausteine ---- */
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 18px; margin-bottom: 16px; box-shadow: var(--shadow);
}
h2 { margin: 0 0 12px; font-size: 18px; letter-spacing: -.01em; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.grow { flex: 1; }
.btn {
background: var(--surface-2); color: var(--fg); border: 1px solid var(--line);
border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px;
transition: border-color .12s, background .12s;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.07); }
.btn.danger { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.btn.danger:hover { border-color: var(--danger); }
.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; }
.btn.small { font-size: .8rem; padding: 3px 8px; }
.btn.icon { padding: 6px 9px; flex: none; }
.btn:disabled { opacity: .55; cursor: default; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.input, select {
width: 100%; background: var(--inset); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px;
}
.input:focus, select:focus { border-color: var(--accent); outline: none; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item {
display: flex; gap: 10px; align-items: center;
background: var(--inset); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
}
.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: var(--surface-2); color: var(--muted); }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, monospace; font-size: 12px; }
.err { color: var(--danger); }
.err-inline { color: var(--danger); margin-bottom: 6px; }
.hint { font-size: .8rem; color: var(--muted); margin-bottom: 10px; }
/* ---- Aufnahme ---- */
.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; }
.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; }
audio { width: 100%; margin-bottom: 10px; }
/* ---- Transkript ---- */
.transcript { line-height: 2.1; font-size: 16px; }
.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; }
.transcript .w:hover { background: var(--surface-2); }
.transcript .w.active, .w.active { background: var(--accent); color: var(--accent-fg); }
.w.sp0 { color: var(--sp0-w); } .w.sp1 { color: var(--sp1-w); }
.w.sp2 { color: var(--sp2-w); } .w.sp3 { color: var(--sp3-w); }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--accent); color: var(--fg);
padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; box-shadow: var(--shadow);
}
.toast.err { border-color: var(--danger); }
/* ---- Analyse ---- */
.summary-snip {
font-size: .85rem; color: var(--muted); margin-top: 4px; overflow: hidden;
text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.analysis { margin: 10px 0; padding: 12px; background: var(--inset); border: 1px solid var(--line); border-radius: 10px; }
.analysis h3 { margin: 8px 0 4px; font-size: .9rem; color: var(--muted); }
.summary { margin: 0 0 6px; line-height: 1.55; white-space: pre-line; }
.todos { margin: 0; padding-left: 20px; }
.todos li { margin: 3px 0; }
.parts { margin: 0; padding-left: 20px; } .parts li { margin: 3px 0; }
.clean { line-height: 1.55; background: var(--inset); padding: 8px 10px; border-radius: 8px; margin: 0 0 4px; }
/* ---- Dialog-Turns / Sprecher ---- */
.dialog { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.turn { padding: 6px 10px; border-radius: 8px; background: var(--inset); line-height: 1.5; }
.turn .spk { margin-right: 6px; font-weight: 600; }
.turn.sp0 { border-left: 3px solid var(--sp0); } .turn.sp0 .spk { color: var(--sp0); }
.turn.sp1 { border-left: 3px solid var(--sp1); } .turn.sp1 .spk { color: var(--sp1); }
.turn.sp2 { border-left: 3px solid var(--sp2); } .turn.sp2 .spk { color: var(--sp2); }
.turn.sp3 { border-left: 3px solid var(--sp3); } .turn.sp3 .spk { color: var(--sp3); }
.spk-edit { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; }
.spk-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: var(--inset); border-left: 3px solid var(--line); }
.spk-row.sp0 { border-left-color: var(--sp0); } .spk-row.sp1 { border-left-color: var(--sp1); }
.spk-row.sp2 { border-left-color: var(--sp2); } .spk-row.sp3 { border-left-color: var(--sp3); }
.spk-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex: none; }
.spk-row.sp0 .spk-dot { background: var(--sp0); } .spk-row.sp1 .spk-dot { background: var(--sp1); }
.spk-row.sp2 .spk-dot { background: var(--sp2); } .spk-row.sp3 .spk-dot { background: var(--sp3); }
.spk-name { flex: 1; min-width: 80px; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: var(--fg); }
.spk-learn-l { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.ana-time { font-variant-numeric: tabular-nums; font-weight: 600; }
.ana-time.live { color: var(--accent); }
.ana-line { margin: 2px 0 8px; }
/* ---- Aufnahmen: Liste links, Detail rechts ---- */
.rec-layout { display: flex; gap: 8px; align-items: flex-start; }
.rec-splitter {
flex: 0 0 6px; align-self: stretch; cursor: col-resize; border-radius: 3px;
background: transparent; transition: background .12s;
}
.rec-splitter:hover, body.resizing .rec-splitter { background: var(--accent-soft); }
body.resizing { cursor: col-resize; user-select: none; }
.rec-list-pane {
flex: 0 0 340px; position: sticky; top: 12px; max-height: calc(100vh - 90px);
overflow-y: auto; background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 10px; box-shadow: var(--shadow);
}
.rec-detail-pane { flex: 1 1 auto; min-width: 0; }
.rec-detail-pane .card { margin-bottom: 0; }
.detail-empty { padding: 40px 16px; text-align: center; border: 1px dashed var(--line); border-radius: 14px; }
.day-group { margin-bottom: 6px; }
.day-head {
display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;
color: var(--muted); text-transform: uppercase; letter-spacing: .03em; padding: 6px 4px; user-select: none;
}
.day-head:hover { color: var(--fg); }
.day-caret { display: inline-block; transition: transform .15s; font-size: 10px; }
.day-group.collapsed .day-caret { transform: rotate(-90deg); }
.day-group.collapsed .day-items { display: none; }
.day-count { margin-left: auto; background: var(--accent-soft); color: var(--accent); border-radius: 20px; padding: 1px 8px; font-size: 11px; font-weight: 650; }
.day-items { display: flex; flex-direction: column; gap: 6px; }
.item.rec { cursor: pointer; transition: border-color .1s, background .1s; }
.item.rec:hover { border-color: var(--accent); }
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */
.status-line { margin: 4px 0 10px; }
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
.mod-col { min-width: 0; display: flex; flex-direction: column; gap: 10px; min-height: 60px; }
.mod-col[data-col="main"] { flex: 1 1 54%; }
.mod-col[data-col="side"] { flex: 1 1 46%; }
.mod-col.empty { flex: 0 0 26px; border: 1px dashed var(--line); border-radius: 12px; align-self: stretch; }
.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; position: sticky; top: 12px; }
.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; }
@media (max-width: 1100px) {
.detail-cols { flex-direction: column; }
.mod-col { width: 100%; }
.mod-col.empty { display: none; }
.transcript-panel { max-height: 60vh; position: static; }
}
.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.module.dragging { opacity: .45; border-style: dashed; }
.mod-head {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab;
background: var(--surface-2); user-select: none;
}
.mod-head:active { cursor: grabbing; }
.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; }
.mod-body { padding: 12px; }
.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); }
/* ---- Sprecher-Verlauf: fester Streifen unter dem Player, volle Breite ---- */
.viz-top {
position: relative; margin: 4px 0 10px; padding: 10px 12px; background: var(--inset);
border: 1px solid var(--line); border-radius: 12px;
}
.lane-label { cursor: pointer; }
.lane-label:hover { color: var(--fg); text-decoration: underline dotted; }
.spkpop {
position: absolute; left: 12px; z-index: 5; width: 270px;
background: var(--surface); border: 1px solid var(--accent); border-radius: 10px;
padding: 10px 12px; box-shadow: 0 6px 22px rgba(0, 0, 0, .35);
}
.spkpop .input { margin-bottom: 6px; }
.viz-wrap { display: flex; gap: 14px; align-items: stretch; }
.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
.lane-row { display: flex; align-items: center; gap: 8px; }
.lane-label {
flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px;
color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; }
.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; }
.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); }
.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); }
.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); }
.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); }
.lane-row.speaking .seg { opacity: 1; }
.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; }
.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } }
.playhead {
display: none; position: absolute; top: 0; bottom: 0; width: 2px;
background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2;
transition: left .25s linear;
}
.lanes { flex: 1 1 100%; }
@media (max-width: 800px) {
#app { padding: 16px; }
.rec-layout { flex-direction: column; }
.rec-list-pane { flex: none !important; width: 100%; position: static; max-height: 340px; }
.rec-splitter { display: none; }
.topbar nav { margin-left: 0; width: 100%; order: 3; }
}
/* ---- Konferenztisch-Ansicht ---- */
.hidden { display: none !important; }
.tablescene { position: relative; width: 100%; aspect-ratio: 4 / 3; max-height: 420px; }
.ttable {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 58%; height: 44%; border-radius: 18px;
background: var(--surface-2); border: 2px solid var(--line);
box-shadow: inset 0 0 0 6px var(--inset);
}
.tperson { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 2px; width: 76px; }
.thead {
position: relative; width: 30px; height: 30px; border-radius: 50%;
background: var(--muted); z-index: 2;
}
.tperson.sp0 .thead, .tperson.sp0 .tbody { background: var(--sp0); }
.tperson.sp1 .thead, .tperson.sp1 .tbody { background: var(--sp1); }
.tperson.sp2 .thead, .tperson.sp2 .tbody { background: var(--sp2); }
.tperson.sp3 .thead, .tperson.sp3 .tbody { background: var(--sp3); }
.teyes { position: absolute; left: 0; right: 0; top: 9px; display: flex; justify-content: center; gap: 7px; transition: transform .25s; }
.teye { width: 5px; height: 5px; border-radius: 50%; background: #17130f; }
:root[data-theme="light"] .teye { background: #fff; }
.tmouth {
position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%);
width: 9px; height: 3px; border-radius: 2px; background: rgba(0, 0, 0, .55); opacity: 0;
}
:root[data-theme="light"] .tmouth { background: rgba(255, 255, 255, .8); }
.tperson.talking .tmouth { opacity: 1; animation: talkmouth .35s ease-in-out infinite; }
@keyframes talkmouth { 0%, 100% { height: 2px; } 50% { height: 7px; } }
.tperson.talking .thead { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 10px var(--accent-soft); }
.tbody { width: 46px; height: 20px; border-radius: 12px 12px 5px 5px; background: var(--muted); margin-top: -6px; opacity: .85; }
.tname { font-size: 11px; color: var(--muted); max-width: 76px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tperson.talking .tname { color: var(--fg); font-weight: 650; }
.tbubble {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 54%; z-index: 3;
background: var(--surface); border: 2px solid var(--line); border-radius: 14px;
padding: 8px 10px; font-size: 13px; line-height: 1.55;
transition: border-color .2s;
}
.tb-scroll { max-height: 150px; overflow-y: auto; }
.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; }
.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; }
.tbubble .bw:hover { background: var(--surface-2); }
.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); }
.tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); }
.tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); }
.tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); }
.tbubble.sp3 { border-color: var(--sp3); } .tbubble.sp3 .tb-name { color: var(--sp3); }
.tbubble::after {
content: ''; position: absolute; width: 12px; height: 12px;
background: inherit; border: inherit; border-width: 0 2px 2px 0;
display: none;
}
.tbubble[class*="arrow-"]::after { display: block; }
.tbubble.arrow-top::after { top: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(-135deg); }
.tbubble.arrow-bottom::after { bottom: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(45deg); }
.tbubble.arrow-left::after { left: -7px; top: 50%; transform: translateY(-50%) rotate(135deg); }
.tbubble.arrow-right::after { right: -7px; top: 50%; transform: translateY(-50%) rotate(-45deg); }
.tidle { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: 13px; text-align: center; }
#viewswitch svg { display: block; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
+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`.