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 deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS title_locked TINYINT(1) NOT NULL DEFAULT 0,
|
ADD COLUMN IF NOT EXISTS title_locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
ADD COLUMN IF NOT EXISTS doc_vec MEDIUMTEXT NULL`);
|
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 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
|
// 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
|
// 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;
|
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) {
|
function parseJsonCols(row) {
|
||||||
for (const c of ['todos', 'participants']) {
|
for (const c of ['todos', 'participants']) {
|
||||||
if (row && typeof row[c] === 'string') {
|
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.
|
// Combine the base correction rule with the per-recording glossary.
|
||||||
const correctionHint = (glossary) => CORRECTION_HINT + glossaryHint(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
|
// 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
|
// 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"
|
// 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.`;
|
`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.
|
// Runs on the ALREADY-CORRECTED transcript, so every field is consistent.
|
||||||
const PROMPT_JSON = (transcript) =>
|
const PROMPT_JSON = (transcript, ctx = '') =>
|
||||||
`Du analysierst das Transkript eines deutschen Gesprächs.
|
`Du analysierst das Transkript eines deutschen Gesprächs.${ctx}
|
||||||
|
|
||||||
Transkript:
|
Transkript:
|
||||||
"""
|
"""
|
||||||
@@ -239,8 +263,8 @@ ${NO_INVENT}`;
|
|||||||
// transcript, reads through ASR errors via `hint`, and returns one rich JSON
|
// transcript, reads through ASR errors via `hint`, and returns one rich JSON
|
||||||
// analysis. Richer summary than the short/chunk prompts (the whole arc is
|
// analysis. Richer summary than the short/chunk prompts (the whole arc is
|
||||||
// visible) but still analysis-only → small output.
|
// visible) but still analysis-only → small output.
|
||||||
const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
|
const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT, ctx = '') =>
|
||||||
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}
|
`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:
|
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"]}
|
{"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
|
// Corrects ASR errors AND splits the single mixed mono transcript into a
|
||||||
// speaker-labelled dialogue by CONTEXT (this is text inference, not acoustic
|
// speaker-labelled dialogue by CONTEXT (this is text inference, not acoustic
|
||||||
// voice recognition). Output is plain "Name: Text" lines — robust to parse.
|
// voice recognition). Output is plain "Name: Text" lines — robust to parse.
|
||||||
const PROMPT_CLEAN = (transcript, hint = CORRECTION_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}
|
`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.
|
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),
|
// 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.
|
// the LLM must NOT re-guess speakers — only fix ASR errors and keep the labels.
|
||||||
const PROMPT_CLEAN_LABELED = (transcript, hint = CORRECTION_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}
|
`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.
|
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
|
// 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
|
// that survived every prompt wording (wrong in 4 of 7 runs). The tail is marked
|
||||||
// as context-only so it is not summarized twice.
|
// as context-only so it is not summarized twice.
|
||||||
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '') =>
|
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '', ctx = '') =>
|
||||||
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
|
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${ctx}${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
|
||||||
${context}` : ''}${tail ? `
|
${context}` : ''}${tail ? `
|
||||||
|
|
||||||
Die letzten Sätze des VORIGEN Abschnitts, nur damit du weißt, worauf sich der Anfang bezieht — NICHT zusammenfassen:
|
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 =
|
// Title micro-call over 2-sentence section Kurzfassungen (small input/output =
|
||||||
// reliable regime). The LLM-reduce that used to build the whole summary is
|
// reliable regime). The LLM-reduce that used to build the whole summary is
|
||||||
// gone: cross-section synthesis is beyond the 4B (see analyzeLong header).
|
// 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 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.
|
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": "..."}`;
|
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
|
// output regime as the title call. Gives the conglomerate a Copilot-style
|
||||||
// intro paragraph; equal weighting is enforced by the input (every section
|
// intro paragraph; equal weighting is enforced by the input (every section
|
||||||
// contributes the same 1-2 sentences).
|
// contributes the same 1-2 sentences).
|
||||||
const PROMPT_OVERVIEW = (kurzfassungen) =>
|
const PROMPT_OVERVIEW = (kurzfassungen, ctx = '') =>
|
||||||
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
|
`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.
|
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": "..."}`;
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"ueberblick": "..."}`;
|
||||||
|
|
||||||
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
|
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
|
||||||
// `labeled` = the text already carries acoustic "Sprecher X:" turn labels.
|
// `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();
|
const raw = (transcript || '').trim();
|
||||||
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
||||||
const hint = correctionHint(glossary);
|
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 manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
|
||||||
const longMeeting = raw.length > SECTION_MIN_CHARS;
|
const longMeeting = raw.length > SECTION_MIN_CHARS;
|
||||||
if (raw.length > WHOLE_MAX_CHARS || longMeeting || (manySpeakers && raw.length > SINGLE_MAX_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` +
|
console.log(`llm: ${raw.length} chars / ${countSpeakers(raw)} speakers -> section path` +
|
||||||
(longMeeting ? ` (>${SECTION_MIN_CHARS} chars)` : ' (round-robin meeting)'));
|
(longMeeting ? ` (>${SECTION_MIN_CHARS} chars)` : ' (round-robin meeting)'));
|
||||||
return analyzeLong(raw, hint);
|
return analyzeLong(raw, hint, ctx, ctxShort);
|
||||||
}
|
}
|
||||||
if (raw.length > SINGLE_MAX_CHARS) {
|
if (raw.length > SINGLE_MAX_CHARS) {
|
||||||
// Whole-pass gives the coherent summary; if Gemma's JSON breaks even after
|
// 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.
|
// the repair call, degrade to chunked map-reduce instead of failing hard.
|
||||||
try { return await analyzeWhole(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); }
|
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
|
// 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),
|
// chunk boundaries → incoherent long summaries). Analysis-ONLY (small JSON out),
|
||||||
// no full-dialogue rewrite → decode stays short. transcriptClean left empty on
|
// no full-dialogue rewrite → decode stays short. transcriptClean left empty on
|
||||||
// purpose: the detail view renders diarized turns from `words`.
|
// purpose: the detail view renders diarized turns from `words`.
|
||||||
async function analyzeWhole(raw, hint = CORRECTION_HINT) {
|
async function analyzeWhole(raw, hint = CORRECTION_HINT, ctx = '') {
|
||||||
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint));
|
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint, ctx));
|
||||||
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||||
if (!j) {
|
if (!j) {
|
||||||
// Gemma occasionally breaks the JSON on very long inputs (leaked prose,
|
// 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
|
// Single-call path (short transcripts): one combined NPU call = corrected
|
||||||
// dialogue + JSON analysis. PROMPT_JSON alone is the fallback if it doesn't parse.
|
// 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 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);
|
const parts = splitCombined(out);
|
||||||
let transcriptClean = parts.clean
|
let transcriptClean = parts.clean
|
||||||
.replace(/^```.*$/gm, '')
|
.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, '')}}`);
|
let j = extractJson(parts.json) || extractJson(`{${parts.json.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||||
if (echoed(j)) {
|
if (echoed(j)) {
|
||||||
console.log(`llm: combined JSON ${j ? 'echoed field descriptions' : 'unparseable'} -> separate JSON call; tail: ${JSON.stringify(out.slice(-250))}`);
|
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');
|
if (!j) throw new Error('LLM lieferte kein gültiges JSON');
|
||||||
return {
|
return {
|
||||||
@@ -630,7 +659,10 @@ function salvageChunkFields(text) {
|
|||||||
// from a micro-call over 2-sentence Kurzfassungen (last section excluded:
|
// from a micro-call over 2-sentence Kurzfassungen (last section excluded:
|
||||||
// end-of-call smalltalk heuristic). transcriptClean stays empty on purpose
|
// end-of-call smalltalk heuristic). transcriptClean stays empty on purpose
|
||||||
// (detail view renders diarized turns from `words`).
|
// (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);
|
const chunks = chunkText(raw, CHUNK_CHARS);
|
||||||
console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only, rolling context)`);
|
console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only, rolling context)`);
|
||||||
const sections = []; // { sum, names[] }
|
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 verbatim tail stays on every try — it is grounding, not context
|
||||||
// the model can get chatty about.
|
// the model can get chatty about.
|
||||||
const tail = i > 0 ? chunks[i - 1].slice(-TAIL_CHARS) : '';
|
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, '')}}`)
|
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`)
|
||||||
|| salvageChunkFields(rawOut);
|
|| salvageChunkFields(rawOut);
|
||||||
if (j && echoedSum(j)) { console.log(`llm: chunk ${i + 1} echoed field description (try ${tries + 1})`); j = null; }
|
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 = '';
|
let title = '';
|
||||||
const forTitle = sections.length >= 3 ? sections.slice(0, -1) : sections;
|
const forTitle = sections.length >= 3 ? sections.slice(0, -1) : sections;
|
||||||
try {
|
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);
|
const tj = extractJson(out);
|
||||||
title = (tj?.titel || tj?.title || '').toString().trim().slice(0, 120);
|
title = (tj?.titel || tj?.title || '').toString().trim().slice(0, 120);
|
||||||
} catch (e) { console.log(`llm: title call failed: ${e.message}`); }
|
} 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.
|
// without it when the call fails or echoes.
|
||||||
let overview = '';
|
let overview = '';
|
||||||
try {
|
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) || {};
|
const oj = extractJson(out) || {};
|
||||||
// Key is model-spelled: "ueberblick" as prompted, but also "überblick"/…
|
// Key is model-spelled: "ueberblick" as prompted, but also "überblick"/…
|
||||||
// — accept any long string value rather than losing the overview to a key
|
// — 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
|
// Remember which label each speaker had in the LLM input, so renaming a
|
||||||
// speaker later can patch the finished text (relabelAnalysis).
|
// speaker later can patch the finished text (relabelAnalysis).
|
||||||
await db.setSpeakerLabels(id, labels).catch(() => {});
|
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),
|
// Participants = the ACOUSTICALLY diarized speakers (named where known),
|
||||||
// not the LLM's list — the model returned every name MENTIONED in the
|
// 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).
|
// 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 });
|
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 -------------------------------------------------------------------
|
// --- Trash -------------------------------------------------------------------
|
||||||
// Delete = move to the bin. Nothing on disk is touched until the retention
|
// Delete = move to the bin. Nothing on disk is touched until the retention
|
||||||
// window expires, so a restore brings back audio, transcript and analysis.
|
// 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,
|
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false,
|
||||||
// Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und
|
// Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und
|
||||||
// welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters).
|
// 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 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); }
|
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', {});
|
const r = await api.post('/api/record/stop', {});
|
||||||
state.recording = false;
|
state.recording = false;
|
||||||
toast('Aufnahme gespeichert – wird transkribiert…');
|
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.tab = 'recordings'; render();
|
||||||
state.lastListSig = null; refreshRecList();
|
state.lastListSig = null; refreshRecList();
|
||||||
openRecording(r.id);
|
openRecording(r.id);
|
||||||
@@ -1222,6 +1232,7 @@ async function openRecording(id) {
|
|||||||
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
|
${(!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>` : ''}
|
${spkOrder.length ? `<button class="btn small icon" id="viewswitch" title="Ansicht wechseln: Konferenztisch / Transkript"></button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<section class="ctxbox" id="ctxbox"></section>
|
||||||
<div class="detail-cols">
|
<div class="detail-cols">
|
||||||
<div class="mod-col" data-col="main">${layout.main.map(modHtml).join('')}</div>
|
<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>
|
<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);
|
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
|
||||||
markEmpty();
|
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');
|
const renBtn = detail.querySelector('#renametitle');
|
||||||
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
|
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
|
||||||
const reBtn = detail.querySelector('#reanalyze');
|
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:hover { border-color: var(--accent); }
|
||||||
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
|
.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 ---- */
|
/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */
|
||||||
.status-line { margin: 4px 0 10px; }
|
.status-line { margin: 4px 0 10px; }
|
||||||
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
|
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ sequenceDiagram
|
|||||||
REC->>DB: WAV + MP3 + Zeile (status=transcribing)
|
REC->>DB: WAV + MP3 + Zeile (status=transcribing)
|
||||||
SRV->>DB: addMarkers() → Tabelle markers
|
SRV->>DB: addMarkers() → Tabelle markers
|
||||||
Note over REC: kehrt nach ~1,5 s zurück — GUI frei
|
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->>REC: A76-Takt kappen (1,6 GHz)
|
||||||
REC->>WH: transcribeWav() [BatchedInferencePipeline, batch=8]
|
REC->>WH: transcribeWav() [BatchedInferencePipeline, batch=8]
|
||||||
WH-->>REC: Wörter + Zeitstempel
|
WH-->>REC: Wörter + Zeitstempel
|
||||||
@@ -32,6 +36,7 @@ sequenceDiagram
|
|||||||
SRV->>SRV: applyMarkers(): Cluster benennen + Stimmen lernen
|
SRV->>SRV: applyMarkers(): Cluster benennen + Stimmen lernen
|
||||||
REC->>REC: finally: Takt zurück
|
REC->>REC: finally: Takt zurück
|
||||||
SRV->>LLM: analyzeRecording() (fire-and-forget, analysis_status=pending)
|
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)
|
LLM->>DB: Titel/Summary/Teilnehmer/Todos (analysis_status=done, analysis_ms)
|
||||||
GUI->>SRV: Polling analysis_status
|
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/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` `{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 |
|
| `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 |
|
| `POST /api/recordings/:id/speaker` `{speaker,name,enroll}` | Sprecher benennen + optional Stimme lernen |
|
||||||
| `/api/glossary` (CRUD) | Eigennamen-Glossar |
|
| `/api/glossary` (CRUD) | Eigennamen-Glossar |
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,57 @@ flowchart LR
|
|||||||
Wichtig: Begriffslisten **niemals** als Whisper-`--initial-prompt` (Echo-Loop, verifiziert) und dem 3–4B-Modell
|
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.
|
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
|
## Zusammenspiel mit Whisper auf der NPU
|
||||||
|
|
||||||
rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich
|
rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich
|
||||||
|
|||||||
@@ -3,6 +3,67 @@
|
|||||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||||
Was, Warum, betroffene Dateien/Backups).
|
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
|
## 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
|
**Wunsch:** eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von
|
||||||
|
|||||||
Reference in New Issue
Block a user