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:
@@ -21,6 +21,7 @@ KI-Zusammenfassung (Gemma-3-4B auf der NPU) — **komplett offline**, keine Clou
|
||||
| [06 – iPhone-Bluetooth](docs/06-iphone-bt.md) | HFP-Pfad, Routing-Watcher, Echo-Fix |
|
||||
| [07 – Betrieb](docs/07-betrieb.md) | Services, Zugänge, Backups, Troubleshooting |
|
||||
| [08 – Changelog](docs/08-changelog.md) | Chronik aller Entwicklungsschritte |
|
||||
| [09 – Suche & Verwaltung](docs/09-suche-und-verwaltung.md) | Inhaltssuche mit Zeitstempel-Verweis, Papierkorb, Titel umbenennen |
|
||||
|
||||
## System-Überblick
|
||||
|
||||
|
||||
@@ -55,6 +55,32 @@ export async function ensureSchema() {
|
||||
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 ---------------------------------------------------
|
||||
@@ -209,10 +235,13 @@ export async function mergeSpeakerNames(id, map) {
|
||||
}
|
||||
|
||||
// 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=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
|
||||
`UPDATE recordings SET title=CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END,
|
||||
summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
|
||||
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
|
||||
WHERE id=?`,
|
||||
[title || null, summary || null, todos ? JSON.stringify(todos) : null,
|
||||
@@ -220,6 +249,15 @@ export async function updateAnalysis(id, { title, summary, todos, participants,
|
||||
st, Date.now(), id]);
|
||||
}
|
||||
|
||||
// --- 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') {
|
||||
@@ -230,9 +268,11 @@ function parseJsonCols(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 id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at FROM recordings ORDER BY id DESC');
|
||||
`SELECT ${LIST_COLS} FROM recordings WHERE deleted_at IS NULL ORDER BY id DESC`);
|
||||
return rows.map(parseTodos);
|
||||
}
|
||||
|
||||
@@ -248,7 +288,45 @@ export async function getRecording(id) {
|
||||
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.query('DELETE FROM markers WHERE recording_id=?', [id]).catch(() => {});
|
||||
await pool.query('DELETE FROM recordings WHERE id=?', [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
@@ -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; } }
|
||||
@@ -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
@@ -0,0 +1,420 @@
|
||||
// Content search across all meetings: "which project did X present last week?"
|
||||
//
|
||||
// Retrieval is deliberately split from the answer. The passage hits come from
|
||||
// SQL and are on screen in milliseconds; the written answer is a separate,
|
||||
// slow (~5 tok/s on this NPU) LLM call the GUI loads afterwards. Asking the LLM
|
||||
// first would mean staring at a spinner for a minute before seeing anything.
|
||||
//
|
||||
// Ranking is hybrid:
|
||||
// full-text — MariaDB FULLTEXT over the passage text. Nails proper nouns
|
||||
// ("Nordlicht", "Jeckel") that an embedding blurs away.
|
||||
// semantic — cosine over text embeddings, so a question worded differently
|
||||
// than the meeting still hits. Optional: without the model
|
||||
// (see emb.js) `vec` stays NULL and search runs full-text only.
|
||||
// boosts — speaker match, exact phrase, recency.
|
||||
// Neither alone is enough: full-text misses paraphrases, embeddings miss names.
|
||||
import { pool } from './db.js';
|
||||
import * as emb from './emb.js';
|
||||
import * as llm from './llm.js';
|
||||
|
||||
// A passage is the retrieval unit. Too short and a hit has no context to show
|
||||
// or to answer from; too long and the timestamp stops meaning anything and the
|
||||
// embedding averages several topics into mush. ~2-4 sentences of one speaker.
|
||||
const PASSAGE_CHARS = +(process.env.AIME_PASSAGE_CHARS || 420);
|
||||
const PASSAGE_MAX_CHARS = +(process.env.AIME_PASSAGE_MAX_CHARS || 700);
|
||||
// A speaker change always ends a passage, but a long monologue is still split.
|
||||
const TOPK = +(process.env.AIME_SEARCH_TOPK || 20);
|
||||
const ANSWER_PASSAGES = +(process.env.AIME_ANSWER_PASSAGES || 8);
|
||||
|
||||
// German stop words. Natural-language FULLTEXT would rank on "welches"/"hat"
|
||||
// and drown the two words that carry the question. MariaDB's built-in stopword
|
||||
// list is English only, so the filtering has to happen here.
|
||||
const STOP = new Set(`aber alle allem allen aller alles als also am an ander andere anderem anderen anderer anderes
|
||||
auch auf aus bei beim bin bis bist da damit dann der den des dem die das dass daß derselbe derselben denn dazu dein
|
||||
deine dem den der des dessen deshalb dies diese diesem diesen dieser dieses doch dort du durch ein eine einem einen
|
||||
einer eines einig einige einigem einigen einiger einiges einmal er ihn ihm es etwas euer eure für gegen gewesen hab
|
||||
habe haben hat hatte hatten hier hin hinter ich mich mir ihr ihre ihrem ihren ihrer ihres euch im in indem ins ist
|
||||
jede jedem jeden jeder jedes jene jenem jenen jener jenes jetzt kann kannst können könnt machen man manche manchem
|
||||
manchen mancher manches mein meine mit muss musst müssen nach nicht nichts noch nun nur ob oder ohne sehr sein seine
|
||||
seinem seinen seiner seines selbst sich sie sind so solche solchem solchen solcher solches soll sollte sondern sonst
|
||||
über um und uns unse unsem unsen unser unses unter viel vom von vor während war waren warst weg weil weiter
|
||||
wenn werde werden wieder will wir wird wirst wollen wollte würde würden zu zum zur zwar zwischen mal schon ja nein
|
||||
okay halt eben eigentlich quasi irgendwie
|
||||
was wer wen wem wessen wann wo wohin woher wie warum wieso weshalb welche welchem welchen welcher welches
|
||||
wurde wurden worden gibt gab geht ging steht stand macht gemacht gesagt sagt sagte bitte danke`.split(/\s+/));
|
||||
|
||||
// Query words worth searching for.
|
||||
//
|
||||
// Capitalisation is NOT a licence to keep a stop word: German capitalises every
|
||||
// noun, and a question starts with a capitalised "Welches"/"Was"/"Wer". Keeping
|
||||
// those was what made "Welches Projekt hat X letzte Woche vorgestellt" search
|
||||
// for "Welches" and "Woche" and rank the wrong passages to the top.
|
||||
function contentTerms(q) {
|
||||
const raw = (q || '').split(/[^\p{L}\p{N}äöüßÄÖÜ-]+/u).filter(Boolean);
|
||||
const out = [];
|
||||
for (const w of raw) {
|
||||
if (w.length < 3) continue; // below innodb_ft_min_token_size
|
||||
if (STOP.has(w.toLowerCase())) continue;
|
||||
out.push(w);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
// --- relative dates ----------------------------------------------------------
|
||||
// "letzte Woche" is answered deterministically, not by the LLM: a wrong week is
|
||||
// worse than no filter at all, and date arithmetic is exactly what a 4B model
|
||||
// gets subtly wrong. Weeks are Mon-Sun (ISO), matching how people say it here.
|
||||
function startOfDay(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }
|
||||
function mondayOf(d) {
|
||||
const x = startOfDay(d);
|
||||
x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); // getDay(): 0=Sun
|
||||
return x;
|
||||
}
|
||||
const MONTHS = ['januar', 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember'];
|
||||
|
||||
// Returns {from, to, label, match} — `match` is the text that expressed the date
|
||||
// so search() can strip it before extracting search terms. Leaving "letzte
|
||||
// Woche" in the terms makes every passage containing "Woche" a candidate.
|
||||
export function parseDateRange(q, now = new Date()) {
|
||||
const s = (q || '').toLowerCase();
|
||||
const day = 86400000;
|
||||
const rules = [
|
||||
[/\bvorgestern\b/, () => [new Date(+startOfDay(now) - 2 * day), new Date(+startOfDay(now) - day), 'vorgestern']],
|
||||
[/\bgestern\b/, () => [new Date(+startOfDay(now) - day), startOfDay(now), 'gestern']],
|
||||
[/\bheute\b/, () => [startOfDay(now), new Date(+startOfDay(now) + day), 'heute']],
|
||||
[/\b(diese|dieser)\s+woche\b/, () => [mondayOf(now), new Date(+mondayOf(now) + 7 * day), 'diese Woche']],
|
||||
[/\b(letzte|letzter|letzten|vergangene|vergangener|vergangenen|vorige|voriger|vorigen)\s+woche\b/, () => {
|
||||
const from = new Date(+mondayOf(now) - 7 * day);
|
||||
return [from, new Date(+from + 7 * day), 'letzte Woche'];
|
||||
}],
|
||||
[/\b(diesen|diesem)\s+monat\b/, () =>
|
||||
[new Date(now.getFullYear(), now.getMonth(), 1), new Date(now.getFullYear(), now.getMonth() + 1, 1), 'diesen Monat']],
|
||||
[/\b(letzten|letztem|vergangenen|vorigen)\s+monat\b/, () =>
|
||||
[new Date(now.getFullYear(), now.getMonth() - 1, 1), new Date(now.getFullYear(), now.getMonth(), 1), 'letzten Monat']],
|
||||
[new RegExp(`\\b(im\\s+)?(${MONTHS.join('|')})\\b`), (m) => {
|
||||
const mi = MONTHS.indexOf(m[2]);
|
||||
// A month still ahead of us this year must mean last year's.
|
||||
const year = mi > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear();
|
||||
return [new Date(year, mi, 1), new Date(year, mi + 1, 1), MONTHS[mi][0].toUpperCase() + MONTHS[mi].slice(1)];
|
||||
}],
|
||||
];
|
||||
for (const [re, fn] of rules) {
|
||||
const m = re.exec(s);
|
||||
if (!m) continue;
|
||||
const [from, to, label] = fn(m);
|
||||
return { from, to, label, match: m[0] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- indexing ----------------------------------------------------------------
|
||||
// Passages are cut from the `words` rows, not from transcript_clean: only the
|
||||
// words carry per-word timestamps, and the timestamp is the whole point of a
|
||||
// hit. A passage never spans two speakers.
|
||||
export function buildPassages(words, speakerNames = {}) {
|
||||
const out = [];
|
||||
let cur = null;
|
||||
const flush = () => {
|
||||
if (cur && cur.text.trim().length >= 25) { // drop "Ja." / "Mhm." fragments
|
||||
cur.text = cur.text.trim();
|
||||
out.push(cur);
|
||||
}
|
||||
cur = null;
|
||||
};
|
||||
for (const w of words) {
|
||||
const sp = w.speaker || null;
|
||||
if (cur && cur.speaker !== sp) flush();
|
||||
if (!cur) cur = { speaker: sp, speaker_name: speakerNames[sp] || null, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms, text: '' };
|
||||
cur.text += (cur.text ? ' ' : '') + w.word;
|
||||
cur.t_end_ms = w.t_end_ms;
|
||||
// Prefer to break on a sentence end once the target length is reached;
|
||||
// force a break at the hard cap so one monologue is not a single passage.
|
||||
const endsSentence = /[.!?…]["»)]?$/.test(w.word);
|
||||
if ((cur.text.length >= PASSAGE_CHARS && endsSentence) || cur.text.length >= PASSAGE_MAX_CHARS) flush();
|
||||
}
|
||||
flush();
|
||||
return out.map((p, i) => ({ ...p, idx: i }));
|
||||
}
|
||||
|
||||
// Rebuild one recording's passages. Called after transcription/diarization and
|
||||
// after a speaker rename (the speaker name is denormalised into the row so a
|
||||
// search can filter by person without joining back).
|
||||
export async function indexRecording(recId) {
|
||||
const [recRows] = await pool.query('SELECT speaker_names, title, summary FROM recordings WHERE id=?', [recId]);
|
||||
if (!recRows.length) return 0;
|
||||
let names = {};
|
||||
try { names = JSON.parse(recRows[0].speaker_names || '{}') || {}; } catch {}
|
||||
await indexDoc(recId, recRows[0]);
|
||||
const [words] = await pool.query(
|
||||
'SELECT word,t_start_ms,t_end_ms,speaker FROM words WHERE recording_id=? ORDER BY idx', [recId]);
|
||||
const passages = buildPassages(words, names);
|
||||
await pool.query('DELETE FROM passages WHERE recording_id=?', [recId]);
|
||||
if (!passages.length) return 0;
|
||||
const vecs = await emb.embedMany(passages.map(p => p.text)).catch(() => null);
|
||||
const rows = passages.map((p, i) => [
|
||||
recId, p.idx, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text,
|
||||
vecs && vecs[i] ? JSON.stringify(vecs[i]) : null]);
|
||||
await pool.query(
|
||||
'INSERT INTO passages (recording_id, idx, speaker, speaker_name, t_start_ms, t_end_ms, text, vec) VALUES ?', [rows]);
|
||||
console.log(`search: #${recId} indexiert — ${passages.length} Passagen${vecs ? ' (mit Embeddings)' : ' (nur Volltext)'}`);
|
||||
return passages.length;
|
||||
}
|
||||
|
||||
// Document-level embedding over title + summary.
|
||||
//
|
||||
// Measured on this corpus: passage-level cosine over raw ASR text separates
|
||||
// badly — for "wie werden unsere Daten gesichert" the whole candidate field sat
|
||||
// between 0.809 and 0.856, and an off-topic passage outranked the two passages
|
||||
// that literally describe the backup job. The transcript is conversational and
|
||||
// the ASR breaks compounds ("Backup -Job"), which is exactly what an embedding
|
||||
// handles worst. Title and summary are clean LLM prose about the MEETING'S
|
||||
// topic, so a second similarity on that level says "this recording is about
|
||||
// your question" even when no single passage embeds well. Cheap: one vector per
|
||||
// recording, 22 of them.
|
||||
async function indexDoc(recId, row) {
|
||||
const doc = [row.title || '', row.summary || ''].filter(Boolean).join('. ').slice(0, 4000);
|
||||
if (!doc.trim()) return;
|
||||
const v = await emb.embedMany([doc]).catch(() => null);
|
||||
await pool.execute('UPDATE recordings SET doc_vec=? WHERE id=?',
|
||||
[v && v[0] ? JSON.stringify(v[0]) : null, recId]);
|
||||
}
|
||||
|
||||
// Refresh only the document vectors (seconds, vs ~9 min for a full passage
|
||||
// re-embed). Used after installing the model or changing titles/summaries.
|
||||
export async function reindexDocs() {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, title, summary FROM recordings WHERE deleted_at IS NULL');
|
||||
let n = 0;
|
||||
for (const r of rows) {
|
||||
try { await indexDoc(r.id, r); n++; } catch (e) { console.error(`search: doc #${r.id} — ${e.message}`); }
|
||||
}
|
||||
console.log(`search: ${n} Dokument-Vektoren aktualisiert`);
|
||||
return n;
|
||||
}
|
||||
|
||||
// Index everything that has words but no passages (first run after the update),
|
||||
// or everything when force=true. Sequential on purpose — this competes with
|
||||
// transcription for the same cores.
|
||||
export async function reindexAll({ force = false, onProgress = null } = {}) {
|
||||
const [rows] = await pool.query(
|
||||
force
|
||||
? 'SELECT id FROM recordings WHERE deleted_at IS NULL ORDER BY id'
|
||||
: `SELECT r.id FROM recordings r
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM words w WHERE w.recording_id=r.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM passages p WHERE p.recording_id=r.id)
|
||||
ORDER BY r.id`);
|
||||
let done = 0;
|
||||
for (const r of rows) {
|
||||
try { await indexRecording(r.id); } catch (e) { console.error(`search: #${r.id} fehlgeschlagen — ${e.message}`); }
|
||||
if (onProgress) onProgress(++done, rows.length);
|
||||
}
|
||||
return { indexed: done, total: rows.length };
|
||||
}
|
||||
|
||||
// --- retrieval ---------------------------------------------------------------
|
||||
const norm = s => (s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
|
||||
function cosine(a, b) {
|
||||
let s = 0, na = 0, nb = 0;
|
||||
for (let i = 0; i < a.length; i++) { s += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
||||
return s / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
|
||||
}
|
||||
|
||||
export async function search(q, { limit = TOPK, recordingId = null } = {}) {
|
||||
const range = parseDateRange(q);
|
||||
// The date phrase is a FILTER, not a search term — strip it before tokenising.
|
||||
// Case-insensitively but WITHOUT lowercasing the rest: the capitalisation of
|
||||
// the remaining words is what identifies names for the speaker boost below.
|
||||
const terms = contentTerms(
|
||||
range ? q.replace(new RegExp(range.match.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'), ' ') : q);
|
||||
const where = ['r.deleted_at IS NULL'];
|
||||
const args = [];
|
||||
if (recordingId) { where.push('p.recording_id=?'); args.push(recordingId); }
|
||||
if (range) { where.push('r.created_at >= ? AND r.created_at < ?'); args.push(range.from, range.to); }
|
||||
|
||||
// "Was lief letzte Woche?" — a date with nothing else to match on. Returning
|
||||
// "no hits" would be misleading (there ARE meetings), so show that week's
|
||||
// recordings from their opening passages instead of an empty page.
|
||||
if (!terms.length && range) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text,
|
||||
r.title, r.created_at, r.duration_ms
|
||||
FROM passages p JOIN recordings r ON r.id=p.recording_id
|
||||
WHERE ${where.join(' AND ')} AND p.idx < 3
|
||||
ORDER BY r.created_at DESC, p.idx ASC LIMIT ?`, [...args, limit]);
|
||||
return {
|
||||
hits: rows.map(r => ({ ...r, score: 0, ft: 0, vec: 0 })),
|
||||
terms, range: { label: range.label, from: range.from, to: range.to },
|
||||
semantic: false, total: rows.length, dateOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Full-text candidates. BOOLEAN MODE with OR'd terms: a passage matching only
|
||||
// "Jeckel" must still be a candidate, the ranking sorts it out afterwards.
|
||||
let ftRows = [];
|
||||
if (terms.length) {
|
||||
const clean = terms.map(t => t.replace(/[+\-><()~*"@]/g, ''));
|
||||
const boolExpr = clean.map(t => `${t}*`).join(' '); // candidate selection (prefix, OR)
|
||||
// Scored in NATURAL LANGUAGE mode, which is IDF-weighted; BOOLEAN MODE only
|
||||
// counts matches, so a passage repeating a common word like "Daten" beat the
|
||||
// passage that actually contained the rare, meaning-carrying term.
|
||||
const nlExpr = clean.join(' ');
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text, p.vec,
|
||||
r.title, r.created_at, r.duration_ms, r.doc_vec,
|
||||
MATCH(p.text) AGAINST(?) AS ft
|
||||
FROM passages p JOIN recordings r ON r.id=p.recording_id
|
||||
WHERE ${where.join(' AND ')} AND MATCH(p.text) AGAINST(? IN BOOLEAN MODE)
|
||||
ORDER BY ft DESC LIMIT 200`,
|
||||
[nlExpr, ...args, boolExpr]);
|
||||
ftRows = rows;
|
||||
}
|
||||
|
||||
// Semantic candidates. Only worth loading when the model is there AND some
|
||||
// passage actually carries a vector.
|
||||
let vecRows = [];
|
||||
let qvec = null;
|
||||
if (emb.available()) {
|
||||
qvec = await emb.embedQuery(q).catch(() => null);
|
||||
if (qvec) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id, p.recording_id, p.speaker, p.speaker_name, p.t_start_ms, p.t_end_ms, p.text, p.vec,
|
||||
r.title, r.created_at, r.duration_ms, r.doc_vec
|
||||
FROM passages p JOIN recordings r ON r.id=p.recording_id
|
||||
WHERE ${where.join(' AND ')} AND p.vec IS NOT NULL`, args);
|
||||
vecRows = rows;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge both candidate sets, then score every candidate on both axes.
|
||||
const byId = new Map();
|
||||
for (const r of [...ftRows, ...vecRows]) if (!byId.has(r.id)) byId.set(r.id, r);
|
||||
const ftMax = Math.max(1e-9, ...ftRows.map(r => r.ft || 0));
|
||||
const ftById = new Map(ftRows.map(r => [r.id, (r.ft || 0) / ftMax]));
|
||||
|
||||
const qn = norm(q);
|
||||
const nameTerms = terms.filter(t => t[0] === t[0].toUpperCase());
|
||||
const now = Date.now();
|
||||
|
||||
// Raw e5 cosines sit in a narrow band (measured here: 0.82-0.86 across ALL
|
||||
// passages, relevant or not) because the model puts everything in the same
|
||||
// cone. Feeding that raw into the score makes the semantic axis worth ~0.01
|
||||
// while full-text is worth up to 0.55 — the ranking then ignores semantics
|
||||
// entirely except when full-text finds nothing. So spread the band: rank by
|
||||
// how far above the field a passage is, not by its absolute cosine.
|
||||
const cand = [...byId.values()];
|
||||
const cosById = new Map();
|
||||
if (qvec) {
|
||||
for (const r of cand) {
|
||||
if (!r.vec) continue;
|
||||
try { cosById.set(r.id, cosine(qvec, JSON.parse(r.vec))); } catch {}
|
||||
}
|
||||
}
|
||||
const cosVals = [...cosById.values()];
|
||||
const cosMean = cosVals.length ? cosVals.reduce((a, b) => a + b, 0) / cosVals.length : 0;
|
||||
const cosSd = cosVals.length
|
||||
? Math.sqrt(cosVals.reduce((a, b) => a + (b - cosMean) ** 2, 0) / cosVals.length) : 0;
|
||||
const cosMin = cosVals.length ? Math.min(...cosVals) : 0;
|
||||
const cosMax = cosVals.length ? Math.max(...cosVals) : 0;
|
||||
// Min-max, not standard deviations: with a 0.047-wide band an sd-based squash
|
||||
// saturates at 1.0 for dozens of passages and throws away the ordering
|
||||
// exactly where it matters — at the top.
|
||||
const vecNorm = id => {
|
||||
if (!cosById.has(id) || cosMax - cosMin < 1e-9) return 0;
|
||||
return (cosById.get(id) - cosMin) / (cosMax - cosMin);
|
||||
};
|
||||
|
||||
// Document level: how close is the RECORDING (title + summary) to the
|
||||
// question. One value per recording, normalised over the recordings actually
|
||||
// in play.
|
||||
const docCos = new Map();
|
||||
if (qvec) {
|
||||
for (const r of cand) {
|
||||
if (docCos.has(r.recording_id) || !r.doc_vec) continue;
|
||||
try { docCos.set(r.recording_id, cosine(qvec, JSON.parse(r.doc_vec))); } catch {}
|
||||
}
|
||||
}
|
||||
const dVals = [...docCos.values()];
|
||||
const dMin = dVals.length ? Math.min(...dVals) : 0;
|
||||
const dMax = dVals.length ? Math.max(...dVals) : 0;
|
||||
const docNorm = recId => {
|
||||
if (!docCos.has(recId) || dMax - dMin < 1e-9) return 0;
|
||||
return (docCos.get(recId) - dMin) / (dMax - dMin);
|
||||
};
|
||||
|
||||
// Relevance floor for semantic-only candidates, still in sd terms (a scale
|
||||
// that means "stands out from the field" rather than "is the best of a bad
|
||||
// field", which is what min-max would say).
|
||||
const semFloor = cosMean + cosSd;
|
||||
|
||||
const hits = [];
|
||||
for (const r of cand) {
|
||||
const ft = ftById.get(r.id) || 0;
|
||||
const vec = vecNorm(r.id);
|
||||
const doc = docNorm(r.recording_id);
|
||||
if (!ft && (cosById.get(r.id) || 0) < semFloor) continue;
|
||||
// Speaker boost: the person asked about is usually the one TALKING, and
|
||||
// their own words never contain their name — without this the passage that
|
||||
// actually answers the question ranks below every passage mentioning them.
|
||||
const spk = nameTerms.some(t => norm(r.speaker_name).includes(norm(t))) ? 0.25 : 0;
|
||||
const phrase = qn.length > 8 && norm(r.text).includes(qn) ? 0.35 : 0;
|
||||
// Mild recency tilt — "last week" questions are the common case. Half a
|
||||
// point of decay per year; never enough to beat a clearly better match.
|
||||
const ageDays = (now - new Date(r.created_at).getTime()) / 86400000;
|
||||
const recency = 0.1 / (1 + ageDays / 180);
|
||||
// Passage semantics get less weight than the document level because the
|
||||
// measurement above showed them to be the noisier of the two signals.
|
||||
const score = 0.45 * ft + 0.25 * vec + 0.30 * doc + spk + phrase + recency;
|
||||
hits.push({
|
||||
id: r.id, recording_id: r.recording_id, title: r.title, created_at: r.created_at,
|
||||
duration_ms: r.duration_ms, speaker: r.speaker, speaker_name: r.speaker_name,
|
||||
t_start_ms: r.t_start_ms, t_end_ms: r.t_end_ms, text: r.text,
|
||||
score, ft, vec: +vec.toFixed(4), doc: +doc.toFixed(4),
|
||||
cos: +(cosById.get(r.id) || 0).toFixed(4),
|
||||
});
|
||||
}
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
return {
|
||||
hits: hits.slice(0, limit),
|
||||
terms,
|
||||
range: range ? { label: range.label, from: range.from, to: range.to } : null,
|
||||
semantic: !!qvec,
|
||||
total: hits.length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- answer ------------------------------------------------------------------
|
||||
const ts = ms => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
||||
const dateDe = d => new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
|
||||
// Writes the prose answer over the top hits. Every passage is numbered and the
|
||||
// model is told to cite those numbers, so each claim can be traced back to a
|
||||
// recording and a minute — an uncited sentence is the tell for a hallucination.
|
||||
export async function answer(q, hits) {
|
||||
const use = hits.slice(0, ANSWER_PASSAGES);
|
||||
if (!use.length) return { answer: 'Dazu finde ich in den Aufnahmen nichts.', sources: [] };
|
||||
const ctx = use.map((h, i) =>
|
||||
`[${i + 1}] Meeting "${h.title || 'Aufnahme #' + h.recording_id}" (#${h.recording_id}) vom ${dateDe(h.created_at)}, `
|
||||
+ `${h.speaker_name || h.speaker || 'unbekannt'} bei ${ts(h.t_start_ms)}:\n${h.text}`).join('\n\n');
|
||||
const prompt = `Du beantwortest eine Frage AUSSCHLIESSLICH aus den folgenden Ausschnitten aus Meeting-Mitschriften.
|
||||
|
||||
${ctx}
|
||||
|
||||
Frage: ${q}
|
||||
|
||||
Regeln:
|
||||
- Antworte kurz und konkret auf Deutsch, in höchstens 4 Sätzen.
|
||||
- Belege JEDE Aussage mit der Nummer des Ausschnitts, z.B. [2].
|
||||
- Steht die Antwort nicht in den Ausschnitten, schreibe genau: "Dazu finde ich in den Aufnahmen nichts."
|
||||
- Rate nicht und ergänze nichts aus eigenem Wissen.
|
||||
|
||||
Antwort:`;
|
||||
const text = (await llm.askRaw(prompt) || '').trim();
|
||||
// Map the [n] the model used back to real recordings + timestamps.
|
||||
const cited = new Set((text.match(/\[(\d+)\]/g) || []).map(m => +m.slice(1, -1)));
|
||||
const sources = use
|
||||
.map((h, i) => ({ n: i + 1, ...h }))
|
||||
.filter(h => cited.size === 0 || cited.has(h.n));
|
||||
return { answer: text, sources };
|
||||
}
|
||||
+99
-2
@@ -10,6 +10,8 @@ import * as db from './db.js';
|
||||
import * as llm from './llm.js';
|
||||
import * as diar from './diar.js';
|
||||
import * as spk from './spk.js';
|
||||
import * as search from './search.js';
|
||||
import * as emb from './emb.js';
|
||||
const pexec = promisify(execFile);
|
||||
|
||||
// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH
|
||||
@@ -315,6 +317,9 @@ async function analyzeRecording(id) {
|
||||
// still carries the label from analysis start ("Sprecher 1"), so patch it
|
||||
// once at the end — same mechanism as renaming a speaker by hand.
|
||||
await relabelAnalysis(id).catch(() => {});
|
||||
// Rebuild the search index last: it needs the final speaker names, which
|
||||
// relabelAnalysis may only just have settled.
|
||||
await search.indexRecording(id).catch(e => console.error('search index:', e.message));
|
||||
console.log(`analysis done for #${id}: ${a.title}`);
|
||||
} catch (e) {
|
||||
console.error(`analysis failed for #${id}:`, e.message);
|
||||
@@ -428,7 +433,85 @@ app.get('/api/recordings/:id', wrap(async (req, res) => {
|
||||
const r = await db.getRecording(+req.params.id);
|
||||
r ? res.json(r) : res.status(404).json({ error: 'not found' });
|
||||
}));
|
||||
app.delete('/api/recordings/:id', wrap(async (req, res) => { await db.deleteRecording(+req.params.id); res.json({ ok: true }); }));
|
||||
// Rename. An empty title clears it AND unlocks, so the next analysis may
|
||||
// propose one again (see db.renameRecording / updateAnalysis).
|
||||
app.patch('/api/recordings/:id/title', wrap(async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
const row = await db.renameRecording(id, String((req.body && req.body.title) || ''));
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
res.json({ ok: true, id, title: row.title, title_locked: !!row.title_locked });
|
||||
}));
|
||||
|
||||
// --- Trash -------------------------------------------------------------------
|
||||
// Delete = move to the bin. Nothing on disk is touched until the retention
|
||||
// window expires, so a restore brings back audio, transcript and analysis.
|
||||
app.delete('/api/recordings/:id', wrap(async (req, res) => {
|
||||
await db.deleteRecording(+req.params.id);
|
||||
res.json({ ok: true, trashed: true, days: db.TRASH_DAYS });
|
||||
}));
|
||||
app.get('/api/trash', wrap(async (req, res) => res.json({ days: db.TRASH_DAYS, items: await db.listTrash() })));
|
||||
app.post('/api/trash/:id/restore', wrap(async (req, res) => {
|
||||
await db.restoreRecording(+req.params.id);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
// Irreversible: drops the row, its words/markers/passages and the audio files.
|
||||
app.delete('/api/trash/:id', wrap(async (req, res) => {
|
||||
res.json({ ok: true, purged: await purgeRecording(+req.params.id) });
|
||||
}));
|
||||
|
||||
// Shared by the manual purge route and the retention sweep.
|
||||
async function purgeRecording(id) {
|
||||
const files = await db.purgeRecording(id);
|
||||
let removed = 0;
|
||||
for (const f of files) {
|
||||
try { fs.unlinkSync(path.join(REC_DIR, f)); removed++; }
|
||||
catch (e) { if (e.code !== 'ENOENT') console.error(`purge #${id}: ${f} — ${e.message}`); }
|
||||
}
|
||||
return { id, files: removed };
|
||||
}
|
||||
|
||||
// Retention sweep: once at boot (the box is often off for days, so a timer
|
||||
// alone would keep missing the window) and then daily.
|
||||
async function sweepTrash() {
|
||||
const ids = await db.expiredTrashIds().catch(() => []);
|
||||
for (const id of ids) {
|
||||
try { await purgeRecording(id); console.log(`trash: #${id} nach ${db.TRASH_DAYS} Tagen endgültig gelöscht`); }
|
||||
catch (e) { console.error(`trash sweep #${id}:`, e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search ------------------------------------------------------------------
|
||||
// Hits are pure SQL and come back immediately; the written answer is a separate
|
||||
// call because the LLM needs ~30-90 s on this hardware (see search.js).
|
||||
app.get('/api/search', wrap(async (req, res) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q.length < 2) return res.json({ hits: [], terms: [], range: null, semantic: false, total: 0 });
|
||||
res.json(await search.search(q, { limit: Math.min(+req.query.limit || 20, 50) }));
|
||||
}));
|
||||
// Runs through the NPU queue: rkllm and the whisper encoder cannot share the NPU.
|
||||
app.post('/api/search/answer', wrap(async (req, res) => {
|
||||
const q = String((req.body && req.body.q) || '').trim();
|
||||
if (!q) return res.status(400).json({ error: 'q fehlt' });
|
||||
const { hits } = await search.search(q, { limit: 20 });
|
||||
res.json(await withNpuLock('search-answer', () => search.answer(q, hits)));
|
||||
}));
|
||||
app.get('/api/search/status', wrap(async (req, res) => {
|
||||
const [[{ n }]] = await db.pool.query('SELECT COUNT(*) AS n FROM passages');
|
||||
const [[{ v }]] = await db.pool.query('SELECT COUNT(*) AS v FROM passages WHERE vec IS NOT NULL');
|
||||
res.json({ passages: n, embedded: v, ...emb.status(), indexing: !!indexJob });
|
||||
}));
|
||||
// Background reindex; `force` rebuilds everything, otherwise only recordings
|
||||
// that have no passages yet (the normal case after this update).
|
||||
let indexJob = null;
|
||||
app.post('/api/search/reindex', wrap(async (req, res) => {
|
||||
if (indexJob) return res.json({ ok: true, already: true });
|
||||
const force = !!(req.body && req.body.force);
|
||||
indexJob = search.reindexAll({ force, onProgress: (d, t) => console.log(`search: reindex ${d}/${t}`) })
|
||||
.then(r => { console.log(`search: reindex fertig — ${r.indexed}/${r.total}`); return r; })
|
||||
.catch(e => { console.error('search: reindex:', e.message); })
|
||||
.finally(() => { indexJob = null; });
|
||||
res.json({ ok: true, started: true });
|
||||
}));
|
||||
|
||||
// --- Voiceprints / enrollment ---
|
||||
app.get('/api/voiceprints', wrap(async (req, res) => {
|
||||
@@ -623,6 +706,9 @@ app.post('/api/recordings/:id/speaker', wrap(async (req, res) => {
|
||||
}
|
||||
}
|
||||
const r2 = await db.getRecording(id);
|
||||
// The passage rows carry the speaker NAME denormalised (so search can filter
|
||||
// by person without a join) — a rename has to be written through.
|
||||
search.indexRecording(id).catch(e => console.error('search index:', e.message));
|
||||
res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg,
|
||||
relabeled: relabel.changed });
|
||||
}));
|
||||
@@ -632,4 +718,15 @@ app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname,
|
||||
const PORT = process.env.PORT || 3000;
|
||||
db.ensureSchema()
|
||||
.catch(e => console.error('ensureSchema:', e.message))
|
||||
.finally(() => app.listen(PORT, '0.0.0.0', () => console.log(`callrec listening on :${PORT}`)));
|
||||
.finally(() => app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`callrec listening on :${PORT}`);
|
||||
// Retention sweep at boot + daily. unref() so it never holds the process open.
|
||||
sweepTrash().catch(e => console.error('trash sweep:', e.message));
|
||||
setInterval(() => sweepTrash().catch(e => console.error('trash sweep:', e.message)), 24 * 3600 * 1000).unref();
|
||||
// Backfill the search index for everything recorded before this update.
|
||||
// Deliberately not under the NPU lock: it is CPU/SQL only and must not
|
||||
// delay a transcription that is already queued.
|
||||
search.reindexAll({ onProgress: (d, t) => { if (d === 1 || d === t) console.log(`search: backfill ${d}/${t}`); } })
|
||||
.then(r => { if (r.total) console.log(`search: backfill fertig — ${r.indexed}/${r.total} Aufnahmen`); })
|
||||
.catch(e => console.error('search backfill:', e.message));
|
||||
}));
|
||||
|
||||
+260
-10
@@ -4,11 +4,15 @@ const api = {
|
||||
async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||||
async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||||
async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); },
|
||||
async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }
|
||||
async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||||
async patch(u, b) { const r = await fetch(u, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }
|
||||
};
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '' };
|
||||
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '',
|
||||
// search: `q` is what was actually submitted (not what is being typed), so a
|
||||
// tab switch and back restores the result page instead of an empty form.
|
||||
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false };
|
||||
|
||||
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
|
||||
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
|
||||
@@ -39,8 +43,8 @@ function render() {
|
||||
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
|
||||
</div>
|
||||
<nav>
|
||||
${['recordings', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
${['recordings', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
</nav>
|
||||
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
|
||||
</header>
|
||||
@@ -51,7 +55,7 @@ function render() {
|
||||
app.querySelector('#recbtn').onclick = toggleRecord;
|
||||
bindMarkerInput();
|
||||
if (state.recording) startTimerUI();
|
||||
({ bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab]();
|
||||
({ bluetooth: viewBluetooth, recordings: viewRecordings, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
|
||||
}
|
||||
|
||||
// Header record button: start = defaults only (default source = CallRecMix,
|
||||
@@ -289,6 +293,171 @@ async function viewGlossary() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Search ----------
|
||||
// Two-stage on purpose (see search.js): the hit list is SQL and lands in
|
||||
// milliseconds, the written answer is a ~30-90 s LLM call that streams in
|
||||
// afterwards. Never make the user wait on the slow half to see the fast half.
|
||||
const SEARCH_EXAMPLES = [
|
||||
'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
|
||||
'Was wurde über die Preiserhöhung besprochen?',
|
||||
'Welche Aufgaben hat Markus im Juli übernommen?',
|
||||
];
|
||||
|
||||
function viewSearch() {
|
||||
const v = document.querySelector('#view');
|
||||
const hasResults = !!state.searchRes;
|
||||
v.innerHTML = `
|
||||
<div class="search-page ${hasResults ? 'has-results' : 'landing'}">
|
||||
<form class="search-box" id="searchform" autocomplete="off">
|
||||
<span class="search-ico">🔍</span>
|
||||
<input id="searchq" class="search-input" type="text" placeholder="Frage zu deinen Meetings…"
|
||||
value="${escapeHtml(state.searchQ)}" />
|
||||
${state.searchQ ? '<button type="button" class="search-clear" id="searchclear" title="Leeren">✕</button>' : ''}
|
||||
<button type="submit" class="search-go">Suchen</button>
|
||||
</form>
|
||||
${hasResults ? '' : `
|
||||
<div class="search-hints">
|
||||
<div class="muted small">Durchsucht alle Mitschriften. Beispiele:</div>
|
||||
${SEARCH_EXAMPLES.map(e => `<button class="example" data-ex="${escapeHtml(e)}">${escapeHtml(e)}</button>`).join('')}
|
||||
</div>`}
|
||||
<div id="searchout"></div>
|
||||
</div>`;
|
||||
|
||||
const input = v.querySelector('#searchq');
|
||||
v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); };
|
||||
const clr = v.querySelector('#searchclear');
|
||||
if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); };
|
||||
v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); });
|
||||
if (!hasResults) input.focus();
|
||||
if (hasResults) renderSearchResults();
|
||||
}
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!q || q.length < 2) return;
|
||||
state.searchQ = q;
|
||||
state.searchAnswer = null;
|
||||
state.searchBusy = true;
|
||||
const out = document.querySelector('#searchout');
|
||||
if (out) out.innerHTML = '<div class="muted searching">Suche…</div>';
|
||||
document.querySelector('.search-page').classList.add('has-results');
|
||||
try {
|
||||
state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q));
|
||||
} catch (e) {
|
||||
state.searchRes = null;
|
||||
if (out) out.innerHTML = '<div class="err">' + escapeHtml(e.message) + '</div>';
|
||||
state.searchBusy = false;
|
||||
return;
|
||||
}
|
||||
state.searchBusy = false;
|
||||
renderSearchResults();
|
||||
// Only bother the NPU when there is something to summarise.
|
||||
if (state.searchRes.hits.length) askSearchAnswer(q);
|
||||
}
|
||||
|
||||
// The written answer. Fired after the hits are already on screen; the box shows
|
||||
// a live elapsed counter because on this hardware it genuinely takes a while.
|
||||
async function askSearchAnswer(q) {
|
||||
const box = document.querySelector('#answerbox');
|
||||
if (!box) return;
|
||||
const t0 = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
const el = box.querySelector('.answer-elapsed');
|
||||
if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
|
||||
}, 1000);
|
||||
try {
|
||||
const a = await api.post('/api/search/answer', { q });
|
||||
// A late answer to a query the user has already replaced must not overwrite
|
||||
// the newer result.
|
||||
if (state.searchQ !== q) return;
|
||||
state.searchAnswer = a;
|
||||
renderAnswer();
|
||||
} catch (e) {
|
||||
if (state.searchQ !== q) return;
|
||||
box.innerHTML = `<div class="answer-head">Antwort</div><div class="err">${escapeHtml(e.message)}</div>`;
|
||||
} finally { clearInterval(tick); }
|
||||
}
|
||||
|
||||
// Highlight the query's content words inside a snippet, the way a search engine
|
||||
// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
|
||||
function highlight(text, terms) {
|
||||
let html = escapeHtml(text);
|
||||
for (const t of (terms || [])) {
|
||||
if (t.length < 3) continue;
|
||||
const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
html = html.replace(new RegExp(`(${safe})`, 'gi'), '<mark>$1</mark>');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSearchResults() {
|
||||
const out = document.querySelector('#searchout');
|
||||
if (!out || !state.searchRes) return;
|
||||
const { hits, terms, range, semantic, total } = state.searchRes;
|
||||
if (!hits.length) {
|
||||
out.innerHTML = `<div class="search-empty">
|
||||
<b>Keine Treffer für „${escapeHtml(state.searchQ)}".</b>
|
||||
<div class="muted small">${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
|
||||
Andere Formulierung oder weniger Wörter versuchen.</div></div>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `
|
||||
<div id="answerbox" class="answer-box">
|
||||
<div class="answer-head">Antwort <span class="muted small answer-elapsed">…</span></div>
|
||||
<div class="answer-body skeleton"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="result-meta muted small">
|
||||
${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''}
|
||||
· ${semantic ? 'Volltext + semantisch' : 'nur Volltext'}
|
||||
</div>
|
||||
<ol class="results">
|
||||
${hits.map(h => `
|
||||
<li class="result" data-rec="${h.recording_id}" data-t="${h.t_start_ms}">
|
||||
<div class="result-src">${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))}
|
||||
· Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}</div>
|
||||
<a class="result-title" href="#">${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id}
|
||||
<span class="result-at">bei ${fmt(h.t_start_ms)}</span></a>
|
||||
<div class="result-snip">${highlight(h.text, terms)}</div>
|
||||
</li>`).join('')}
|
||||
</ol>`;
|
||||
out.querySelectorAll('.result').forEach(li => li.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
openFromSearch(+li.dataset.rec, +li.dataset.t);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAnswer() {
|
||||
const box = document.querySelector('#answerbox');
|
||||
if (!box || !state.searchAnswer) return;
|
||||
const { answer, sources } = state.searchAnswer;
|
||||
box.innerHTML = `
|
||||
<div class="answer-head">Antwort</div>
|
||||
<div class="answer-body">${escapeHtml(answer)}</div>
|
||||
${sources && sources.length ? `<div class="answer-src">
|
||||
${sources.map(s => `<button class="src-chip" data-rec="${s.recording_id}" data-t="${s.t_start_ms}">
|
||||
[${s.n}] ${escapeHtml(s.title) || '#' + s.recording_id} · ${fmt(s.t_start_ms)}</button>`).join('')}
|
||||
</div>` : ''}`;
|
||||
box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t));
|
||||
}
|
||||
|
||||
// Jump from a hit into the recording, positioned at the spoken minute. The
|
||||
// detail pane lives in the recordings tab, so switch there first and wait for
|
||||
// the player element to exist before seeking.
|
||||
async function openFromSearch(recId, tMs) {
|
||||
state.tab = 'recordings';
|
||||
state.selectedId = recId;
|
||||
state.trashOpen = false;
|
||||
render();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const p = document.querySelector('#player');
|
||||
if (p) {
|
||||
const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} };
|
||||
if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true });
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Recordings ----------
|
||||
// Analysis-duration counter. Shows the total wall time of a processing run
|
||||
// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
|
||||
@@ -322,10 +491,22 @@ function viewRecordings() {
|
||||
state.resetCollapse = true;
|
||||
v.innerHTML = `
|
||||
<div class="rec-layout">
|
||||
<aside class="rec-list-pane"><div id="reclist" class="list">Lade…</div></aside>
|
||||
<aside class="rec-list-pane">
|
||||
<div class="list-bar">
|
||||
<button id="trashtoggle" class="btn small ghost ${state.trashOpen ? 'active' : ''}"
|
||||
title="Gelöschte Aufnahmen — bleiben 30 Tage wiederherstellbar">🗑 Papierkorb</button>
|
||||
</div>
|
||||
<div id="reclist" class="list">Lade…</div>
|
||||
</aside>
|
||||
<div class="rec-splitter" title="Ziehen: Listenbreite ändern"></div>
|
||||
<section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section>
|
||||
</div>`;
|
||||
v.querySelector('#trashtoggle').onclick = () => {
|
||||
state.trashOpen = !state.trashOpen;
|
||||
state.lastListSig = null;
|
||||
v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
|
||||
refreshRecList();
|
||||
};
|
||||
// Draggable splitter: list width between LIST_MIN (the old fixed width) and
|
||||
// twice that; persisted for all sessions.
|
||||
const LIST_MIN = 340, LIST_MAX = 680;
|
||||
@@ -355,6 +536,7 @@ function viewRecordings() {
|
||||
async function refreshRecList() {
|
||||
const listEl = document.querySelector('#reclist');
|
||||
if (!listEl) return; // left the tab
|
||||
if (state.trashOpen) return refreshTrashList(listEl);
|
||||
let recs;
|
||||
try { recs = await api.get('/api/recordings'); }
|
||||
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||||
@@ -404,7 +586,8 @@ async function refreshRecList() {
|
||||
<div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div>
|
||||
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn danger icon" data-del="${r.id}" title="Löschen">🗑</button>
|
||||
<button class="btn icon" data-ren="${r.id}" title="Titel umbenennen">✏️</button>
|
||||
<button class="btn danger icon" data-del="${r.id}" title="In den Papierkorb">🗑</button>
|
||||
</div>`; }).join('');
|
||||
return `
|
||||
<div class="day-group ${collapsed ? 'collapsed' : ''}">
|
||||
@@ -412,18 +595,24 @@ async function refreshRecList() {
|
||||
<div class="day-items">${items}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// Click a row (not the trash button) → open in the right pane.
|
||||
// Click a row (not one of its buttons) → open in the right pane.
|
||||
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
|
||||
if (e.target.closest('[data-del]')) return;
|
||||
if (e.target.closest('[data-del]') || e.target.closest('[data-ren]')) return;
|
||||
openRecording(+it.dataset.open);
|
||||
});
|
||||
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const rec = recs.find(r => r.id == b.dataset.ren);
|
||||
await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
|
||||
});
|
||||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const rec = recs.find(r => r.id == b.dataset.del);
|
||||
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
|
||||
if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return;
|
||||
if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return;
|
||||
await api.del('/api/recordings/' + b.dataset.del);
|
||||
if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; }
|
||||
toast('In den Papierkorb verschoben');
|
||||
state.lastListSig = null; refreshRecList();
|
||||
});
|
||||
bindMarkerChips();
|
||||
@@ -438,6 +627,64 @@ async function refreshRecList() {
|
||||
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
||||
}
|
||||
|
||||
// Rename a recording's title. An empty answer clears the title and hands
|
||||
// naming back to the LLM on the next analysis (see db.renameRecording).
|
||||
async function renameRecording(id, current) {
|
||||
const t = prompt('Titel der Aufnahme:', current || '');
|
||||
if (t === null) return false; // cancelled
|
||||
try {
|
||||
const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
|
||||
state.lastListSig = null;
|
||||
refreshRecList();
|
||||
// Keep the open detail pane in sync without rebuilding it (audio keeps playing).
|
||||
const h = document.querySelector('#detail h2');
|
||||
if (h && state.selectedId == id) {
|
||||
const badge = h.querySelector('.badge');
|
||||
h.textContent = r.title || 'Aufnahme #' + id;
|
||||
if (badge) h.appendChild(badge);
|
||||
}
|
||||
toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
|
||||
return true;
|
||||
} catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
|
||||
}
|
||||
|
||||
// Trash pane: same list slot, different contents. Deliberately flat (no day
|
||||
// grouping) — the interesting order here is "when did I delete it", and the
|
||||
// remaining days matter more than the recording date.
|
||||
async function refreshTrashList(listEl) {
|
||||
let data;
|
||||
try { data = await api.get('/api/trash'); }
|
||||
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = `<div class="muted trash-empty">Papierkorb ist leer.<br><span class="small">Gelöschte Aufnahmen bleiben ${data.days} Tage hier.</span></div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = `<div class="trash-hint muted small">Wird nach ${data.days} Tagen automatisch endgültig gelöscht.</div>` + items.map(r => `
|
||||
<div class="item rec trashed">
|
||||
<div class="grow">
|
||||
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b>
|
||||
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
|
||||
· <span class="days-left ${r.days_left <= 3 ? 'urgent' : ''}">noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}</span></div>
|
||||
</div>
|
||||
<button class="btn small" data-restore="${r.id}" title="Wiederherstellen">↩︎</button>
|
||||
<button class="btn danger icon" data-purge="${r.id}" title="Endgültig löschen">✕</button>
|
||||
</div>`).join('');
|
||||
listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
|
||||
await api.post(`/api/trash/${b.dataset.restore}/restore`, {});
|
||||
toast('Wiederhergestellt');
|
||||
refreshTrashList(listEl);
|
||||
});
|
||||
listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => {
|
||||
const rec = items.find(r => r.id == b.dataset.purge);
|
||||
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge;
|
||||
if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return;
|
||||
await api.del('/api/trash/' + b.dataset.purge);
|
||||
toast('Endgültig gelöscht');
|
||||
refreshTrashList(listEl);
|
||||
});
|
||||
}
|
||||
|
||||
async function openRecording(id) {
|
||||
state.selectedId = id;
|
||||
const detail = document.querySelector('#detail');
|
||||
@@ -545,6 +792,7 @@ async function openRecording(id) {
|
||||
detail.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
||||
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
|
||||
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
|
||||
${vizHtml}
|
||||
<div class="status-line">
|
||||
@@ -594,6 +842,8 @@ async function openRecording(id) {
|
||||
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
|
||||
markEmpty();
|
||||
}));
|
||||
const renBtn = detail.querySelector('#renametitle');
|
||||
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
|
||||
const reBtn = detail.querySelector('#reanalyze');
|
||||
if (reBtn) reBtn.onclick = async () => {
|
||||
// Re-runs speaker separation every time: empty = automatic detection (now
|
||||
|
||||
@@ -395,3 +395,101 @@ body.resizing { cursor: col-resize; user-select: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine
|
||||
dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen.
|
||||
========================================================================== */
|
||||
.search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; }
|
||||
/* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */
|
||||
.search-page.landing { padding-top: 12vh; }
|
||||
.search-page.has-results { padding-top: 1.25rem; }
|
||||
|
||||
.search-box {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
border: 1px solid var(--line); border-radius: 2rem;
|
||||
background: var(--surface); box-shadow: var(--shadow);
|
||||
padding: .35rem .35rem .35rem 1rem;
|
||||
transition: box-shadow .15s, border-color .15s;
|
||||
}
|
||||
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.search-ico { opacity: .55; font-size: 1rem; }
|
||||
.search-input {
|
||||
flex: 1; border: 0; outline: 0; background: transparent; color: var(--fg);
|
||||
font: inherit; font-size: 1.05rem; padding: .55rem 0;
|
||||
}
|
||||
.search-clear { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: .95rem; padding: .25rem .4rem; }
|
||||
.search-clear:hover { color: var(--fg); }
|
||||
.search-go {
|
||||
border: 0; border-radius: 1.6rem; cursor: pointer;
|
||||
background: var(--accent); color: var(--accent-fg);
|
||||
font: inherit; font-weight: 600; padding: .55rem 1.1rem;
|
||||
}
|
||||
.search-go:hover { filter: brightness(1.07); }
|
||||
|
||||
.search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
|
||||
.search-hints .example {
|
||||
border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2);
|
||||
color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left;
|
||||
}
|
||||
.search-hints .example:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.searching { padding: 1.5rem 0; }
|
||||
.search-empty { padding: 1.5rem 0; }
|
||||
|
||||
/* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */
|
||||
.answer-box {
|
||||
margin: 1.25rem 0 .5rem; padding: .9rem 1rem;
|
||||
background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem;
|
||||
}
|
||||
.answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; }
|
||||
.answer-body { line-height: 1.55; white-space: pre-wrap; }
|
||||
/* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */
|
||||
.answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; }
|
||||
.answer-body.skeleton span {
|
||||
height: .7rem; border-radius: .35rem; background: var(--line); opacity: .55;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.answer-body.skeleton span:nth-child(1) { width: 92%; }
|
||||
.answer-body.skeleton span:nth-child(2) { width: 78%; animation-delay: .18s; }
|
||||
.answer-body.skeleton span:nth-child(3) { width: 45%; animation-delay: .36s; }
|
||||
@keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: .7; } }
|
||||
.answer-src { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
.src-chip {
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--fg);
|
||||
border-radius: 1rem; font: inherit; font-size: .8rem; padding: .2rem .6rem; cursor: pointer;
|
||||
}
|
||||
.src-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.result-meta { margin: .9rem 0 .3rem; }
|
||||
.results { list-style: none; margin: 0; padding: 0; }
|
||||
.result { padding: .85rem 0; cursor: pointer; border-bottom: 1px solid var(--line); }
|
||||
.result:hover .result-title { text-decoration: underline; }
|
||||
.result-src { color: var(--muted); font-size: .78rem; margin-bottom: .15rem; }
|
||||
.result-title { display: block; color: var(--accent); font-size: 1.05rem; text-decoration: none; }
|
||||
.result-at { color: var(--muted); font-size: .8rem; font-weight: 400; margin-left: .4rem; }
|
||||
.result-snip { color: var(--fg); font-size: .9rem; line-height: 1.5; margin-top: .2rem; }
|
||||
.result-snip mark { background: var(--warn-soft); color: inherit; font-weight: 600; padding: 0 .1em; border-radius: .15em; }
|
||||
|
||||
/* ==========================================================================
|
||||
Papierkorb + Umbenennen
|
||||
========================================================================== */
|
||||
.list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
|
||||
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.btn.ghost:hover { color: var(--fg); border-color: var(--line); }
|
||||
.btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.trash-hint, .trash-empty { padding: .6rem .75rem; }
|
||||
.item.rec.trashed { opacity: .8; }
|
||||
.item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); }
|
||||
.days-left { color: var(--muted); }
|
||||
.days-left.urgent { color: var(--danger); font-weight: 600; }
|
||||
/* Der Stift sitzt neben der H2 des Detailkopfs, ohne die Zeile umzubrechen. */
|
||||
.rename-btn { vertical-align: middle; margin-left: -.25rem; opacity: .55; }
|
||||
.rename-btn:hover { opacity: 1; }
|
||||
.item.rec [data-ren] { opacity: .45; }
|
||||
.item.rec:hover [data-ren] { opacity: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-page.landing { padding-top: 6vh; }
|
||||
.search-go { padding: .55rem .8rem; }
|
||||
}
|
||||
@@ -3,6 +3,78 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
Was, Warum, betroffene Dateien/Backups).
|
||||
|
||||
## 2026-07-31 — Inhaltssuche, Papierkorb, Titel umbenennen (+ WLAN-Ausfall behoben)
|
||||
|
||||
Drei Funktionen auf Wunsch des Nutzers, dazu die Störung vom Vormittag. Details:
|
||||
[09 – Suche & Verwaltung](09-suche-und-verwaltung.md).
|
||||
|
||||
### Vorab: Board war nicht erreichbar — SSID-Rename killt den PSK-Hash
|
||||
|
||||
Das Board lief weiter als USB-Soundkarte, war aber im Netz unsichtbar. `wpa_supplicant` hing auf
|
||||
`ssid="RupuWolf5"`; das WLAN heißt seit dem Zusammenlegen der Bänder nur noch **`RupuWolf`** (4 BSSIDs,
|
||||
2437/2462/5300/5500 MHz). Nach dem SSID-Fix scheiterte die Anmeldung **trotz unverändertem Passwort**:
|
||||
|
||||
```
|
||||
wlP4p65s0: WPA: 4-Way Handshake failed - pre-shared key may be incorrect
|
||||
CTRL-EVENT-SSID-TEMP-DISABLED ssid="RupuWolf" auth_failures=1 reason=WRONG_KEY
|
||||
```
|
||||
|
||||
Ursache: die Conf speichert `psk=<64 Hex>`, und **WPA-PSK leitet diesen Hash per PBKDF2 mit der SSID als
|
||||
Salt ab** — ein SSID-Rename entwertet jeden vorberechneten Hash. Fix: Hash aus dem Klartext der
|
||||
`#psk="…"`-Kommentarzeile neu rechnen (`wpa_passphrase "RupuWolf" "$PLAIN"`).
|
||||
Backups: `.bak-rupuwolf5` (alte SSID), `.bak-oldhash`.
|
||||
|
||||
Außerdem: Hostname auf **`AIme`** gesetzt, `avahi-daemon` installiert → **`AIme.local`** löst per mDNS auf
|
||||
und ersetzt das IP-Suchen. Die FritzBox zeigt weiter `armbian-sd`, weil sie den Gerätenamen beim ersten
|
||||
DHCP-Kontakt einfriert; ein Mitschnitt belegt, dass das Board korrekt `Hostname (12): "AIme"` sendet.
|
||||
|
||||
### Titel umbenennen
|
||||
|
||||
`PATCH /api/recordings/:id/title` + Stift in Liste und Detailkopf. Neue Spalte
|
||||
**`recordings.title_locked`**: ein von Hand gesetzter Titel überlebt jede spätere KI-Analyse. Leeres Feld
|
||||
löscht Titel und Sperre.
|
||||
|
||||
### Papierkorb, 30 Tage
|
||||
|
||||
`DELETE /api/recordings/:id` setzt nur noch `deleted_at`; Audio, Transkript und Analyse bleiben vollständig
|
||||
liegen. Neue Ansicht über der Liste mit Resttagen (rot ab 3 Tagen), Wiederherstellen, endgültig Löschen.
|
||||
Aufräumlauf beim Boot **und** täglich — der Boot-Lauf, weil das Board oft tagelang aus ist.
|
||||
|
||||
**Altfehler mitbehoben:** das bisherige Löschen ließ die `words`-Zeilen verwaist in der Datenbank und die
|
||||
WAV/MP3-Dateien für immer auf der Karte zurück. `purgeRecording` räumt beides ab.
|
||||
|
||||
### Inhaltssuche mit Zeitstempel-Verweis
|
||||
|
||||
Neue Tabelle `passages` (1689 Zeilen aus 22 Aufnahmen), neuer Tab „Suche" in Google-Optik: Trefferliste
|
||||
sofort aus SQL, formulierte Antwort mit Belegen `[1] [2]` wird nachgereicht (Qwen3-4B, **~140 s**). Jeder
|
||||
Treffer nennt Meeting, Sprecher und Gesprächsminute und springt beim Klick genau dorthin.
|
||||
|
||||
Hybrid aus Volltext (MariaDB FULLTEXT), Passage-Embedding und Dokument-Embedding
|
||||
(`multilingual-e5-small`, ONNX/CPU auf den A55-Kernen). **Ohne das Modell degradiert die Suche sauber auf
|
||||
Volltext statt auszufallen.**
|
||||
|
||||
**Gemessene Grenzen — bewusst dokumentiert statt geschönt:**
|
||||
|
||||
- Volltext allein findet „Steueroptimierung" **nicht** (im Transkript steht eine andere Wortform) → genau
|
||||
dafür ist die semantische Ebene da, die findet #71 mit `vec=0,92`.
|
||||
- `e5-small` trennt auf verrauschtem ASR-Text schwach: für „Wie werden unsere Daten gesichert" lag die
|
||||
Kosinus-Spanne **aller** 1689 Passagen bei nur 0,8085–0,8560, und eine irrelevante Passage („Agenda
|
||||
zusammenpacken", 0,8560) schlug die richtige Antwort („Backup-Job startet um 11 Uhr", 0,8419).
|
||||
- Gegenmaßnahmen: Dokument-Ebene aus Titel + Zusammenfassung (sauberer LLM-Text statt ASR-Rauschen) und
|
||||
IDF-Wertung (Auswahl per `BOOLEAN MODE`, Wertung per `NATURAL LANGUAGE MODE` — Boolean gewichtet nicht
|
||||
nach Seltenheit, weshalb „Daten"-Wiederholung „gesichert" schlug).
|
||||
- Ergebnis: richtige Passage auf **Rang 11 von 296** statt gar nicht. Auffindbar, aber nicht in den Top-8,
|
||||
die das LLM liest. **Nächster Hebel: `multilingual-e5-base`.**
|
||||
|
||||
Relative Datumsangaben („letzte Woche") werden **deterministisch** aufgelöst, nicht vom LLM — ein falsches
|
||||
Kalenderfenster wäre schlimmer als keins. Der Datumsausdruck wird aus den Suchbegriffen entfernt.
|
||||
|
||||
### Dateien
|
||||
|
||||
Neu: `app/search.js`, `app/emb.js`, `app/inference/embed_text.py`, `docs/09-suche-und-verwaltung.md`.
|
||||
Geändert: `app/db.js`, `app/server.js`, `app/web/src/main.js`, `app/web/src/style.css`, `README.md`.
|
||||
Board-Backups: `.bak-search`. Nicht im Repo: `/opt/aime-models/e5-small/` (470 MB Modell).
|
||||
|
||||
## 2026-07-30 (nachmittags) — v2 machte den Uplink stumm, v3 behebt es
|
||||
|
||||
**Symptom (Nutzer, Testanruf 13:51):** „Die Gegenseite hat mich nicht mehr gehört." Regression aus v2 vom
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 09 — Inhaltssuche, Papierkorb, Titel umbenennen
|
||||
|
||||
Drei Funktionen, die am 2026-07-31 dazukamen. Die Suche ist der große Teil, die anderen beiden sind
|
||||
kleine Ergänzungen an derselben Datenschicht.
|
||||
|
||||
## Titel nachträglich umbenennen
|
||||
|
||||
`PATCH /api/recordings/:id/title`. Der Stift steht in der Aufnahmeliste und im Detailkopf.
|
||||
|
||||
Der eigentliche Punkt ist die Spalte **`recordings.title_locked`**: sobald ein Titel von Hand gesetzt
|
||||
wurde, darf die nächste KI-Analyse ihn nicht mehr überschreiben. Ohne diese Sperre hätte ein
|
||||
„↻ Analyse neu" den selbst vergebenen Namen still wieder gegen einen LLM-Vorschlag getauscht.
|
||||
|
||||
```sql
|
||||
-- db.js/updateAnalysis
|
||||
UPDATE recordings SET title = CASE WHEN title_locked=1 THEN title ELSE COALESCE(?,title) END, ...
|
||||
```
|
||||
|
||||
Ein leeres Feld löscht den Titel **und** die Sperre — danach darf die KI wieder benennen.
|
||||
|
||||
## Papierkorb (30 Tage)
|
||||
|
||||
Löschen setzt nur noch `recordings.deleted_at`. Nichts wird angefasst: Wörter, Marker, Passagen und die
|
||||
WAV/MP3-Dateien bleiben liegen, damit eine Wiederherstellung vollständig ist und nicht halb.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Aufnahme sichtbar<br/>deleted_at IS NULL] -->|"🗑 Löschen"| B[Papierkorb<br/>deleted_at = jetzt]
|
||||
B -->|"↩︎ Wiederherstellen"| A
|
||||
B -->|"✕ Endgültig (manuell)"| C[purgeRecording]
|
||||
B -->|"Aufräumlauf: älter als 30 Tage"| C
|
||||
C --> D["DELETE markers + words + passages<br/>DELETE recordings<br/>unlink wav + mp3"]
|
||||
```
|
||||
|
||||
Der Aufräumlauf (`sweepTrash` in `server.js`) startet **beim Boot und danach täglich**. Der Boot-Lauf ist
|
||||
nötig, weil das Board oft tagelang aus ist — ein reiner 24-Stunden-Timer würde das Fenster immer wieder
|
||||
verpassen.
|
||||
|
||||
**Altfehler mitbehoben:** das frühere `deleteRecording` löschte nur `markers` und die `recordings`-Zeile.
|
||||
Die `words`-Zeilen blieben verwaist in der Datenbank zurück und die Audiodateien für immer auf der Karte.
|
||||
`purgeRecording` räumt jetzt beides mit ab.
|
||||
|
||||
Retention über `AIME_TRASH_DAYS` einstellbar (Vorgabe 30).
|
||||
|
||||
## Inhaltssuche
|
||||
|
||||
### Aufbau
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q["Frage:<br/>„Welches Projekt hat X letzte Woche vorgestellt?“"] --> DR[parseDateRange<br/>deterministisch, kein LLM]
|
||||
Q --> CT[contentTerms<br/>Stoppwörter raus]
|
||||
DR -->|"Filter: 20.–27.07."| SEL
|
||||
CT -->|"Projekt, X, vorgestellt"| SEL
|
||||
|
||||
subgraph SEL["Kandidaten + Bewertung (SQL, Millisekunden)"]
|
||||
FT["Volltext<br/>BOOLEAN = Auswahl<br/>NATURAL LANGUAGE = Wertung (IDF)"]
|
||||
VP["Passage-Embedding<br/>Kosinus, min-max"]
|
||||
VD["Dokument-Embedding<br/>Titel + Zusammenfassung"]
|
||||
end
|
||||
|
||||
SEL --> SC["score = 0,45·ft + 0,25·vec + 0,30·doc<br/>+ Sprecher + Wortgruppe + Aktualität"]
|
||||
SC --> HITS["Trefferliste sofort<br/>Meeting · Sprecher · mm:ss"]
|
||||
HITS --> LLM["Qwen3-4B über die Top-8<br/>~140 s, wird nachgereicht"]
|
||||
LLM --> ANS["Antwort mit Belegen [1] [2]"]
|
||||
```
|
||||
|
||||
Die Zweiteilung ist Absicht: die Trefferliste kommt aus SQL und steht sofort, die formulierte Antwort
|
||||
braucht auf dieser NPU **~140 s**. Erst antworten und dann anzeigen hieße eine Minute Leerlauf.
|
||||
|
||||
### Passagen
|
||||
|
||||
Eine Passage ist die Sucheinheit: 2–4 Sätze **eines** Sprechers, ~420 Zeichen, harte Grenze 700. Sie wird
|
||||
aus den `words`-Zeilen geschnitten, nicht aus `transcript_clean` — nur die Wörter tragen Zeitstempel, und
|
||||
der Zeitstempel ist der ganze Zweck eines Treffers. Ein Sprecherwechsel beendet eine Passage immer.
|
||||
|
||||
Stand 2026-07-31: 22 Aufnahmen → **1689 Passagen** (Neuaufbau in ~1 s ohne, ~9 min mit Embeddings).
|
||||
|
||||
### Warum hybrid — mit Messwerten
|
||||
|
||||
Keine der beiden Ebenen genügt allein, und beide Grenzen sind an diesem Korpus **gemessen**:
|
||||
|
||||
| Frage | Volltext | Semantisch |
|
||||
|---|---|---|
|
||||
| `Steueroptimierung` | **0 Treffer** — im Transkript steht eine andere Wortform | findet #71, `vec=0,92` |
|
||||
| `Wie können wir Steuern sparen` | trifft über „Steuern" | bestätigt #71 |
|
||||
| `OneDrive` | 21 Treffer, exakt | Eigennamen verwischen im Embedding |
|
||||
|
||||
### Bekannte Grenze der semantischen Ebene
|
||||
|
||||
`multilingual-e5-small` trennt auf **verrauschtem ASR-Text** schwach. Gemessen für „Wie werden unsere Daten
|
||||
gesichert" über alle 1689 Passagen:
|
||||
|
||||
```
|
||||
Kosinus-Spanne der Kandidaten: 0,8085 .. 0,8560 (Breite nur 0,047)
|
||||
|
||||
cos=0,8560 #77 „Vielleicht packen wir nochmal eine Agenda zusammen…“ ← irrelevant, Rang 1
|
||||
cos=0,8419 #85 „…jeden Abend um 11 Uhr der Backup-Job startet.“ ← die Antwort, Rang 5
|
||||
```
|
||||
|
||||
Das Embedding selbst rankt also falsch; keine Normalisierung repariert eine falsche Reihenfolge. Grund ist
|
||||
vermutlich, dass die ASR Komposita zerbricht („Backup -Job", „KI -Binnenstrich") — genau das, womit ein
|
||||
Embedding am schlechtesten umgeht.
|
||||
|
||||
Zwei Gegenmaßnahmen sind drin und messbar wirksam, lösen den Fall aber nicht ganz:
|
||||
|
||||
1. **Dokument-Ebene** (`recordings.doc_vec`): Titel + Zusammenfassung sind saubere LLM-Prosa statt
|
||||
ASR-Rauschen. Sagt „diese *Aufnahme* passt zur Frage", auch wenn keine einzelne Passage gut einbettet.
|
||||
2. **IDF-Wertung**: Kandidaten per `BOOLEAN MODE` auswählen, aber mit `NATURAL LANGUAGE MODE` bewerten.
|
||||
Boolean gewichtet nicht nach Seltenheit, weshalb eine Passage, die „Daten" mehrfach wiederholt, die
|
||||
Passage schlug, die „gesichert" tatsächlich beschreibt.
|
||||
|
||||
Ergebnis für den schwierigen Fall: die richtige Passage steht auf **Rang 11 von 296** statt gar nicht — sie
|
||||
ist auffindbar, erreicht aber die Top-8 nicht, die das LLM zu sehen bekommt. **Nächster Hebel wäre
|
||||
`multilingual-e5-base`** (~1,1 GB statt 470 MB, Neuindexierung ~25–30 min).
|
||||
|
||||
### Relative Datumsangaben
|
||||
|
||||
„letzte Woche" wird **deterministisch** aufgelöst, nicht vom LLM: ein falsches Kalenderfenster ist
|
||||
schlimmer als gar kein Filter, und Datumsarithmetik ist genau das, was ein 4B-Modell subtil falsch macht.
|
||||
Wochen laufen Montag–Sonntag. Der erkannte Datumsausdruck wird aus den Suchbegriffen **entfernt** — sonst
|
||||
sucht die Anfrage nach dem Wort „Woche".
|
||||
|
||||
Wenn nur ein Zeitraum und kein Suchbegriff übrig bleibt („Was lief letzte Woche?"), zeigt die Suche die
|
||||
Aufnahmen dieses Zeitraums ab ihren Eröffnungspassagen statt einer leeren Seite.
|
||||
|
||||
### Embedding-Modell
|
||||
|
||||
`/opt/aime-models/e5-small/` — `model.onnx` (470 MB, fp32), `tokenizer.json`, `config.json`, geladen von
|
||||
`intfloat/multilingual-e5-small`. Läuft über `inference/embed_text.py` als Daemon auf onnxruntime/CPU,
|
||||
**`taskset -c 0-3`** (A55), damit die A76-Kerne für whisper und sherpa frei bleiben.
|
||||
|
||||
Bewusst **ohne** sentence-transformers: das zöge torch + transformers (~2 GB) auf eine Karte, auf der
|
||||
bereits whisper, sherpa und ein 5-GB-LLM liegen. `tokenizers` und `onnxruntime` waren für den NPU-Pfad
|
||||
ohnehin schon im venv.
|
||||
|
||||
Die e5-Präfixe `query: ` und `passage: ` sind Pflicht — ohne sie verliert das Modell deutlich an
|
||||
Genauigkeit, weil genau dieses asymmetrische Paar kurze Fragen auf lange Passagen abbildet.
|
||||
|
||||
**Ohne das Modell degradiert die Suche sauber auf reinen Volltext** (`emb.available()` ist dann `false`),
|
||||
sie fällt nicht aus. Das war die Voraussetzung dafür, die Suche vor dem Download in Betrieb zu nehmen.
|
||||
|
||||
### Wann neu indexiert wird
|
||||
|
||||
| Auslöser | Umfang |
|
||||
|---|---|
|
||||
| Analyse einer Aufnahme fertig | diese Aufnahme (nach `relabelAnalysis`, damit die Sprechernamen stehen) |
|
||||
| Sprecher umbenannt | diese Aufnahme (`speaker_name` liegt denormalisiert in `passages`) |
|
||||
| Dienststart | alle Aufnahmen **ohne** Passagen (Nachzügler) |
|
||||
| `POST /api/search/reindex {"force":true}` | alles |
|
||||
|
||||
## API
|
||||
|
||||
| Route | Zweck |
|
||||
|---|---|
|
||||
| `PATCH /api/recordings/:id/title` | Titel setzen/löschen |
|
||||
| `DELETE /api/recordings/:id` | in den Papierkorb |
|
||||
| `GET /api/trash` | Inhalt + Resttage |
|
||||
| `POST /api/trash/:id/restore` | wiederherstellen |
|
||||
| `DELETE /api/trash/:id` | endgültig löschen (inkl. Dateien) |
|
||||
| `GET /api/search?q=` | Trefferliste, sofort |
|
||||
| `POST /api/search/answer` | formulierte Antwort mit Belegen (NPU-Warteschlange) |
|
||||
| `GET /api/search/status` | Passagen, davon eingebettet, Modell da? |
|
||||
| `POST /api/search/reindex` | Index neu bauen (`{"force":true}` = alles) |
|
||||
|
||||
## Stellschrauben
|
||||
|
||||
| Variable | Vorgabe | Wirkung |
|
||||
|---|---|---|
|
||||
| `AIME_TRASH_DAYS` | 30 | Aufbewahrung im Papierkorb |
|
||||
| `AIME_PASSAGE_CHARS` | 420 | Ziellänge einer Passage |
|
||||
| `AIME_SEARCH_TOPK` | 20 | Treffer je Suche |
|
||||
| `AIME_ANSWER_PASSAGES` | 8 | Passagen für die LLM-Antwort (5 ≈ 90 s statt 140 s) |
|
||||
| `AIME_EMB_DIR` | `/opt/aime-models/e5-small` | Embedding-Modell |
|
||||
|
||||
## Dateien
|
||||
|
||||
`app/search.js` (neu), `app/emb.js` (neu), `app/inference/embed_text.py` (neu), `app/db.js`,
|
||||
`app/server.js`, `app/web/src/main.js`, `app/web/src/style.css`.
|
||||
Backups auf dem Board: `.bak-search`.
|
||||
Reference in New Issue
Block a user