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:
@@ -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
@@ -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
@@ -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
@@ -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