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') {
+63 -28
View File
@@ -205,6 +205,30 @@ function glossaryHint(glossary) {
// Combine the base correction rule with the per-recording glossary.
const correctionHint = (glossary) => CORRECTION_HINT + glossaryHint(glossary);
// --- Kontextnotiz des Nutzers -------------------------------------------------
// Freitext, den der Nutzer nach dem Stop notiert ("Call mit Meier von Fa. Krämer
// wegen Angebot Halle 3"). Er ist Hintergrundwissen, KEIN Gesprächsinhalt: das
// 4B-Modell schreibt eine solche Notiz sonst als Gesagtes in die Zusammenfassung
// zurück (dieselbe Falle wie beim CORRECTION_HINT, siehe dessen STRENG-Satz).
// Deshalb steht die Notiz in eigenen Anführungsblöcken MIT Verbot, daraus Fakten
// abzuleiten. Zwei Deckel: der lange für Prompts, die den Text einmal sehen, der
// kurze für den Abschnitts-Pfad, wo die Notiz in JEDEN Chunk-Prompt geht
// (CHUNK_CHARS = 3200 — eine 1200-Zeichen-Notiz wäre dort ein Drittel Ballast).
const CONTEXT_MAX_CHARS = +(process.env.LLM_CONTEXT_CHARS || 1200);
const CONTEXT_CHUNK_CHARS = +(process.env.LLM_CONTEXT_CHUNK_CHARS || 600);
function contextBlock(note, max = CONTEXT_MAX_CHARS) {
const n = (note || '').toString().replace(/\r/g, '').replace(/\n{3,}/g, '\n\n').trim();
if (!n) return '';
const t = n.length > max ? n.slice(0, max).trim() + '…' : n;
return `
HINTERGRUNDWISSEN DES NUTZERS (von ihm nach dem Gespräch notiert, NICHT Teil des Gesprächs):
"""
${t}
"""
Nutze diese Notiz NUR, um Personen, Firmen, Rollen und Fachbegriffe richtig zu schreiben und zuzuordnen, den Anlass des Gesprächs zu verstehen und die Themen richtig zu gewichten. STRENG: Die Notiz ist KEIN Transkript. Übernimm daraus KEINE Aussagen, Ergebnisse, Entscheidungen oder Aufgaben — jede Angabe in deiner Antwort muss im Gespräch selbst gesprochen worden sein. Erwähne die Notiz nicht und zitiere sie nicht.`;
}
// Shared todo-extraction rule (used verbatim in the combined suffix AND the
// JSON-only fallback so the two never drift). Tuned for RECALL: the 3B model
// was dropping clear work assignments under an over-cautious "im Zweifel leer"
@@ -220,8 +244,8 @@ const NO_INVENT =
`WICHTIG (keine Halluzination): Verwende in Titel und Zusammenfassung AUSSCHLIESSLICH reale, gebräuchliche deutsche Wörter und NUR Informationen, die im Transkript tatsächlich vorkommen. Erfinde NICHTS — keine Wörter, keine zusammengesetzten Fantasiebegriffe (z.B. NIEMALS Erfindungen wie "Audio-Dualeintragung"), keine Fakten, keine Namen, keine Zahlen. Bist du dir bei einem Wort unsicher, formuliere einfacher mit gebräuchlichen Wörtern statt zu raten. Halte die Zusammenfassung schlicht und nah am Gesagten.`;
// Runs on the ALREADY-CORRECTED transcript, so every field is consistent.
const PROMPT_JSON = (transcript) =>
`Du analysierst das Transkript eines deutschen Gesprächs.
const PROMPT_JSON = (transcript, ctx = '') =>
`Du analysierst das Transkript eines deutschen Gesprächs.${ctx}
Transkript:
"""
@@ -239,8 +263,8 @@ ${NO_INVENT}`;
// transcript, reads through ASR errors via `hint`, and returns one rich JSON
// analysis. Richer summary than the short/chunk prompts (the whole arc is
// visible) but still analysis-only → small output.
const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}
const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT, ctx = '') =>
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}${ctx}
Lies das GESAMTE Gespräch und gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "kurzer Überblick in 2-4 Sätzen: worum es ging und was das konkrete Ergebnis war", "kernpunkte": ["wichtiger besprochener Punkt als kurzer Stichpunkt, je ein Punkt pro Thema"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer Satz"], "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]}
@@ -279,8 +303,8 @@ ${broken}
// Corrects ASR errors AND splits the single mixed mono transcript into a
// speaker-labelled dialogue by CONTEXT (this is text inference, not acoustic
// voice recognition). Output is plain "Name: Text" lines — robust to parse.
const PROMPT_CLEAN = (transcript, hint = CORRECTION_HINT) =>
`Das folgende deutsche Transkript stammt aus automatischer Spracherkennung eines Gesprächs mit möglicherweise MEHREREN Sprechern (in einer Tonspur gemischt). ${hint}
const PROMPT_CLEAN = (transcript, hint = CORRECTION_HINT, ctx = '') =>
`Das folgende deutsche Transkript stammt aus automatischer Spracherkennung eines Gesprächs mit möglicherweise MEHREREN Sprechern (in einer Tonspur gemischt). ${hint}${ctx}
Aufgabe: Gib das korrigierte Gespräch als DIALOG zurück. Erkenne aus dem Kontext (Anrede, Fragen und Antworten, genannte Namen, Themen- und Perspektivwechsel) die Sprecherwechsel und ordne jede Aussage einem Sprecher zu. Verwende genannte Namen (z.B. "Michael"); wenn ein Name unbekannt ist, "Sprecher 1", "Sprecher 2". Wenn nur eine Person spricht, nutze durchgehend ein Label.
@@ -291,8 +315,8 @@ ${transcript}`;
// When acoustic diarization already labelled the turns ("Sprecher 1: …" lines),
// the LLM must NOT re-guess speakers — only fix ASR errors and keep the labels.
const PROMPT_CLEAN_LABELED = (transcript, hint = CORRECTION_HINT) =>
`Das folgende deutsche Gespräch ist bereits nach Sprechern gegliedert (Zeilen im Format "Sprecher X: Text"), die Zuordnung stammt aus Stimm-Erkennung und ist korrekt. ${hint}
const PROMPT_CLEAN_LABELED = (transcript, hint = CORRECTION_HINT, ctx = '') =>
`Das folgende deutsche Gespräch ist bereits nach Sprechern gegliedert (Zeilen im Format "Sprecher X: Text"), die Zuordnung stammt aus Stimm-Erkennung und ist korrekt. ${hint}${ctx}
Aufgabe: Gib den Text korrigiert zurück und BEHALTE die Sprecher-Labels und die Zeilenstruktur EXAKT bei (dieselben Labels, dieselbe Reihenfolge). Korrigiere nur die Wörter, ändere keine Sprecher-Zuordnung. Keine Einleitung, kein Markdown.
@@ -429,8 +453,8 @@ const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filt
// POSTS into "the team publishes only on Instagram" — the one factual inversion
// that survived every prompt wording (wrong in 4 of 7 runs). The tail is marked
// as context-only so it is not summarized twice.
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '') =>
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '', ctx = '') =>
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${ctx}${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
${context}` : ''}${tail ? `
Die letzten Sätze des VORIGEN Abschnitts, nur damit du weißt, worauf sich der Anfang bezieht — NICHT zusammenfassen:
@@ -494,9 +518,9 @@ async function verifySection(chunk, draft) {
// Title micro-call over 2-sentence section Kurzfassungen (small input/output =
// reliable regime). The LLM-reduce that used to build the whole summary is
// gone: cross-section synthesis is beyond the 4B (see analyzeLong header).
const PROMPT_TITLE = (kurzfassungen) =>
const PROMPT_TITLE = (kurzfassungen, ctx = '') =>
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
${kurzfassungen}
${kurzfassungen}${ctx}
Gib einen prägnanten deutschen Titel (max. 6 Wörter) für das GESAMTE Gespräch. Der Titel benennt das größte Thema.
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"titel": "..."}`;
@@ -505,33 +529,38 @@ Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"titel": "..."}`;
// output regime as the title call. Gives the conglomerate a Copilot-style
// intro paragraph; equal weighting is enforced by the input (every section
// contributes the same 1-2 sentences).
const PROMPT_OVERVIEW = (kurzfassungen) =>
const PROMPT_OVERVIEW = (kurzfassungen, ctx = '') =>
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
${kurzfassungen}
${kurzfassungen}${ctx}
Gib einen kurzen Überblick über das GESAMTE Gespräch in 2-3 deutschen Sätzen: worum es ging und was die wichtigsten Ergebnisse waren. Gewichte ALLE Abschnitte gleich — nicht nur die letzten.
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"ueberblick": "..."}`;
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
// `labeled` = the text already carries acoustic "Sprecher X:" turn labels.
export async function analyze(transcript, { labeled = false, glossary = [] } = {}) {
// `context` = Freitext-Kontextnotiz des Nutzers (siehe contextBlock).
export async function analyze(transcript, { labeled = false, glossary = [], context = '' } = {}) {
const raw = (transcript || '').trim();
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
const hint = correctionHint(glossary);
const ctx = contextBlock(context);
const ctxShort = contextBlock(context, CONTEXT_CHUNK_CHARS);
if (ctx) console.log(`llm: Kontextnotiz des Nutzers wird mitgegeben (${(context || '').trim().length} Zeichen` +
`${(context || '').trim().length > CONTEXT_MAX_CHARS ? `, auf ${CONTEXT_MAX_CHARS} gekürzt` : ''})`);
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
const longMeeting = raw.length > SECTION_MIN_CHARS;
if (raw.length > WHOLE_MAX_CHARS || longMeeting || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
console.log(`llm: ${raw.length} chars / ${countSpeakers(raw)} speakers -> section path` +
(longMeeting ? ` (>${SECTION_MIN_CHARS} chars)` : ' (round-robin meeting)'));
return analyzeLong(raw, hint);
return analyzeLong(raw, hint, ctx, ctxShort);
}
if (raw.length > SINGLE_MAX_CHARS) {
// Whole-pass gives the coherent summary; if Gemma's JSON breaks even after
// the repair call, degrade to chunked map-reduce instead of failing hard.
try { return await analyzeWhole(raw, hint); }
catch (e) { console.log(`llm: whole-analysis failed (${e.message}) -> chunked fallback`); return analyzeLong(raw, hint); }
try { return await analyzeWhole(raw, hint, ctx); }
catch (e) { console.log(`llm: whole-analysis failed (${e.message}) -> chunked fallback`); return analyzeLong(raw, hint, ctx, ctxShort); }
}
return analyzeSingle(raw, labeled, hint);
return analyzeSingle(raw, labeled, hint, ctx);
}
// Whole-transcript analysis in ONE call (16K-context model). Reads the ENTIRE
@@ -540,8 +569,8 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
// chunk boundaries → incoherent long summaries). Analysis-ONLY (small JSON out),
// no full-dialogue rewrite → decode stays short. transcriptClean left empty on
// purpose: the detail view renders diarized turns from `words`.
async function analyzeWhole(raw, hint = CORRECTION_HINT) {
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint));
async function analyzeWhole(raw, hint = CORRECTION_HINT, ctx = '') {
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint, ctx));
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (!j) {
// Gemma occasionally breaks the JSON on very long inputs (leaked prose,
@@ -568,9 +597,9 @@ async function analyzeWhole(raw, hint = CORRECTION_HINT) {
// Single-call path (short transcripts): one combined NPU call = corrected
// dialogue + JSON analysis. PROMPT_JSON alone is the fallback if it doesn't parse.
async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT) {
async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT, ctx = '') {
const cleanPrompt = labeled ? PROMPT_CLEAN_LABELED : PROMPT_CLEAN;
const out = await ask(cleanPrompt(raw, hint) + COMBINED_SUFFIX);
const out = await ask(cleanPrompt(raw, hint, ctx) + COMBINED_SUFFIX);
const parts = splitCombined(out);
let transcriptClean = parts.clean
.replace(/^```.*$/gm, '')
@@ -587,7 +616,7 @@ async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT) {
let j = extractJson(parts.json) || extractJson(`{${parts.json.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (echoed(j)) {
console.log(`llm: combined JSON ${j ? 'echoed field descriptions' : 'unparseable'} -> separate JSON call; tail: ${JSON.stringify(out.slice(-250))}`);
j = extractJson(await ask(PROMPT_JSON(transcriptClean || raw)));
j = extractJson(await ask(PROMPT_JSON(transcriptClean || raw, ctx)));
}
if (!j) throw new Error('LLM lieferte kein gültiges JSON');
return {
@@ -630,7 +659,10 @@ function salvageChunkFields(text) {
// from a micro-call over 2-sentence Kurzfassungen (last section excluded:
// end-of-call smalltalk heuristic). transcriptClean stays empty on purpose
// (detail view renders diarized turns from `words`).
async function analyzeLong(raw, hint = CORRECTION_HINT) {
// `note` = Kontextnotiz-Block für die kleinen Titel-/Überblick-Aufrufe,
// `noteShort` die gekürzte Fassung für die vielen Chunk-Prompts. NICHT `ctx`
// nennen: so heißt in der Chunk-Schleife schon der rollende Gesprächsverlauf.
async function analyzeLong(raw, hint = CORRECTION_HINT, note = '', noteShort = note) {
const chunks = chunkText(raw, CHUNK_CHARS);
console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only, rolling context)`);
const sections = []; // { sum, names[] }
@@ -657,7 +689,10 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
// The verbatim tail stays on every try — it is grounding, not context
// the model can get chatty about.
const tail = i > 0 ? chunks[i - 1].slice(-TAIL_CHARS) : '';
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], ctx, tries === 2 ? '' : hint, tail));
// Die Kontextnotiz fällt auf dem LETZTEN Versuch mit dem hint weg: wenn
// das Modell zweimal die Feldbeschreibung nachgeplappert hat, ist jeder
// Zusatztext vor dem Abschnitt verdächtig (siehe Kommentar oben).
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], ctx, tries === 2 ? '' : hint, tail, tries === 2 ? '' : noteShort));
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`)
|| salvageChunkFields(rawOut);
if (j && echoedSum(j)) { console.log(`llm: chunk ${i + 1} echoed field description (try ${tries + 1})`); j = null; }
@@ -728,7 +763,7 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
let title = '';
const forTitle = sections.length >= 3 ? sections.slice(0, -1) : sections;
try {
const out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n')));
const out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n'), note));
const tj = extractJson(out);
title = (tj?.titel || tj?.title || '').toString().trim().slice(0, 120);
} catch (e) { console.log(`llm: title call failed: ${e.message}`); }
@@ -738,7 +773,7 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
// without it when the call fails or echoes.
let overview = '';
try {
const out = await ask(PROMPT_OVERVIEW(sections.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n')));
const out = await ask(PROMPT_OVERVIEW(sections.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n'), note));
const oj = extractJson(out) || {};
// Key is model-spelled: "ueberblick" as prompted, but also "überblick"/…
// — accept any long string value rather than losing the overview to a key
+22 -1
View File
@@ -319,7 +319,12 @@ async function analyzeRecording(id) {
// Remember which label each speaker had in the LLM input, so renaming a
// speaker later can patch the finished text (relabelAnalysis).
await db.setSpeakerLabels(id, labels).catch(() => {});
const a = await llm.analyze(text, { labeled, glossary });
// Kontextnotiz des Nutzers (Freitext, nach dem Stop getippt) geht als
// Hintergrundwissen mit. Bewusst JETZT frisch gelesen und nicht aus `rec`
// von oben: die Sprecher-Erkennung darüber braucht Minuten, der Nutzer tippt
// in dieser Zeit — was bis hierher gespeichert ist, zählt noch mit.
const note = await db.getContextNote(id).catch(() => '');
const a = await llm.analyze(text, { labeled, glossary, context: note });
// Participants = the ACOUSTICALLY diarized speakers (named where known),
// not the LLM's list — the model returned every name MENTIONED in the
// conversation as a "Teilnehmer" (11 names on a 3-person call, #69).
@@ -479,6 +484,22 @@ app.patch('/api/recordings/:id/title', wrap(async (req, res) => {
res.json({ ok: true, id, title: row.title, title_locked: !!row.title_locked });
}));
// Kontextnotiz speichern (leerer Text löscht sie). Der Rückgabewert `applies`
// sagt der GUI ehrlich, ob die Notiz die LAUFENDE Analyse noch erreicht — das
// entscheidet sich daran, wie weit die Verarbeitung ist:
// 'auto' = wird noch transkribiert; die Analyse liest die Notiz danach
// 'running' = Analyse läuft bereits, sie kann die Notiz verpasst haben
// 'reanalyze' = Analyse ist fertig/fehlgeschlagen → nur ein neuer Lauf nutzt sie
app.patch('/api/recordings/:id/context', wrap(async (req, res) => {
const id = +req.params.id;
const row = await db.setContextNote(id, (req.body && req.body.context_note) || '');
if (!row) return res.status(404).json({ error: 'not found' });
const applies = row.status === 'transcribing' ? 'auto'
: row.analysis_status === 'pending' ? 'running' : 'reanalyze';
res.json({ ok: true, id, context_note: row.context_note || '',
context_note_at: row.context_note_at, applies, max: db.CONTEXT_MAX });
}));
// --- 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.
+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');
+11
View File
@@ -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; }