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:
+86
-1
@@ -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');
|
||||
|
||||
@@ -259,6 +259,17 @@ body.resizing { cursor: col-resize; user-select: none; }
|
||||
.item.rec:hover { border-color: var(--accent); }
|
||||
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ---- Kontextnotiz (eine Karte über den Modulen, nicht verschiebbar) ---- */
|
||||
.ctxbox { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 0 0 10px; background: var(--inset); }
|
||||
/* Offen + noch leer = die Einladung direkt nach dem Stop: farbig hervorgehoben. */
|
||||
.ctxbox.open:not(.filled) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.ctx-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ctx-title { font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.ctx-head .btn { margin-left: auto; }
|
||||
.ctx-text { margin-top: 8px; white-space: pre-wrap; font-size: 14px; }
|
||||
.ctx-input { margin-top: 8px; resize: vertical; min-height: 76px; font: inherit; line-height: 1.45; }
|
||||
.ctx-actions { margin-top: 8px; }
|
||||
|
||||
/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */
|
||||
.status-line { margin: 4px 0 10px; }
|
||||
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
|
||||
|
||||
Reference in New Issue
Block a user