feat(analyse): Kontextnotiz zum Gespraech nach dem Stop

Ein Freitextfeld ("Kontext zum Gespraech") in der Detailansicht, nach dem
Stop aufgeklappt. Der Text geht als Hintergrundwissen in die LLM-Analyse:
Personen, Firmen, Anlass und Eigennamen muessen nicht mehr aus dem
fehlerbehafteten ASR-Text erraten werden.

Bewusst EIN Feld statt eines Formulars mit Teilnehmer-/Anlass-/Begriffs-
feldern (Entscheidung des Nutzers): die Box benutzen auch technisch nicht
versierte Menschen. Gefuehrt wird ueber den Platzhaltertext, die Struktur
zieht die Analyse selbst aus der Prosa.

- recordings.context_note/-_at, db.setContextNote/getContextNote
- PATCH /api/recordings/:id/context; Antwort `applies` sagt ehrlich, ob die
  Notiz die laufende Verarbeitung noch erreicht (auto/running/reanalyze)
- analyzeRecording liest die Notiz erst kurz vor dem LLM-Aufruf, damit
  waehrend der minutenlangen Vorstufen Getipptes noch zaehlt
- llm.contextBlock: eigener """-Block mit Verbot, daraus Aussagen, Ergebnisse
  oder Aufgaben abzuleiten (sonst schreibt das 4B-Modell die Notiz als
  Gesagtes zurueck); eingespeist in Single-, Whole- und Sektions-Pfad
- zwei Deckel: 1200 Zeichen fuer Prompts, die die Notiz einmal sehen,
  600 fuer die Chunk-Prompts (dort landet sie in jedem Chunk von 3200)
- GUI-Karte zeichnet sich selbst neu (kein openRecording -> Audio laeuft
  weiter), ungespeicherter Text ueberlebt den Neuaufbau nach der Analyse

Verifiziert an #97: Kontext greift (korrekte Namen, richtiges Thema), die
absichtlich eingelegten Fremdfakten der Testnotiz erscheinen nicht in der
Zusammenfassung. Alte Analyse danach zurueckgeschrieben.

Doku: Kapitel 05 (neuer Abschnitt + Flussdiagramm + Deckel-Tabelle),
Kapitel 03 (Sequenzdiagramm + API), Changelog 2026-08-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-17 16:10:38 +02:00
parent 3d628b4b96
commit 0f530b575a
8 changed files with 332 additions and 30 deletions
+32
View File
@@ -88,6 +88,15 @@ export async function ensureSchema() {
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`);
// Kontextnotiz des Nutzers: was er direkt nach dem Stop über das Gespräch
// notiert (wer war dabei, worum ging es, welche Eigennamen). Bewusst EIN
// Freitextfeld statt Formularfeldern — die Box benutzen auch Menschen, die
// kein Formular ausfüllen wollen; die Struktur (Namen, Begriffe) holt sich
// die Analyse selbst aus der Prosa. Fließt als Hintergrundwissen in die
// LLM-Analyse (llm.analyze -> contextBlock), NICHT als Gesprächsinhalt.
await pool.query(`ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS context_note TEXT NULL,
ADD COLUMN IF NOT EXISTS context_note_at TIMESTAMP NULL DEFAULT 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
@@ -539,6 +548,29 @@ export async function renameRecording(id, title) {
return rows[0] || null;
}
// --- Kontextnotiz ------------------------------------------------------------
// Leerer Text löscht die Notiz wieder (dann fällt die Analyse auf reines
// Transkript zurück). Deckel bei CONTEXT_MAX: die Spalte ist TEXT (64 KB), aber
// die Notiz landet in JEDEM Chunk-Prompt — ein Roman darin verdrängt Transkript.
export const CONTEXT_MAX = 4000;
export async function setContextNote(id, note) {
const n = (note || '').toString().trim().slice(0, CONTEXT_MAX);
await pool.execute(
'UPDATE recordings SET context_note=?, context_note_at=IF(?<>\'\', CURRENT_TIMESTAMP, NULL) WHERE id=?',
[n || null, n, id]);
const [rows] = await pool.query(
'SELECT id,context_note,context_note_at,status,analysis_status FROM recordings WHERE id=?', [id]);
return rows[0] || null;
}
// Nur die Notiz nachladen. Die Analyse liest sie so SPÄT wie möglich (kurz vor
// dem LLM-Aufruf statt beim Start): Sprecher-Erkennung und Whisper laufen davor
// minutenlang, und was der Nutzer in dieser Zeit noch tippt, soll mitzählen.
export async function getContextNote(id) {
const [rows] = await pool.query('SELECT context_note FROM recordings WHERE id=?', [id]);
return (rows[0] && rows[0].context_note) || '';
}
function parseJsonCols(row) {
for (const c of ['todos', 'participants']) {
if (row && typeof row[c] === 'string') {