diff --git a/app/db.js b/app/db.js index deb0fb4..1fc8ee4 100644 --- a/app/db.js +++ b/app/db.js @@ -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') { diff --git a/app/llm.js b/app/llm.js index 9b2f2f3..cc1b434 100644 --- a/app/llm.js +++ b/app/llm.js @@ -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 diff --git a/app/server.js b/app/server.js index 62d9e83..ae3b7bb 100644 --- a/app/server.js +++ b/app/server.js @@ -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. diff --git a/app/web/src/main.js b/app/web/src/main.js index 876b86b..54930a0 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -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) ? `` : ''} ${spkOrder.length ? `` : ''} +