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
+86 -1
View File
@@ -15,7 +15,13 @@ const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, s
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false,
// Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und
// welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters).
projectId: null, projectTabId: 0 };
projectId: null, projectTabId: 0,
// Kontextnotiz: `ctxPromptFor` = die Aufnahme, deren Kontextbox nach dem Stop
// aufgeklappt und fokussiert dasteht (bis gespeichert oder weggeklickt).
// `ctxDrafts` hält getippten, noch nicht gespeicherten Text pro Aufnahme —
// die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das
// Getippte weg.
ctxPromptFor: null, ctxDrafts: {} };
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); }
@@ -87,6 +93,10 @@ async function toggleRecord() {
const r = await api.post('/api/record/stop', {});
state.recording = false;
toast('Aufnahme gespeichert – wird transkribiert…');
// Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription
// läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da,
// wenn die KI-Analyse danach startet (server.js analyzeRecording).
state.ctxPromptFor = r.id;
state.tab = 'recordings'; render();
state.lastListSig = null; refreshRecList();
openRecording(r.id);
@@ -1222,6 +1232,7 @@ async function openRecording(id) {
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
${spkOrder.length ? `<button class="btn small icon" id="viewswitch" title="Ansicht wechseln: Konferenztisch / Transkript"></button>` : ''}
</div>
<section class="ctxbox" id="ctxbox"></section>
<div class="detail-cols">
<div class="mod-col" data-col="main">${layout.main.map(modHtml).join('')}</div>
<div class="mod-col" data-col="side">${layout.side.map(modHtml).join('')}</div>
@@ -1260,6 +1271,80 @@ async function openRecording(id) {
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
markEmpty();
}));
// ---- Kontextnotiz --------------------------------------------------------
// EIN Freitextfeld, absichtlich kein Formular mit Teilnehmer-/Begriffsfeldern:
// die Box benutzen auch Leute, die kein Formular ausfüllen; Namen und Begriffe
// holt sich die Analyse selbst aus der Prosa (llm.js contextBlock). Der
// Platzhalter führt statt Pflichtfeldern. Direkt nach dem Stop steht die Box
// offen (state.ctxPromptFor). Sie zeichnet sich SELBST neu statt über
// openRecording() — ein Neuaufbau der Detailansicht setzt den <audio>-Tag
// zurück und würde das Abspielen unterbrechen.
const ctxBox = detail.querySelector('#ctxbox');
let ctxNote = r.context_note || '';
const CTX_PH = 'Wer war dabei, worum ging es, wichtige Namen oder Begriffe? Stichworte reichen.\nz. B.: Call mit Herrn Meier von Firma Krämer wegen Angebot Halle 3, Thomas aus dem Vertrieb war auch dabei.';
const ctxInner = (open) => `
<div class="ctx-head">
<span class="ctx-title">📝 Kontext zum Gespräch</span>
${ctxNote ? '<span class="badge ok">hinterlegt</span>' : '<span class="muted small">macht die KI-Analyse deutlich besser</span>'}
<button class="btn small" id="ctxtoggle">${open ? 'Zuklappen' : ctxNote ? '✏️ Bearbeiten' : '➕ Hinzufügen'}</button>
</div>
${(!open && ctxNote) ? `<div class="ctx-text">${escapeHtml(ctxNote)}</div>` : ''}
${open ? `
<textarea class="input ctx-input" id="ctxinput" rows="4" maxlength="4000"
placeholder="${escapeHtml(CTX_PH)}">${escapeHtml(state.ctxDrafts[id] != null ? state.ctxDrafts[id] : ctxNote)}</textarea>
<div class="row ctx-actions">
<button class="btn primary small" id="ctxsave">Speichern</button>
<button class="btn small" id="ctxskip">${ctxNote ? 'Abbrechen' : 'Ohne Kontext'}</button>
<span class="muted small">Nur Hintergrundwissen für die KI — landet nie als Gesagtes in der Zusammenfassung.</span>
</div>` : ''}`;
const saveCtx = async () => {
const ta = ctxBox.querySelector('#ctxinput');
const btn = ctxBox.querySelector('#ctxsave');
if (!ta || !btn) return;
btn.disabled = true; btn.textContent = 'speichert…';
let res;
try { res = await api.patch(`/api/recordings/${id}/context`, { context_note: ta.value }); }
catch (e) { toast('Kontext speichern fehlgeschlagen: ' + e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; return; }
ctxNote = res.context_note || '';
delete state.ctxDrafts[id];
if (state.ctxPromptFor === id) state.ctxPromptFor = null;
drawCtx(false);
if (!ctxNote) { toast('Kontext entfernt'); return; }
// Ehrlich sagen, ob die Notiz die laufende Verarbeitung noch erreicht
// (server.js liefert `applies` genau dafür).
if (res.applies === 'auto') { toast('Kontext gespeichert – die KI-Analyse nutzt ihn'); return; }
if (res.applies === 'running') { toast('Kontext gespeichert. Die Analyse läuft schon – danach „↻ Analyse neu“ drücken, damit er einfließt.'); return; }
if (!confirm('Kontext gespeichert.\n\nDie Analyse ist bereits fertig. Zusammenfassung und ToDos jetzt mit dem Kontext neu erstellen?\n(Die Sprecher-Trennung bleibt unverändert, dauert einige Minuten.)')) return;
try {
await api.post(`/api/recordings/${id}/reanalyze-llm`, {});
toast('Analyse läuft neu – mit Kontext');
setTimeout(() => openRecording(id), 1200);
} catch (e) { toast(e.message, true); }
};
const drawCtx = (open, focus) => {
ctxBox.classList.toggle('open', !!open);
ctxBox.classList.toggle('filled', !!ctxNote);
ctxBox.innerHTML = ctxInner(open);
ctxBox.querySelector('#ctxtoggle').onclick = () => {
if (open) { delete state.ctxDrafts[id]; if (state.ctxPromptFor === id) state.ctxPromptFor = null; }
drawCtx(!open, !open);
};
if (!open) return;
const ta = ctxBox.querySelector('#ctxinput');
// Getipptes überlebt den Neuaufbau nach Ende der Analyse (state.ctxDrafts).
ta.oninput = () => { state.ctxDrafts[id] = ta.value; };
// Strg+Enter speichert; Enter selbst macht Absätze — es ist ein Freitextfeld.
ta.onkeydown = (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveCtx(); };
ctxBox.querySelector('#ctxsave').onclick = saveCtx;
ctxBox.querySelector('#ctxskip').onclick = () => {
delete state.ctxDrafts[id];
if (state.ctxPromptFor === id) state.ctxPromptFor = null;
drawCtx(false);
};
if (focus) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
};
drawCtx(state.ctxPromptFor === id || state.ctxDrafts[id] != null, state.ctxPromptFor === id);
const renBtn = detail.querySelector('#renametitle');
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
const reBtn = detail.querySelector('#reanalyze');