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
+332 -254
View File
@@ -1,254 +1,332 @@
import mysql from 'mysql2/promise';
export const pool = mysql.createPool({
host: 'localhost', user: 'callrec', password: 'callrec123',
database: 'callrec', connectionLimit: 5, waitForConnections: true
});
// Idempotent migration: the LLM analysis columns (auto title lives in `title`).
// MariaDB supports ADD COLUMN IF NOT EXISTS.
export async function ensureSchema() {
await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS summary TEXT NULL,
ADD COLUMN IF NOT EXISTS todos TEXT NULL,
ADD COLUMN IF NOT EXISTS participants 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 speaker_names 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_started_at BIGINT 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(`CREATE TABLE IF NOT EXISTS voiceprints (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
dim INT NOT NULL,
vec MEDIUMTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// 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`);
// Embedding model the vector came from: prints of different models are NOT
// comparable (cosine across models is noise), so identification only ever
// 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(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`);
// 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
// to the correct spelling. `soundalikes` = optional comma-list of mis-hearings.
await pool.query(`CREATE TABLE IF NOT EXISTS glossary (
id INT AUTO_INCREMENT PRIMARY KEY,
term VARCHAR(190) NOT NULL UNIQUE,
soundalikes TEXT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// Live speaker markers: names typed DURING the recording ("who is talking
// right now"). t_ms = offset from recording start. After diarization each
// marker is resolved to the acoustic cluster that was speaking around t_ms
// (`speaker`), which names that cluster — ground truth instead of guessing.
await pool.query(`CREATE TABLE IF NOT EXISTS markers (
id INT AUTO_INCREMENT PRIMARY KEY,
recording_id INT NOT NULL,
t_ms INT NOT NULL,
name VARCHAR(128) NOT NULL,
speaker VARCHAR(32) NULL,
conf VARCHAR(8) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX rec_idx (recording_id))`);
}
// --- live speaker markers ---------------------------------------------------
export async function addMarkers(recId, markers) {
const list = (markers || []).filter(m => m && m.name);
if (!list.length) return;
await pool.query('INSERT INTO markers (recording_id, t_ms, name) VALUES ?',
[list.map(m => [recId, Math.max(0, Math.round(m.t_ms || 0)), String(m.name).slice(0, 128)])]);
}
export async function listMarkers(recId) {
const [rows] = await pool.query(
'SELECT id, t_ms, name, speaker, conf FROM markers WHERE recording_id=? ORDER BY t_ms', [recId]);
return rows;
}
export async function setMarkerSpeaker(id, speaker, conf) {
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 addMarker(recId, tMs, name) {
const [res] = await pool.execute('INSERT INTO markers (recording_id, t_ms, name) VALUES (?,?,?)',
[recId, Math.max(0, Math.round(tMs || 0)), String(name).slice(0, 128)]);
return res.insertId;
}
export async function listGlossary() {
const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term');
return rows.map(r => ({ ...r, enabled: !!r.enabled }));
}
export async function addGlossary(term, soundalikes) {
await pool.execute(
'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1',
[term, soundalikes || null]);
const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]);
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
}
export async function deleteGlossary(id) {
await pool.execute('DELETE FROM glossary WHERE id=?', [id]);
}
export async function updateGlossary(id, term, soundalikes) {
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]);
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
}
export async function addVoiceprint(name, vec, model) {
const [res] = await pool.execute(
'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)',
[name, vec.length, JSON.stringify(vec), model || null]);
return res.insertId;
}
// Add a sample to a named voiceprint, keeping ONE row per name as a running
// 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,
// so future recordings match them more reliably. New name -> fresh row.
export async function upsertVoiceprint(name, vec, model) {
const [rows] = await pool.query(
'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1',
[name, model || null, model || null]);
if (!rows.length) {
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;
}
const old = rows[0];
const on = old.n || 1;
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=?',
[JSON.stringify(mean), on + 1, mean.length, old.id]);
return old.id;
}
// model given → only the prints of that embedding model (what identification
// must use); omitted → all rows (GUI listing).
export async function listVoiceprints(model) {
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');
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
}
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
export async function addRecording(r) {
const [res] = await pool.execute(
'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)',
[r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']);
return res.insertId;
}
export async function addWords(recId, words) {
if (!words || !words.length) return;
const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]);
await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]);
}
// Replace a recording's words wholesale (used when re-diarizing: the text is
// unchanged, only the speaker labels differ). Words are re-indexed by array order.
export async function replaceWords(recId, words) {
await pool.query('DELETE FROM words WHERE recording_id=?', [recId]);
await addWords(recId, words);
}
// Store the transcript + words after async (thermal-guarded) transcription and
// flip the row out of 'transcribing'. The wav + row were saved before whisper
// ran, so this just fills them in.
export async function setTranscript(id, transcript, words) {
await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]);
await replaceWords(id, words || []);
}
// Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The
// 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 markAnalysisStart(id) {
await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]);
}
// Update just the coarse recording status ('transcribing' | 'done' | 'error').
export async function setStatus(id, status) {
await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]);
}
// Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName)
// are authoritative; acoustic identification only fills blanks (mergeSpeakerNames).
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 {}; }
}
export async function setSpeakerName(id, speaker, name) {
const m = await getSpeakerNames(id);
if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N"
await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
}
// The label EVERY speaker carried in the text the LLM produced ({speaker_07:
// "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) {
await pool.execute('UPDATE recordings SET speaker_labels=? WHERE id=?', [JSON.stringify(labels || {}), id]);
}
export async function getSpeakerLabels(id) {
const [rows] = await pool.query('SELECT speaker_labels FROM recordings WHERE id=?', [id]);
try { return JSON.parse((rows[0] && rows[0].speaker_labels) || '{}') || {}; } catch { return {}; }
}
// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
export async function mergeSpeakerNames(id, map) {
if (!map || !Object.keys(map).length) return;
const m = await getSpeakerNames(id);
let changed = false;
for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; }
if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
}
// Store the LLM result. `todos` is an array → persisted as JSON text.
export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) {
const st = status || 'done';
await pool.execute(
`UPDATE recordings SET title=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
WHERE id=?`,
[title || null, summary || null, todos ? JSON.stringify(todos) : null,
participants ? JSON.stringify(participants) : null, transcriptClean || null, st,
st, Date.now(), id]);
}
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;
export async function listRecordings() {
const [rows] = await pool.query(
'SELECT id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at FROM recordings ORDER BY id DESC');
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 };
}
export async function deleteRecording(id) {
await pool.query('DELETE FROM markers WHERE recording_id=?', [id]).catch(() => {});
await pool.query('DELETE FROM recordings WHERE id=?', [id]);
}
import mysql from 'mysql2/promise';
export const pool = mysql.createPool({
host: 'localhost', user: 'callrec', password: 'callrec123',
database: 'callrec', connectionLimit: 5, waitForConnections: true
});
// Idempotent migration: the LLM analysis columns (auto title lives in `title`).
// MariaDB supports ADD COLUMN IF NOT EXISTS.
export async function ensureSchema() {
await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS summary TEXT NULL,
ADD COLUMN IF NOT EXISTS todos TEXT NULL,
ADD COLUMN IF NOT EXISTS participants 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 speaker_names 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_started_at BIGINT 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(`CREATE TABLE IF NOT EXISTS voiceprints (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
dim INT NOT NULL,
vec MEDIUMTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// 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`);
// Embedding model the vector came from: prints of different models are NOT
// comparable (cosine across models is noise), so identification only ever
// 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(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`);
// 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
// to the correct spelling. `soundalikes` = optional comma-list of mis-hearings.
await pool.query(`CREATE TABLE IF NOT EXISTS glossary (
id INT AUTO_INCREMENT PRIMARY KEY,
term VARCHAR(190) NOT NULL UNIQUE,
soundalikes TEXT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
// Live speaker markers: names typed DURING the recording ("who is talking
// right now"). t_ms = offset from recording start. After diarization each
// marker is resolved to the acoustic cluster that was speaking around t_ms
// (`speaker`), which names that cluster — ground truth instead of guessing.
await pool.query(`CREATE TABLE IF NOT EXISTS markers (
id INT AUTO_INCREMENT PRIMARY KEY,
recording_id INT NOT NULL,
t_ms INT NOT NULL,
name VARCHAR(128) NOT NULL,
speaker VARCHAR(32) NULL,
conf VARCHAR(8) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX rec_idx (recording_id))`);
// Trash instead of hard delete: `deleted_at` set = in the bin, rows and audio
// files stay untouched until purgeExpired() passes TRASH_DAYS. Every listing
// filters on it, so a binned recording is invisible everywhere else.
// `title_locked` marks a title the USER typed — a later re-analysis must not
// overwrite it with a fresh LLM guess (see updateAnalysis).
await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS title_locked TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS doc_vec MEDIUMTEXT NULL`);
// Search index. One row per passage (a few sentences of ONE speaker, see
// search.js/buildPassages), carrying the time window so a hit can name the
// exact minute and the player can jump there. `vec` = optional text embedding
// for semantic ranking; NULL when the embedding model is not installed, in
// which case search degrades to full-text only.
await pool.query(`CREATE TABLE IF NOT EXISTS passages (
id INT AUTO_INCREMENT PRIMARY KEY,
recording_id INT NOT NULL,
idx INT NOT NULL,
speaker VARCHAR(32) NULL,
speaker_name VARCHAR(128) NULL,
t_start_ms INT NOT NULL,
t_end_ms INT NOT NULL,
text TEXT NOT NULL,
vec MEDIUMTEXT NULL,
INDEX rec_idx (recording_id),
FULLTEXT KEY ft_text (text)) ENGINE=InnoDB`);
}
// --- live speaker markers ---------------------------------------------------
export async function addMarkers(recId, markers) {
const list = (markers || []).filter(m => m && m.name);
if (!list.length) return;
await pool.query('INSERT INTO markers (recording_id, t_ms, name) VALUES ?',
[list.map(m => [recId, Math.max(0, Math.round(m.t_ms || 0)), String(m.name).slice(0, 128)])]);
}
export async function listMarkers(recId) {
const [rows] = await pool.query(
'SELECT id, t_ms, name, speaker, conf FROM markers WHERE recording_id=? ORDER BY t_ms', [recId]);
return rows;
}
export async function setMarkerSpeaker(id, speaker, conf) {
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 addMarker(recId, tMs, name) {
const [res] = await pool.execute('INSERT INTO markers (recording_id, t_ms, name) VALUES (?,?,?)',
[recId, Math.max(0, Math.round(tMs || 0)), String(name).slice(0, 128)]);
return res.insertId;
}
export async function listGlossary() {
const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term');
return rows.map(r => ({ ...r, enabled: !!r.enabled }));
}
export async function addGlossary(term, soundalikes) {
await pool.execute(
'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1',
[term, soundalikes || null]);
const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]);
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
}
export async function deleteGlossary(id) {
await pool.execute('DELETE FROM glossary WHERE id=?', [id]);
}
export async function updateGlossary(id, term, soundalikes) {
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]);
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
}
export async function addVoiceprint(name, vec, model) {
const [res] = await pool.execute(
'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)',
[name, vec.length, JSON.stringify(vec), model || null]);
return res.insertId;
}
// Add a sample to a named voiceprint, keeping ONE row per name as a running
// 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,
// so future recordings match them more reliably. New name -> fresh row.
export async function upsertVoiceprint(name, vec, model) {
const [rows] = await pool.query(
'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1',
[name, model || null, model || null]);
if (!rows.length) {
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;
}
const old = rows[0];
const on = old.n || 1;
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=?',
[JSON.stringify(mean), on + 1, mean.length, old.id]);
return old.id;
}
// model given → only the prints of that embedding model (what identification
// must use); omitted → all rows (GUI listing).
export async function listVoiceprints(model) {
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');
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
}
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
export async function addRecording(r) {
const [res] = await pool.execute(
'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)',
[r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']);
return res.insertId;
}
export async function addWords(recId, words) {
if (!words || !words.length) return;
const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]);
await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]);
}
// Replace a recording's words wholesale (used when re-diarizing: the text is
// unchanged, only the speaker labels differ). Words are re-indexed by array order.
export async function replaceWords(recId, words) {
await pool.query('DELETE FROM words WHERE recording_id=?', [recId]);
await addWords(recId, words);
}
// Store the transcript + words after async (thermal-guarded) transcription and
// flip the row out of 'transcribing'. The wav + row were saved before whisper
// ran, so this just fills them in.
export async function setTranscript(id, transcript, words) {
await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]);
await replaceWords(id, words || []);
}
// Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The
// 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 markAnalysisStart(id) {
await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]);
}
// Update just the coarse recording status ('transcribing' | 'done' | 'error').
export async function setStatus(id, status) {
await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]);
}
// Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName)
// are authoritative; acoustic identification only fills blanks (mergeSpeakerNames).
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 {}; }
}
export async function setSpeakerName(id, speaker, name) {
const m = await getSpeakerNames(id);
if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N"
await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
}
// The label EVERY speaker carried in the text the LLM produced ({speaker_07:
// "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) {
await pool.execute('UPDATE recordings SET speaker_labels=? WHERE id=?', [JSON.stringify(labels || {}), id]);
}
export async function getSpeakerLabels(id) {
const [rows] = await pool.query('SELECT speaker_labels FROM recordings WHERE id=?', [id]);
try { return JSON.parse((rows[0] && rows[0].speaker_labels) || '{}') || {}; } catch { return {}; }
}
// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
export async function mergeSpeakerNames(id, map) {
if (!map || !Object.keys(map).length) return;
const m = await getSpeakerNames(id);
let changed = false;
for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; }
if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
}
// Store the LLM result. `todos` is an array → persisted as JSON text.
// A user-typed title (title_locked=1) survives every re-analysis: without the
// CASE the LLM would silently rename the recording back on the next run.
export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) {
const st = status || 'done';
await pool.execute(
`UPDATE recordings SET title=CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END,
summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
WHERE id=?`,
[title || null, summary || null, todos ? JSON.stringify(todos) : null,
participants ? JSON.stringify(participants) : null, transcriptClean || null, st,
st, Date.now(), id]);
}
// --- title rename ------------------------------------------------------------
// Empty/blank title unlocks again: the next analysis is then free to propose one.
export async function renameRecording(id, title) {
const t = (title || '').trim().slice(0, 255);
await pool.execute('UPDATE recordings SET title=?, title_locked=? WHERE id=?', [t || null, t ? 1 : 0, id]);
const [rows] = await pool.query('SELECT id,title,title_locked FROM recordings WHERE id=?', [id]);
return rows[0] || null;
}
function parseJsonCols(row) {
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
index.html vor dem ersten Paint, der Header-Toggle schaltet um.
Akzent = warmes Kupfer (Tonband-Anmutung), Rot bleibt der Aufnahme reserviert. */
/* Aime — Anruf-Assistent. Zwei Themes über CSS-Variablen; data-theme setzt
index.html vor dem ersten Paint, der Header-Toggle schaltet um.
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;
--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;
}
/* ==========================================================================
Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine
dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen.
========================================================================== */
.search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; }
/* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */
.search-page.landing { padding-top: 12vh; }
.search-page.has-results { padding-top: 1.25rem; }
* { 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-*) */
* { 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;
.search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.search-hints .example {
border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2);
color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left;
}
::-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; }
.search-hints .example:hover { border-color: var(--accent); color: var(--accent); }
/* ---- 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; }
.searching { padding: 1.5rem 0; }
.search-empty { padding: 1.5rem 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;
/* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */
.answer-box {
margin: 1.25rem 0 .5rem; padding: .9rem 1rem;
background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem;
}
.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; }
.answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; }
.answer-body { line-height: 1.55; white-space: pre-wrap; }
/* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */
.answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; }
.answer-body.skeleton span {
height: .7rem; border-radius: .35rem; background: var(--line); opacity: .55;
animation: pulse 1.4s ease-in-out infinite;
}
.answer-body.skeleton span:nth-child(1) { width: 92%; }
.answer-body.skeleton span:nth-child(2) { width: 78%; animation-delay: .18s; }
.answer-body.skeleton span:nth-child(3) { width: 45%; animation-delay: .36s; }
@keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: .7; } }
.answer-src { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .4rem; }
.src-chip {
border: 1px solid var(--line); background: var(--surface); color: var(--fg);
border-radius: 1rem; font: inherit; font-size: .8rem; padding: .2rem .6rem; cursor: pointer;
}
.src-chip:hover { border-color: var(--accent); color: var(--accent); }
/* 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; } }
.result-meta { margin: .9rem 0 .3rem; }
.results { list-style: none; margin: 0; padding: 0; }
.result { padding: .85rem 0; cursor: pointer; border-bottom: 1px solid var(--line); }
.result:hover .result-title { text-decoration: underline; }
.result-src { color: var(--muted); font-size: .78rem; margin-bottom: .15rem; }
.result-title { display: block; color: var(--accent); font-size: 1.05rem; text-decoration: none; }
.result-at { color: var(--muted); font-size: .8rem; font-weight: 400; margin-left: .4rem; }
.result-snip { color: var(--fg); font-size: .9rem; line-height: 1.5; margin-top: .2rem; }
.result-snip mark { background: var(--warn-soft); color: inherit; font-weight: 600; padding: 0 .1em; border-radius: .15em; }
/* 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); }
/* ==========================================================================
Papierkorb + Umbenennen
========================================================================== */
.list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--fg); border-color: var(--line); }
.btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.trash-hint, .trash-empty { padding: .6rem .75rem; }
.item.rec.trashed { opacity: .8; }
.item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); }
.days-left { color: var(--muted); }
.days-left.urgent { color: var(--danger); font-weight: 600; }
/* Der Stift sitzt neben der H2 des Detailkopfs, ohne die Zeile umzubrechen. */
.rename-btn { vertical-align: middle; margin-left: -.25rem; opacity: .55; }
.rename-btn:hover { opacity: 1; }
.item.rec [data-ren] { opacity: .45; }
.item.rec:hover [data-ren] { opacity: 1; }
/* 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; }
}
@media (max-width: 640px) {
.search-page.landing { padding-top: 6vh; }
.search-go { padding: .55rem .8rem; }
}