LLM: Sektions-Pfad ab 5 Sprechern gegen Recency-Bias in Meetings
Whole-Pass versagte auf #74 (13-Sprecher-Weekly, 31k Zeichen): Summary enthielt fast nur die letzten Sprecher. analyze() routet labeled Transkripte mit >=5 Sprechern jetzt in analyzeLong; Sektions-Pfad liefert Themen-Überschriften, jede Person, Smalltalk-Dämpfung, Todos mit Verantwortlichem, Überblick-Mikro-Call, Cap 16000. Kap. 05 + Changelog aktualisiert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b33b555fa7
commit
432c4b14dd
+67
-12
@@ -334,6 +334,20 @@ const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full clean
|
||||
// Budget at 12288 ctx: 12288 − 1536 gen ≈ 10752 tok ≈ 35k chars; 32000 leaves
|
||||
// margin. Beyond that the section-conglomerate path below takes over.
|
||||
const WHOLE_MAX_CHARS = 32000;
|
||||
// Round-robin meetings (many speakers, each reporting their own topics) defeat
|
||||
// the whole-pass even below WHOLE_MAX: on #74 (33min standup, 13 diarized
|
||||
// speakers, 31k chars) the one-call summary kept only the LAST speakers'
|
||||
// topics — a 4B cannot weight 15+ equal topics from a 10k-token prompt.
|
||||
// Labeled transcripts with >= this many distinct speakers go through the
|
||||
// section-conglomerate path instead, which covers every part by construction.
|
||||
const MANY_SPEAKERS = 5;
|
||||
|
||||
// Count distinct "Name:"-style turn labels in a labeled dialogue.
|
||||
function countSpeakers(text) {
|
||||
const s = new Set();
|
||||
for (const m of text.matchAll(/^([^:\n]{1,40}):\s/gm)) s.add(m[1].trim());
|
||||
return s.size;
|
||||
}
|
||||
|
||||
// Split transcript into <=maxChars pieces on natural boundaries (labeled lines
|
||||
// first, else sentences, else hard char cut) so no chunk splits mid-word.
|
||||
@@ -378,9 +392,9 @@ Aktueller Abschnitt:` : ''}
|
||||
${transcript}
|
||||
"""
|
||||
|
||||
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen.${context ? ` Wenn der aktuelle Abschnitt eine frühere Aussage oder Entscheidung ÄNDERT, sage das ausdrücklich ("jetzt X, vorher Y").` : ''}
|
||||
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. Wenn mehrere Personen nacheinander berichten, fasse den BERUFLICHEN Beitrag JEDER Person zusammen — lasse NIEMANDEN weg; nutze den Namen der Person, wenn er aus dem Kontext hervorgeht (Vorstellung, Anrede), sonst das Sprecher-Label. Berufliches steht im Vordergrund: private Erlebnisse und Smalltalk (Essen, Familie, Auto, Urlaubserzählungen, Anekdoten) höchstens in einem halben Satz erwähnen oder ganz weglassen.${context ? ` Wenn der aktuelle Abschnitt eine frühere Aussage oder Entscheidung ÄNDERT, sage das ausdrücklich ("jetzt X, vorher Y").` : ''}
|
||||
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
|
||||
{"zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "eigennamen": ["jeder im Abschnitt vorkommende Personen- oder Firmenname, exakt wie im Text"], "teilnehmer": ["Name oder Rolle"], "todos": ["konkret zugesagte Aufgabe im Imperativ; Grußformeln sind KEINE Aufgabe"]}`;
|
||||
{"thema": "kurze Überschrift dieses Abschnitts, max. 6 Wörter", "zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "eigennamen": ["jeder im Abschnitt vorkommende Personen- oder Firmenname, exakt wie im Text"], "teilnehmer": ["Name oder Rolle"], "todos": ["nur beruflich relevante, konkret angewiesene oder fest zugesagte Aufgabe im Imperativ, dahinter in Klammern wer sie übernimmt (falls erkennbar); private Vorhaben, Freizeit und Grußformeln sind KEINE Aufgabe"]}`;
|
||||
|
||||
// Title micro-call over 2-sentence section Kurzfassungen (small input/output =
|
||||
// reliable regime). The LLM-reduce that used to build the whole summary is
|
||||
@@ -392,13 +406,28 @@ ${kurzfassungen}
|
||||
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": "..."}`;
|
||||
|
||||
// Overview micro-call over the section Kurzfassungen — same small-input/small-
|
||||
// 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) =>
|
||||
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
|
||||
${kurzfassungen}
|
||||
|
||||
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 = [] } = {}) {
|
||||
const raw = (transcript || '').trim();
|
||||
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
||||
const hint = correctionHint(glossary);
|
||||
if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint);
|
||||
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
|
||||
if (raw.length > WHOLE_MAX_CHARS || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
|
||||
if (manySpeakers) console.log(`llm: ${countSpeakers(raw)} speakers -> section path (round-robin meeting)`);
|
||||
return analyzeLong(raw, hint);
|
||||
}
|
||||
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.
|
||||
@@ -481,6 +510,7 @@ function salvageChunkFields(text) {
|
||||
const t = text.replace(/[“”„«»]/g, '"');
|
||||
const sum = (t.match(/"zusammenfassung"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || '';
|
||||
if (!sum) return null;
|
||||
const thema = (t.match(/"thema"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || '';
|
||||
let names = [];
|
||||
const nm = t.match(/"eigennamen"\s*:\s*\[([\s\S]*?)(\]|$)/);
|
||||
if (nm) names = [...nm[1].matchAll(/"((?:[^"\\]|\\.){2,60})"/g)].map(m => m[1])
|
||||
@@ -488,7 +518,7 @@ function salvageChunkFields(text) {
|
||||
let todos = [];
|
||||
const tm = t.match(/"todos"\s*:\s*\[([\s\S]*?)(\]|$)/);
|
||||
if (tm) todos = [...tm[1].matchAll(/"((?:[^"\\]|\\.){4,200})"/g)].map(m => m[1]);
|
||||
return { zusammenfassung: sum, eigennamen: names, todos };
|
||||
return { thema, zusammenfassung: sum, eigennamen: names, todos };
|
||||
}
|
||||
|
||||
// Chunked path for long transcripts (> WHOLE_MAX_CHARS). Design after the #70
|
||||
@@ -549,7 +579,8 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
&& !/^(Sprecher|Kandidat|Teilnehmer|Person|Firma|Mitarbeiter|Manager|jeder|alle|guten?)\b/i.test(s)
|
||||
&& !/\b(mit|von|der|die|das|nicht)\b.*\b(mit|von|der|die|das|nicht)\b/i.test(s)
|
||||
&& (s.match(/\s/g) || []).length <= 2);
|
||||
if (sum) sections.push({ sum, names });
|
||||
const thema = String(j.thema || '').trim().replace(/[.:]+\s*$/, '').slice(0, 60);
|
||||
if (sum) sections.push({ sum, names, thema });
|
||||
} else if (rawOut) {
|
||||
// Last resort: keep the prose so the section isn't lost (strip fences,
|
||||
// malformed {...} blobs and chatty English openers — the #69 leak).
|
||||
@@ -557,7 +588,7 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
.replace(/^\s*(Okay|Sure|Here'?s|Certainly)[^.\n]*[.:\n]/i, '').trim();
|
||||
// < 60 chars = leftover junk (a stray quote once became a whole "Teil"),
|
||||
// better to drop the section than to render garbage.
|
||||
if (prose.length >= 60) { sum = prose.slice(0, 1200); sections.push({ sum, names: [] }); }
|
||||
if (prose.length >= 60) { sum = prose.slice(0, 1200); sections.push({ sum, names: [], thema: '' }); }
|
||||
}
|
||||
if (sum) ctxLines.push(`${sections.length}. ${(sum.split(/(?<=[.!?])\s/)[0] || '').slice(0, 220)}`);
|
||||
console.log(`llm: chunk ${i + 1}/${chunks.length} done (sections so far: ${sections.length})`);
|
||||
@@ -572,22 +603,46 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
// Title micro-call on 2-sentence Kurzfassungen, LAST section excluded when
|
||||
// there are ≥3 (closing smalltalk must not name the call). Tiny input/output
|
||||
// = the regime where Gemma is reliable; graceful fallback to first sentence.
|
||||
// Kurzfassung per section (thema + first 2 sentences) — shared input for the
|
||||
// title and overview micro-calls.
|
||||
const brief = (s) => `${s.thema ? s.thema + ' — ' : ''}${s.sum.split('. ').slice(0, 2).join('. ').slice(0, 300)}`;
|
||||
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}. ${s.sum.split('. ').slice(0, 2).join('. ').slice(0, 300)}`).join('\n')));
|
||||
const out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n')));
|
||||
const tj = extractJson(out);
|
||||
title = (tj?.titel || tj?.title || '').toString().trim().slice(0, 120);
|
||||
} catch (e) { console.log(`llm: title call failed: ${e.message}`); }
|
||||
if (!title) title = (sections[0]?.sum || '').split(/[.!?\n]/)[0].trim().slice(0, 60) || 'Gespräch';
|
||||
|
||||
// Deterministic conglomerate: chronological, section-labelled, names listed.
|
||||
// No LLM re-weighting = no mis-weighting. GUI renders pre-line.
|
||||
// Overview micro-call (Copilot-style intro). Graceful: conglomerate works
|
||||
// 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 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
|
||||
// spelling mismatch.
|
||||
overview = (oj.ueberblick || oj['überblick'] || oj.uberblick || oj.overview
|
||||
|| Object.values(oj).find(v => typeof v === 'string' && v.trim().length > 40) || '')
|
||||
.toString().trim().slice(0, 800);
|
||||
// Echo check: only the literal instruction phrases, not legitimate wording.
|
||||
if (/Gewichte ALLE Abschnitte|2-3 deutschen Sätzen/.test(overview)) overview = '';
|
||||
console.log(`llm: overview ${overview ? `ok (${overview.length} chars)` : `EMPTY; raw tail: ${JSON.stringify(out.slice(-200))}`}`);
|
||||
} catch (e) { console.log(`llm: overview call failed: ${e.message}`); }
|
||||
|
||||
// Deterministic conglomerate: chronological, one block per section with its
|
||||
// LLM headline. No LLM re-weighting = no mis-weighting. GUI renders pre-line.
|
||||
const body = sections.map((s, k) =>
|
||||
`Teil ${k + 1}/${sections.length}: ${s.sum}${s.names.length ? `\n(Namen: ${s.names.join(', ')})` : ''}`
|
||||
`${s.thema || `Teil ${k + 1}`}:\n${s.sum}${s.names.length ? `\n(Namen: ${s.names.join(', ')})` : ''}`
|
||||
).join('\n\n');
|
||||
const allNames = uniqStr(sections.flatMap(s => s.names));
|
||||
const summary = `Gesprächsverlauf in ${sections.length} Teilen${allNames.length ? ` — genannte Namen: ${allNames.join(', ')}` : ''}\n\n${body}`;
|
||||
const summary = [overview, body, allNames.length ? `Genannte Namen: ${allNames.join(', ')}` : '']
|
||||
.filter(Boolean).join('\n\n');
|
||||
|
||||
return { title, summary: summary.slice(0, 6000), participants, todos, transcriptClean: '' };
|
||||
// 16000, not 6000: a 33-min 13-speaker standup produced ~7k chars of
|
||||
// sections — the old cap cut the last third mid-sentence (#74). Column is
|
||||
// TEXT (64KB), the GUI scrolls.
|
||||
return { title, summary: summary.slice(0, 16000), participants, todos, transcriptClean: '' };
|
||||
}
|
||||
|
||||
+32
-6
@@ -19,25 +19,51 @@
|
||||
flowchart TB
|
||||
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
|
||||
A -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
|
||||
A -- nein --> B{"≤ 32 000 Zeichen?"}
|
||||
A -- nein --> SPK{"labeled + ≥ 5 Sprecher?<br/>(MANY_SPEAKERS, countSpeakers)"}
|
||||
SPK -- "ja (Round-Robin-Meeting)" --> LONG
|
||||
SPK -- nein --> B{"≤ 32 000 Zeichen?"}
|
||||
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)<br/>Instruktion VOR und NACH dem Transkript"]
|
||||
WHOLE --> WCHECK{"JSON parsebar +<br/>Zusammenfassung ≥ 20 Zeichen?"}
|
||||
WCHECK -- "JSON kaputt" --> REPAIR["Repair-Call: NUR der kaputte<br/>Output (klein) → gültiges JSON"]
|
||||
REPAIR --> WCHECK
|
||||
WCHECK -- "weiterhin leer/Junk" --> LONG
|
||||
WCHECK -- ja --> OUT
|
||||
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 4 200 Zeichen<br/>(bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4k bleibt Gemma geerdet)"]
|
||||
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y'):<br/>Mini-JSON zusammenfassung + eigennamen[]<br/>(reine Namens-EXTRAKTION) + teilnehmer + todos<br/>bewusst minimaler Prompt"]
|
||||
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 4 200 Zeichen<br/>(bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4k bleibt das Modell geerdet);<br/>chunkText splittet an Zeilen = Turn-Grenzen"]
|
||||
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y'):<br/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern) — bewusst minimaler Prompt"]
|
||||
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
|
||||
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung-String überlebt fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
|
||||
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung/thema-Strings überleben fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
|
||||
RETRY --> COLLECT
|
||||
SALV -- ja --> COLLECT["Sektionen einsammeln"]
|
||||
COLLECT --> TITLE["TITEL-Mikro-Call: 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
|
||||
TITLE --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce mehr):<br/>'Gesprächsverlauf in n Teilen — genannte Namen: …'<br/>+ 'Teil i/n: Summary (Namen: …)' chronologisch<br/>→ deterministisch, keine Fehlgewichtung möglich"]
|
||||
COLLECT --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
|
||||
TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen<br/>→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet<br/>(Key-tolerant parsen: auch 'überblick' oder<br/>irgendein langer String-Value; leer = weglassen)"]
|
||||
OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):<br/>Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'<br/>chronologisch + 'Genannte Namen: …' am Ende<br/>→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"]
|
||||
ASSEMBLE --> OUT["Ergebnis"]
|
||||
SINGLE --> OUT
|
||||
```
|
||||
|
||||
### Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17)
|
||||
|
||||
Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER `WHOLE_MAX_CHARS`
|
||||
und lief als Whole-Pass — Ergebnis: Summary fast nur aus den LETZTEN Sprechern (Gasblender, Sitas,
|
||||
Zeiterfassung), alle frühen Wochenberichte fehlten. Ein 4B kann 15+ gleichgewichtige Themen aus einem
|
||||
~10k-Token-Prompt nicht ausbalancieren — der Whole-Pass funktioniert nur für narrative Gespräche mit
|
||||
wenigen Sprechern (dort validiert, #70/#71). Deshalb routet `analyze()` **labeled Transkripte mit
|
||||
≥ 5 Sprechern (`MANY_SPEAKERS`) immer in den Sektions-Pfad**, der jede Gesprächsphase per Konstruktion
|
||||
abdeckt. Der Sektions-Pfad wurde dabei auf Copilot-Niveau ausgebaut (3 Iterationen auf #74):
|
||||
|
||||
1. **Themen-Überschrift pro Chunk** (`thema`-Feld) → Konglomerat liest sich wie Besprechungsnotizen
|
||||
(„Performanceproblem bei SAP-System:" statt „Teil 2/10:").
|
||||
2. **Jede berichtende Person MUSS vorkommen** (Prompt-Regel) — vorher fielen einzelne Sprecher weg.
|
||||
3. **Smalltalk-Dämpfung**: Privates (Essen, Familie, Auto, Urlaubserzählungen) max. ein Halbsatz;
|
||||
Todos nur beruflich, mit Verantwortlichem in Klammern (37 → 30 saubere Todos auf #74).
|
||||
4. **Überblick-Mikro-Call** (`PROMPT_OVERVIEW`) liefert den Intro-Absatz; gleiche kleine
|
||||
Input/Output-Klasse wie der Titel-Call. GOTCHA: Qwen antwortet teils mit Key `"überblick"`
|
||||
(ü!) statt `"ueberblick"` — Parser akzeptiert jeden langen String-Value.
|
||||
5. Summary-Cap 6 000 → **16 000** Zeichen: 10 Sektionen ≈ 8k, der alte Cap schnitt mitten im Satz ab.
|
||||
|
||||
Validiert auf #74: alle 13 Sprecher + alle Themen (DWP-Bank, Diakonie, SAP-GUI, Sitas, Zeiterfassung,
|
||||
Wellpass, LinkedIn, Polavis, Gasblender) abgedeckt, kein Recency-Bias mehr. Backup `llm.js.bak-standup`.
|
||||
|
||||
### Warum kein LLM-Reduce mehr (Prompt-Lab 2026-07-16)
|
||||
|
||||
Der alte Reduce (Teil-Summaries → LLM baut EINE Gesamt-Summary) war die Fehlgewichtungs-Quelle. Im
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
Was, Warum, betroffene Dateien/Backups).
|
||||
|
||||
## 2026-07-17 — Round-Robin-Meetings: Sektions-Pfad statt Whole-Pass ab 5 Sprechern (Recency-Bias-Fix)
|
||||
|
||||
- **Problem:** #74 (33-min-Weekly, 13 Sprecher, 31k Zeichen) lief knapp unter `WHOLE_MAX_CHARS` als
|
||||
Whole-Pass → Summary enthielt fast nur die letzten Sprecher. 4B kann 15+ gleichwertige Themen in
|
||||
einem ~10k-Token-Prompt nicht gewichten; Whole-Pass taugt nur für narrative Gespräche mit wenigen
|
||||
Sprechern.
|
||||
- **Fix (llm.js, `.bak-standup`):** `analyze()` routet labeled Transkripte mit ≥ 5 Sprechern
|
||||
(`MANY_SPEAKERS`, `countSpeakers()`) immer in `analyzeLong` (Sektions-Konglomerat). Sektions-Pfad
|
||||
auf Besprechungsnotizen-Niveau ausgebaut: `thema`-Überschrift pro Chunk, Pflicht zur Erwähnung
|
||||
JEDER berichtenden Person, Smalltalk-Dämpfung (Privates max. Halbsatz), Todos nur beruflich mit
|
||||
Verantwortlichem in Klammern, Überblick-Mikro-Call (`PROMPT_OVERVIEW`, Key-tolerant: Qwen schreibt
|
||||
teils `"überblick"`), Summary-Cap 6 000 → 16 000 (alter Cap schnitt 10 Sektionen mitten im Satz ab).
|
||||
- **Validierung:** 3 Iterationen per `reanalyze-llm` auf #74 — final alle 13 Sprecher + alle Themen
|
||||
abgedeckt (Intro-Absatz, 10 Themen-Sektionen, 30 saubere Todos mit Verantwortlichen), kein
|
||||
Recency-Bias. Temporärer `/api/debug/ask` fürs Overview-Prompt-Tuning wieder entfernt
|
||||
(server.js unverändert).
|
||||
|
||||
## 2026-07-17 — GUI: Aufnahme-Tab gestrichen, Record-Button in der Kopfzeile + Stille-MP3-Fix
|
||||
|
||||
- Der Reiter "Aufnahme" ist weg: Quelle (CallRecMix), HFP-Codec (auto) und Sprecherzahl (auto) waren
|
||||
|
||||
Reference in New Issue
Block a user