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:
+63
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user