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; }
+6
View File
@@ -22,6 +22,10 @@ sequenceDiagram
REC->>DB: WAV + MP3 + Zeile (status=transcribing)
SRV->>DB: addMarkers() → Tabelle markers
Note over REC: kehrt nach ~1,5 s zurück — GUI frei
GUI->>GUI: Kontextbox klappt auf (Freitext, seit 2026-08-17)
GUI->>SRV: PATCH /api/recordings/:id/context {context_note}
SRV->>DB: setContextNote() → recordings.context_note
Note over GUI,DB: Die Transkription läuft noch Minuten —<br/>der getippte Kontext ist rechtzeitig da (Kap. 05)
REC->>REC: A76-Takt kappen (1,6 GHz)
REC->>WH: transcribeWav() [BatchedInferencePipeline, batch=8]
WH-->>REC: Wörter + Zeitstempel
@@ -32,6 +36,7 @@ sequenceDiagram
SRV->>SRV: applyMarkers(): Cluster benennen + Stimmen lernen
REC->>REC: finally: Takt zurück
SRV->>LLM: analyzeRecording() (fire-and-forget, analysis_status=pending)
LLM->>DB: getContextNote() — Notiz erst JETZT gelesen (Kap. 05)
LLM->>DB: Titel/Summary/Teilnehmer/Todos (analysis_status=done, analysis_ms)
GUI->>SRV: Polling analysis_status
```
@@ -153,6 +158,7 @@ Datenverlust, aber auch keinen Weg mehr, die Aufnahme aus der GUI zu beenden. No
| `POST /api/recordings/:id/retranscribe` | Hängende/fehlgeschlagene Transkription neu starten |
| `POST /api/recordings/:id/reanalyze` `{numSpeakers?}` | Diarization **immer** neu (leer/0 = automatisch, Zahl = Anzahl gepinnt) + LLM neu |
| `POST /api/recordings/:id/reanalyze-llm` | Nur LLM neu, Diarization/Wörter bleiben |
| `PATCH /api/recordings/:id/context` `{context_note}` | Kontextnotiz speichern (leer = löschen); Antwort `applies` = `auto` / `running` / `reanalyze` sagt, ob die laufende Verarbeitung sie noch erreicht |
| `POST /api/recordings/:id/speaker` `{speaker,name,enroll}` | Sprecher benennen + optional Stimme lernen |
| `/api/glossary` (CRUD) | Eigennamen-Glossar |
+51
View File
@@ -239,6 +239,57 @@ flowchart LR
Wichtig: Begriffslisten **niemals** als Whisper-`--initial-prompt` (Echo-Loop, verifiziert) und dem 3–4B-Modell
keine „ersetze X durch Y“-Anweisung anvertrauen (unzuverlässig) — deshalb die deterministische Text-Ersetzung.
## Kontextnotiz des Nutzers (seit 2026-08-17)
Nach dem Stop bietet die Detailansicht **ein Freitextfeld** an: „Kontext zum Gespräch“. Was dort steht,
geht als **Hintergrundwissen** in die Analyse — nicht als Gesprächsinhalt. Damit weiß das Modell, wer
dabei war, worum es ging und wie Eigennamen geschrieben werden, statt das alles aus fehlerbehaftetem
ASR-Text zu erraten.
**Bewusst EIN Feld, kein Formular** (Entscheidung des Nutzers): die Box benutzen später auch technisch
nicht versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als Teilnehmer-, Anlass- und
Begriffsfelder auszufüllen. Die Struktur zieht sich die Analyse selbst aus der Prosa. Geführt wird über
den **Platzhaltertext** („Wer war dabei, worum ging es, wichtige Namen oder Begriffe? … z. B.: Call mit
Herrn Meier von Firma Krämer wegen Angebot Halle 3 …“) statt über Pflichtfelder.
```mermaid
flowchart TB
STOP["Stop gedrückt"] --> BOX["Kontextbox klappt AUF und ist fokussiert<br/>(state.ctxPromptFor, main.js)"]
BOX --> SAVE["PATCH /api/recordings/:id/context<br/>db.setContextNote → recordings.context_note<br/>(Deckel 4 000 Zeichen, leer = löschen)"]
SAVE --> WHEN{"Wie weit ist die<br/>Verarbeitung?"}
WHEN -- "status=transcribing" --> AUTO["applies='auto':<br/>Analyse liest die Notiz danach → fließt sicher ein"]
WHEN -- "analysis_status=pending" --> RUN["applies='running':<br/>Analyse läuft schon, kann die Notiz verpasst haben<br/>→ GUI verweist auf '↻ Analyse neu'"]
WHEN -- "done / error" --> RE["applies='reanalyze':<br/>GUI fragt nach und startet reanalyze-llm<br/>(Sprecher bleiben, nur LLM neu)"]
AUTO --> READ
RE --> READ
READ["analyzeRecording: db.getContextNote(id)<br/>ERST kurz vor dem LLM-Aufruf gelesen<br/>(Sprecher-Erkennung davor dauert Minuten —<br/>was der Nutzer in der Zeit tippt, zählt noch)"] --> INJ["llm.analyze({context}) → contextBlock()"]
INJ --> P1["analyzeSingle: PROMPT_CLEAN(_LABELED) + PROMPT_JSON"]
INJ --> P2["analyzeWhole: PROMPT_WHOLE_ANALYZE"]
INJ --> P3["analyzeLong: JEDER Chunk-Prompt (gekürzt)<br/>+ Titel- und Überblick-Mikro-Call"]
```
**Halluzinationsbremse.** Die Notiz steht in einem eigenen, mit `"""` abgegrenzten Block mit der
ausdrücklichen Auflage: nur für Schreibweisen, Zuordnung, Anlass und Gewichtung nutzen, **keine**
Aussagen, Ergebnisse, Entscheidungen oder Aufgaben daraus übernehmen, Notiz nicht erwähnen. Ohne diese
Klammer schreibt ein 4B-Modell die Notiz als Gesagtes in die Zusammenfassung zurück — dieselbe Falle wie
beim `CORRECTION_HINT` (siehe dessen STRENG-Satz).
**Zwei Deckel**, weil die Notiz je Pfad unterschiedlich oft im Prompt landet:
| Konstante | Wert | Gilt für |
|---|---|---|
| `db.CONTEXT_MAX` | 4 000 Zeichen | Speicherung (Spalte ist TEXT) |
| `LLM_CONTEXT_CHARS` | 1 200 Zeichen | Prompts, die die Notiz **einmal** sehen (Single/Whole/Titel/Überblick) |
| `LLM_CONTEXT_CHUNK_CHARS` | 600 Zeichen | Chunk-Prompts des Sektions-Pfads — dort geht die Notiz in **jeden** Chunk (`CHUNK_CHARS` = 3 200, eine lange Notiz wäre ein Drittel Ballast) |
Im Sektions-Pfad fällt die Notiz auf dem **letzten** von drei Versuchen zusammen mit dem `hint` weg: hat
das Modell zweimal die Feldbeschreibung nachgeplappert, ist jeder Zusatztext vor dem Abschnitt verdächtig.
**Grenze:** die Notiz verbessert Titel, Zusammenfassung, Teilnehmer und ToDos. Das **Transkript selbst**
verbessert sie nicht — Whisper ist zu dem Zeitpunkt schon gelaufen, und Begriffslisten dürfen ohnehin
nicht als `--initial-prompt` an Whisper (Echo-Loop, siehe Glossar-Abschnitt). Wer Schreibweisen dauerhaft
korrigiert haben will, nimmt das Glossar; die Notiz gilt nur für dieses eine Gespräch.
## Zusammenspiel mit Whisper auf der NPU
rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich
+61
View File
@@ -3,6 +3,67 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-08-17 — Kontext zum Gespräch: ein Freitextfeld nach dem Stop
**Wunsch:** nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten
Call/Meeting zu ergänzen — das verbessert die Analyse nach dem Transkript erheblich, weil das Modell
Personen, Firmen, Anlass und Eigennamen nicht mehr aus fehlerbehaftetem ASR-Text erraten muss.
**Entscheidung des Nutzers: EIN Freitextfeld, kein Formular.** Ein Vorschlag mit Teilnehmer-, Anlass-,
Projekt- und Begriffsfeldern wurde ausdrücklich verworfen: die Box benutzen später auch technisch nicht
versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als vier Pflichtfelder. Geführt wird
über den Platzhaltertext, die Struktur (Namen, Begriffe) zieht sich die Analyse selbst aus der Prosa.
**Umsetzung:**
- Neue Spalten `recordings.context_note` / `context_note_at` (`ensureSchema`), `db.setContextNote()`
(Deckel 4 000 Zeichen, leerer Text löscht) und `db.getContextNote()`.
- `PATCH /api/recordings/:id/context`. Die Antwort enthält `applies` und sagt damit **ehrlich**, ob die
Notiz die laufende Verarbeitung noch erreicht: `auto` (wird noch transkribiert → fließt sicher ein),
`running` (Analyse läuft schon, kann sie verpasst haben), `reanalyze` (fertig → nur ein neuer Lauf
nutzt sie; die GUI fragt dann nach und startet `reanalyze-llm`).
- `analyzeRecording()` liest die Notiz **erst kurz vor dem LLM-Aufruf** (`db.getContextNote`), nicht aus
der Zeile vom Anfang: Sprecher-Erkennung und Transkription davor brauchen Minuten, und was der Nutzer
in dieser Zeit tippt, soll noch mitzählen.
- `llm.js`: neuer `contextBlock()` — die Notiz kommt in einem eigenen `"""`-Block mit der Auflage, sie
**nur** für Schreibweisen, Zuordnung, Anlass und Gewichtung zu nutzen und daraus keine Aussagen,
Ergebnisse oder Aufgaben abzuleiten. Eingespeist in alle Pfade: `analyzeSingle` (`PROMPT_CLEAN`,
`PROMPT_CLEAN_LABELED`, `PROMPT_JSON`), `analyzeWhole` (`PROMPT_WHOLE_ANALYZE`) und `analyzeLong`
(jeder Chunk-Prompt + Titel- und Überblick-Mikro-Call). Zwei Deckel: 1 200 Zeichen für die Prompts, die
die Notiz einmal sehen, 600 für die Chunk-Prompts (dort landet sie in **jedem** Chunk von 3 200
Zeichen). Auf dem letzten von drei Chunk-Versuchen fällt sie mit dem `hint` weg.
- GUI: Karte „📝 Kontext zum Gespräch“ über den Modulen der Detailansicht. Nach dem Stop steht sie offen
und fokussiert (`state.ctxPromptFor`), sonst eingeklappt mit dem gespeicherten Text und
„✏️ Bearbeiten“. Sie zeichnet sich **selbst** neu statt über `openRecording()` — ein Neuaufbau der
Detailansicht setzt den `<audio>`-Tag zurück und würde das Abspielen unterbrechen. Getippter,
ungespeicherter Text überlebt den Neuaufbau nach Ende der Analyse (`state.ctxDrafts`). Strg+Enter
speichert, Enter macht Absätze.
**Verifiziert** an der laufenden App: Endpunkt liefert `applies:"reanalyze"` bei fertiger Analyse und
`applies:"auto"` während einer laufenden Transkription (8-s-Wegwerfaufnahme #115, danach in den
Papierkorb). Echter LLM-Lauf auf #97 (37 s, Single-Pfad) mit einer Testnotiz: Log zeigt
`llm: Kontextnotiz des Nutzers wird mitgegeben (196 Zeichen)`, die Zusammenfassung wurde messbar besser
(„Stimmerkennung“ + korrekte Namen Sascha/Fabio statt des ASR-Fehlers „Audioaufnahme … Lichter-Modus“ mit
falscher Sprecherzuordnung) — und die absichtlich in die Notiz gelegten Fremdfakten („Rückruf ist für
Freitag geplant“, „Halle 3 wurde verkauft“) tauchten **nicht** in Zusammenfassung oder ToDos auf, die
Halluzinationsbremse hält also. Die alte Analyse von #97 war vor dem Test gesichert und wurde danach
zurückgeschrieben, die Testnotiz entfernt. `vite build` sauber, `systemctl restart callrec` erst nach
Prüfung auf `analysis_status='pending'`.
**Offene Grenze:** die Notiz verbessert Titel/Zusammenfassung/Teilnehmer/ToDos, nicht das Transkript —
Whisper ist zu dem Zeitpunkt schon gelaufen. Dauerhaft falsche Schreibweisen gehören ins Glossar.
Ebenfalls noch nicht: die Notiz im Suchindex (`passages`) und ein Hinweis „kein Kontext“ in der
Aufnahmenliste.
Dateien: `app/db.js` (`ensureSchema`, `setContextNote`, `getContextNote`, `CONTEXT_MAX`), `app/server.js`
(`PATCH …/context`, `analyzeRecording`), `app/llm.js` (`contextBlock`, `CONTEXT_MAX_CHARS`,
`CONTEXT_CHUNK_CHARS`, alle Prompt-Bausteine + `analyze/analyzeSingle/analyzeWhole/analyzeLong`),
`app/web/src/main.js` (Kontextkarte, `state.ctxPromptFor`/`ctxDrafts`, `toggleRecord`),
`app/web/src/style.css` (`.ctxbox`, `.ctx-*`).
Board-Backups: `.bak-ctxnote` (db.js, server.js, llm.js, web/src/main.js, web/src/style.css).
Kapitel 05 (neuer Abschnitt „Kontextnotiz des Nutzers" + Flussdiagramm + Deckel-Tabelle), Kapitel 03
(Sequenzdiagramm + API-Tabelle).
## 2026-08-14 — Angeheftete Aufnahmen per Drag & Drop zwischen Unterreitern verschieben
**Wunsch:** eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von