#70-Debug: Diar Weak-Fold (7->4 Sprecher), LLM-Robustheit + Whole-Pass-Modellgrenze, Thermal-Cap fuer analyzeRecording
- diar.js: Stufe-3 Weak-Fold (DIAR_MIN_TOTAL_SEC 20s) gegen Phantom-Sprecher - llm.js: End-Instruktion, JSON-Repair, Sanity+Fallback, Chunk-Retry, Quote-Sanitizer, gelabelter/gewichteter Reduce; WHOLE_MAX 20000 (Gemma-4B-Grenze) - rkllm_server.cpp: max_context_len 8192->12288 - server.js: analyzeRecording unter A76-Takt-Cap (95-97 Grad ungedeckelt) - Doku Kap. 03/04/05 + Changelog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9e1eb82c4
commit
2f00ddeec6
+29
@@ -22,6 +22,15 @@ const MIN_REP_SEC = 0.4; // skip ultra-short cuts for embedding when a longer
|
|||||||
// less than this much total speech is folded into the temporally-nearest real
|
// less than this much total speech is folded into the temporally-nearest real
|
||||||
// speaker. A genuine join/leave participant speaks well over this.
|
// speaker. A genuine join/leave participant speaks well over this.
|
||||||
const MIN_SPEAKER_SEC = +(process.env.DIAR_MIN_SPEAKER_SEC || 3);
|
const MIN_SPEAKER_SEC = +(process.env.DIAR_MIN_SPEAKER_SEC || 3);
|
||||||
|
// Stage 3: a single >=MIN_SPEAKER_SEC stray cluster can still OPEN an anchor
|
||||||
|
// when its (short, unreliable) embedding lands below MERGE_THRESHOLD to every
|
||||||
|
// real speaker (#70: 3 phantom speakers with 2.5-4.8s total talk next to real
|
||||||
|
// ones with 53-736s). Total talk time over the whole call separates real
|
||||||
|
// participants from such noise: any FINAL speaker below this is folded into
|
||||||
|
// the acoustically nearest strong speaker. NOTE: measured on SEGMENT times
|
||||||
|
// (incl. intra-segment pauses), which run ~20-40% above summed word times —
|
||||||
|
// 10s let a 9.2s-word-time phantom through on #70, hence 20.
|
||||||
|
const MIN_TOTAL_SEC = +(process.env.DIAR_MIN_TOTAL_SEC || 20);
|
||||||
|
|
||||||
// Diarization on the capped A76 cores runs at ~1x wall-clock (a 46-min wav ≈
|
// Diarization on the capped A76 cores runs at ~1x wall-clock (a 46-min wav ≈
|
||||||
// 40 min). The old fixed 10-min timeout killed every recording over ~11 min and
|
// 40 min). The old fixed 10-min timeout killed every recording over ~11 min and
|
||||||
@@ -142,6 +151,26 @@ export async function consolidateSpeakers(wavPath, segs) {
|
|||||||
|
|
||||||
const merged = segs.map(s => ({ ...s, speaker: speakerOf[s.speaker] || nearestRep(s.speaker) }));
|
const merged = segs.map(s => ({ ...s, speaker: speakerOf[s.speaker] || nearestRep(s.speaker) }));
|
||||||
|
|
||||||
|
// Stage 3: fold weak FINAL speakers (< MIN_TOTAL_SEC total talk over the
|
||||||
|
// whole call) into the acoustically nearest strong one — see MIN_TOTAL_SEC.
|
||||||
|
const total = {};
|
||||||
|
for (const s of merged) total[s.speaker] = (total[s.speaker] || 0) + (s.end - s.start);
|
||||||
|
const strong = reps.filter(r => (total[r] || 0) >= MIN_TOTAL_SEC);
|
||||||
|
if (strong.length && strong.length < reps.length) {
|
||||||
|
const fold = {};
|
||||||
|
for (const r of Object.keys(total)) {
|
||||||
|
if (strong.includes(r)) continue;
|
||||||
|
let best = strong[0], bs = -Infinity;
|
||||||
|
for (const t of strong) {
|
||||||
|
const c = (cents[r] && cents[t]) ? spk.cosine(cents[r], cents[t]) : -1;
|
||||||
|
if (c > bs) { bs = c; best = t; }
|
||||||
|
}
|
||||||
|
fold[r] = best;
|
||||||
|
}
|
||||||
|
for (const s of merged) if (fold[s.speaker]) s.speaker = fold[s.speaker];
|
||||||
|
console.log(`diar: folded ${Object.keys(fold).length} weak speaker(s) <${MIN_TOTAL_SEC}s total talk`);
|
||||||
|
}
|
||||||
|
|
||||||
// Renumber surviving speakers by first appearance.
|
// Renumber surviving speakers by first appearance.
|
||||||
const rootMap = {}; let n = 0;
|
const rootMap = {}; let n = 0;
|
||||||
for (const s of merged) if (!(s.speaker in rootMap)) rootMap[s.speaker] = `speaker_${String(n++).padStart(2, '0')}`;
|
for (const s of merged) if (!(s.speaker in rootMap)) rootMap[s.speaker] = `speaker_${String(n++).padStart(2, '0')}`;
|
||||||
|
|||||||
+75
-16
@@ -112,7 +112,15 @@ function extractJson(text) {
|
|||||||
let t = text.replace(/```(json)?/gi, '').trim();
|
let t = text.replace(/```(json)?/gi, '').trim();
|
||||||
const s = t.indexOf('{'), e = t.lastIndexOf('}');
|
const s = t.indexOf('{'), e = t.lastIndexOf('}');
|
||||||
if (s === -1 || e === -1 || e < s) return null;
|
if (s === -1 || e === -1 || e < s) return null;
|
||||||
try { return JSON.parse(t.slice(s, e + 1)); } catch { return null; }
|
const cut = t.slice(s, e + 1);
|
||||||
|
try { return JSON.parse(cut); } catch {}
|
||||||
|
// Gemma degrades JSON on longer generations: typographic quotes (“”„) as
|
||||||
|
// string delimiters and trailing commas (#70 round-5 head). Only reached
|
||||||
|
// when the strict parse failed, so a lossy fix-up is a net win.
|
||||||
|
try {
|
||||||
|
const fixed = cut.replace(/[“”„«»]/g, '"').replace(/[‘’]/g, "'").replace(/,\s*([}\]])/g, '$1');
|
||||||
|
return JSON.parse(fixed);
|
||||||
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize an LLM string array: trim, drop empties and the literal "null" the
|
// Normalize an LLM string array: trim, drop empties and the literal "null" the
|
||||||
@@ -207,14 +215,35 @@ const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
|
|||||||
`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}
|
||||||
|
|
||||||
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 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"]}
|
||||||
Antworte auf Deutsch. Fülle alle Felder mit ECHTEM Inhalt aus dem Gespräch; schreibe niemals Feldbeschreibungen ab. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen; "entscheidungen" = nur wirklich getroffene Entscheidungen, [] wenn keine. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle); [] wenn unklar.
|
Antworte auf Deutsch. Fülle alle Felder mit ECHTEM Inhalt aus dem Gespräch; schreibe niemals Feldbeschreibungen ab. WICHTIG — Gewichtung nach GESPRÄCHSANTEIL über das GANZE Gespräch (Anfang, Mitte UND Ende zählen gleich): Titel und Zusammenfassung müssen die Themen abbilden, über die am LÄNGSTEN gesprochen wurde; Smalltalk und kurz erwähnte Randthemen gehören NICHT in Titel oder Überblick. Nenne konkrete ERGEBNISSE statt abstrakter Oberbegriffe: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen, nach Gesprächsanteil geordnet (wichtigstes zuerst); "entscheidungen" = nur wirklich getroffene Entscheidungen, [] wenn keine. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle); [] wenn unklar.
|
||||||
${TODO_RULE}
|
${TODO_RULE}
|
||||||
${NO_INVENT}
|
${NO_INVENT}
|
||||||
|
|
||||||
Transkript:
|
Transkript:
|
||||||
"""
|
"""
|
||||||
${transcript}
|
${transcript}
|
||||||
|
"""
|
||||||
|
|
||||||
|
Gib jetzt AUSSCHLIESSLICH das JSON-Objekt mit den Feldern titel, zusammenfassung, kernpunkte, entscheidungen, teilnehmer und todos zurück — jedes Feld gefüllt mit ECHTEM Inhalt aus dem Gespräch oben. Gewichte nach Gesprächsanteil über das GANZE Gespräch; nicht den Schluss-Smalltalk zusammenfassen. Kein anderer Text, keine Vor- oder Nachbemerkung, kein Markdown, NUR das JSON. Antworte auf Deutsch.`;
|
||||||
|
// ^ Instruction REPEATED after the transcript: with ~7k-token inputs Gemma-3-4B
|
||||||
|
// "loses" a top-only instruction and drifts into English chat about the
|
||||||
|
// conversation instead of analysing it (seen on #70 at 22.7k chars). Field
|
||||||
|
// names only, NO {"titel": ...} skeleton here — Gemma copied the "..."
|
||||||
|
// placeholders literally when the repeat included one. And NO "themen"
|
||||||
|
// inventory field: asking for a topic list first sent Gemma into a repetition
|
||||||
|
// loop of broken JSON (curly quotes, repeated keys) on the same input.
|
||||||
|
|
||||||
|
// Reformat a broken analysis into valid JSON. Sees ONLY the LLM's own broken
|
||||||
|
// output — small prompt, fast prefill, and no transcript to get distracted by.
|
||||||
|
const PROMPT_JSON_REPAIR = (broken) =>
|
||||||
|
`Die folgende Gesprächsanalyse sollte gültiges JSON sein, ist aber fehlerhaft formatiert. Gib NUR das korrigierte, gültige JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
|
||||||
|
{"titel": "...", "zusammenfassung": "...", "kernpunkte": ["..."], "entscheidungen": ["..."], "teilnehmer": ["..."], "todos": ["..."]}
|
||||||
|
Übernimm die Inhalte unverändert; fehlende Felder als leere Liste [].
|
||||||
|
|
||||||
|
Analyse:
|
||||||
|
"""
|
||||||
|
${broken}
|
||||||
"""`;
|
"""`;
|
||||||
|
|
||||||
// Separate plain-text call for the cleaned transcript — avoids escaping a huge
|
// Separate plain-text call for the cleaned transcript — avoids escaping a huge
|
||||||
@@ -287,13 +316,16 @@ const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars p
|
|||||||
// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster.
|
// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster.
|
||||||
const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones
|
const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones
|
||||||
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
|
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
|
||||||
// The 16K-converted Gemma model is RUN at an 8192 runtime context (rkllm_server
|
// The 16K-converted Gemma model is RUN at a 12288 runtime context (rkllm_server
|
||||||
// max_context_len) to cap KV-cache RAM on the 8GB board. The WHOLE conversation
|
// max_context_len) — but 20000 chars stays the whole-pass cutoff: it is a MODEL
|
||||||
// still fits in ONE analysis-only call → coherent summary (vs the map-reduce
|
// limit, not a context limit. Verified on #70 (22.6k chars, fits in 12288 ctx):
|
||||||
// chunking below, which lost cross-chunk coherence → the "Unsinn" long summary).
|
// above ~6k input tokens Gemma-3-4B ignores the instruction (English chat),
|
||||||
// Budget: 8192 ctx − 1536 gen ≈ 6656 tok prompt ≈ 21900 chars; 20000 leaves
|
// fixates on the END of the call (recency bias: summarized the closing car
|
||||||
// margin. Longer recordings still fall back to chunking. Raise together with the
|
// smalltalk), and its JSON decays (curly quotes, repeated keys, repetition
|
||||||
// server's max_context_len if board RAM headroom allows.
|
// loops). At ≤20k chars whole-pass quality is validated good (#69 era). Longer
|
||||||
|
// recordings use the chunked map-reduce below, whose REDUCE prompt weights
|
||||||
|
// topics by share across sections. Do NOT raise without re-validating content
|
||||||
|
// quality — a bigger window does not make the model read evenly.
|
||||||
const WHOLE_MAX_CHARS = 20000;
|
const WHOLE_MAX_CHARS = 20000;
|
||||||
|
|
||||||
// Split transcript into <=maxChars pieces on natural boundaries (labeled lines
|
// Split transcript into <=maxChars pieces on natural boundaries (labeled lines
|
||||||
@@ -320,9 +352,9 @@ const PROMPT_REDUCE = (summaries) =>
|
|||||||
${summaries}
|
${summaries}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Decke ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne konkrete Namen, Rollen und Entscheidungen.
|
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Die Abschnitte sind chronologisch und gleich lang — das HAUPTTHEMA ist, was in mehreren Abschnitten vorkommt oder den meisten Raum einnimmt, NICHT was zuletzt kam: Titel und Überblick nur aus den größten Themen; Smalltalk und Randbemerkungen (z.B. Begrüßung, Privates am Schluss) dort weglassen. Ein Thema, das NUR im letzten Abschnitt vorkommt, ist meist Smalltalk am Gesprächsende — erwähne es höchstens als letzten Kernpunkt, nie in Titel oder Überblick. Decke in den Kernpunkten ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne KONKRETE Ergebnisse mit Namen, Firmen und Rollen: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es.
|
||||||
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
|
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
|
||||||
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
|
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken, wichtigstes zuerst"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
|
||||||
|
|
||||||
const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filter(Boolean))];
|
const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filter(Boolean))];
|
||||||
|
|
||||||
@@ -348,7 +380,12 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
|
|||||||
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);
|
||||||
if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint);
|
if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint);
|
||||||
if (raw.length > SINGLE_MAX_CHARS) return analyzeWhole(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.
|
||||||
|
try { return await analyzeWhole(raw, hint); }
|
||||||
|
catch (e) { console.log(`llm: whole-analysis failed (${e.message}) -> chunked fallback`); return analyzeLong(raw, hint); }
|
||||||
|
}
|
||||||
return analyzeSingle(raw, labeled, hint);
|
return analyzeSingle(raw, labeled, hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +398,20 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
|
|||||||
async function analyzeWhole(raw, hint = CORRECTION_HINT) {
|
async function analyzeWhole(raw, hint = CORRECTION_HINT) {
|
||||||
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint));
|
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint));
|
||||||
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||||
|
if (!j) {
|
||||||
|
// Gemma occasionally breaks the JSON on very long inputs (leaked prose,
|
||||||
|
// unescaped quotes) while the analysis CONTENT is fine. A tiny repair call
|
||||||
|
// that sees only the broken output (not the 20k+ transcript) reformats it
|
||||||
|
// far more reliably (and faster) than re-running the whole analysis.
|
||||||
|
console.log(`llm: whole-analysis JSON unparseable -> repair call; head: ${JSON.stringify(out.slice(0, 200))} tail: ${JSON.stringify(out.slice(-250))}`);
|
||||||
|
j = extractJson(await ask(PROMPT_JSON_REPAIR(out.slice(0, 6000))));
|
||||||
|
}
|
||||||
if (!j) throw new Error('LLM lieferte kein gültiges JSON (Gesamt-Analyse)');
|
if (!j) throw new Error('LLM lieferte kein gültiges JSON (Gesamt-Analyse)');
|
||||||
|
// Content sanity: when Gemma drifts off-task (English chat about the call),
|
||||||
|
// the repair call can still yield formally valid but empty/junk JSON. Throw
|
||||||
|
// so analyze() degrades to the chunked path instead of storing nonsense.
|
||||||
|
const zsm = (j.zusammenfassung || j.summary || '').toString().trim();
|
||||||
|
if (!zsm || zsm.length < 20) throw new Error('Gesamt-Analyse ohne brauchbare Zusammenfassung');
|
||||||
return {
|
return {
|
||||||
title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null,
|
title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null,
|
||||||
summary: composeSummary(j),
|
summary: composeSummary(j),
|
||||||
@@ -422,8 +472,14 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
|||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
let j = null, rawOut = '';
|
let j = null, rawOut = '';
|
||||||
try {
|
try {
|
||||||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
|
// Whether a chunk parses is a sampling coin-flip (same prompt: 3/3 ok in
|
||||||
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
// one run, 1/3 in the next) — one retry turns P(fail) into P(fail²) and
|
||||||
|
// costs ~100s only when needed.
|
||||||
|
for (let tries = 0; tries < 2 && !j; tries++) {
|
||||||
|
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
|
||||||
|
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||||
|
if (!j && tries === 0) console.log(`llm: chunk ${i + 1} unparseable -> retry`);
|
||||||
|
}
|
||||||
okChunks++;
|
okChunks++;
|
||||||
console.log(`llm: chunk ${i + 1} parsed: ${j ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`);
|
console.log(`llm: chunk ${i + 1} parsed: ${j ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`);
|
||||||
} catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); }
|
} catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); }
|
||||||
@@ -458,7 +514,10 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
|||||||
// Gemma answers big inputs with prose instead of JSON (same failure as the
|
// Gemma answers big inputs with prose instead of JSON (same failure as the
|
||||||
// chunk step) → cap each partial summary and retry the reduce once; the
|
// chunk step) → cap each partial summary and retry the reduce once; the
|
||||||
// #69 run lost title+structure because the single reduce didn't parse.
|
// #69 run lost title+structure because the single reduce didn't parse.
|
||||||
const reduceInput = summaries.map(s => s.slice(0, 800)).join('\n\n');
|
// Label each partial so the reduce can APPLY its weighting rule ("main
|
||||||
|
// topic = spans several sections") — unlabeled concatenation gave the last
|
||||||
|
// section (closing smalltalk) equal weight in title/overview (#70).
|
||||||
|
const reduceInput = summaries.map((s, i) => `Abschnitt ${i + 1} von ${summaries.length}:\n${s.slice(0, 800)}`).join('\n\n');
|
||||||
for (let attempt = 1; attempt <= 2 && !red; attempt++) {
|
for (let attempt = 1; attempt <= 2 && !red; attempt++) {
|
||||||
try {
|
try {
|
||||||
const out = await ask(PROMPT_REDUCE(reduceInput));
|
const out = await ask(PROMPT_REDUCE(reduceInput));
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ int main(int argc, char** argv) {
|
|||||||
if (argc < 2) { fprintf(stderr, "usage: %s model.rkllm\n", argv[0]); return 1; }
|
if (argc < 2) { fprintf(stderr, "usage: %s model.rkllm\n", argv[0]); return 1; }
|
||||||
RKLLMParam p = rkllm_createDefaultParam();
|
RKLLMParam p = rkllm_createDefaultParam();
|
||||||
p.model_path = argv[1];
|
p.model_path = argv[1];
|
||||||
p.max_context_len = 8192;
|
p.max_context_len = 12288;
|
||||||
p.max_new_tokens = 1536;
|
p.max_new_tokens = 1536;
|
||||||
// Gemma-3-4B (w8a8) degenerates into repetition loops ("X, X, X…") and echoes
|
// Gemma-3-4B (w8a8) degenerates into repetition loops ("X, X, X…") and echoes
|
||||||
// the prompt under pure-greedy decode → needs real sampling + strong repeat/
|
// the prompt under pure-greedy decode → needs real sampling + strong repeat/
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ async function identifySpeakers(recData) {
|
|||||||
|
|
||||||
// Run LLM analysis for a recording and persist title/summary/todos/participants/
|
// Run LLM analysis for a recording and persist title/summary/todos/participants/
|
||||||
// clean dialogue. Background (NPU ~30-90s) so record/stop stays fast.
|
// clean dialogue. Background (NPU ~30-90s) so record/stop stays fast.
|
||||||
|
// A76 clock cap for the whole run: rkllm burns ~1.7 CPU cores alongside the NPU
|
||||||
|
// and a long Gemma generation hit 95-97°C uncapped on the fanless board (#70,
|
||||||
|
// 16-min run) — same shutdown risk transcribeWav/rediarize already guard against.
|
||||||
async function analyzeRecording(id) {
|
async function analyzeRecording(id) {
|
||||||
|
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
|
||||||
try {
|
try {
|
||||||
const rec = await db.getRecording(id);
|
const rec = await db.getRecording(id);
|
||||||
if (!rec) return;
|
if (!rec) return;
|
||||||
@@ -122,6 +126,8 @@ async function analyzeRecording(id) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`analysis failed for #${id}:`, e.message);
|
console.error(`analysis failed for #${id}:`, e.message);
|
||||||
await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {});
|
await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ stateDiagram-v2
|
|||||||
→ kein Auto-Resume, GUI-Button „Neu transkribieren“.
|
→ kein Auto-Resume, GUI-Button „Neu transkribieren“.
|
||||||
- **NPU-Lock:** alle schweren Jobs (transcribe / analyze / re-diarize) laufen seriell durch `withNpuLock` —
|
- **NPU-Lock:** alle schweren Jobs (transcribe / analyze / re-diarize) laufen seriell durch `withNpuLock` —
|
||||||
zwei gleichzeitig fertig werdende Aufnahmen haben sich sonst gegenseitig zerlegt.
|
zwei gleichzeitig fertig werdende Aufnahmen haben sich sonst gegenseitig zerlegt.
|
||||||
|
- **Takt-Cap gilt für ALLE drei schweren Pfade:** `transcribeWav`, `rediarize` UND seit 2026-07-16 auch
|
||||||
|
`analyzeRecording` (LLM lief vorher ungedeckelt auf 95–97 °C, Kap. 05).
|
||||||
- **GUI-Anzeige „⏱ Analysezeit“:** `analysis_started_at`/`analysis_ms` in der DB; beim Polling auf
|
- **GUI-Anzeige „⏱ Analysezeit“:** `analysis_started_at`/`analysis_ms` in der DB; beim Polling auf
|
||||||
`analysis_status` achten, NICHT auf `status` (sonst wirkt die Summary „leer“).
|
`analysis_status` achten, NICHT auf `status` (sonst wirkt die Summary „leer“).
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -15,9 +15,12 @@ flowchart TB
|
|||||||
JOIN -- ja --> MERGE["dem Sprecher zuordnen"]
|
JOIN -- ja --> MERGE["dem Sprecher zuordnen"]
|
||||||
JOIN -- nein --> NEW["neuen Sprecher eröffnen"]
|
JOIN -- nein --> NEW["neuen Sprecher eröffnen"]
|
||||||
ANCHOR -- "nein (kurz/Streu)" --> NEAREST["IMMER zum nächstliegenden Anchor<br/>(darf NIE neuen Sprecher eröffnen)"]
|
ANCHOR -- "nein (kurz/Streu)" --> NEAREST["IMMER zum nächstliegenden Anchor<br/>(darf NIE neuen Sprecher eröffnen)"]
|
||||||
MERGE --> OUT["finale Sprecher (z. B. 27 → 3)"]
|
MERGE --> FOLD
|
||||||
NEW --> OUT
|
NEW --> FOLD
|
||||||
NEAREST --> OUT
|
NEAREST --> FOLD{"Stufe 3: finale Sprecher mit<br/>< 20 s Gesamtredezeit?<br/>(DIAR_MIN_TOTAL_SEC)"}
|
||||||
|
FOLD -- "ja (Phantom)" --> WEAK["in akustisch nächsten<br/>starken Sprecher falten"]
|
||||||
|
FOLD -- nein --> OUT["finale Sprecher (z. B. 50 → 4)"]
|
||||||
|
WEAK --> OUT
|
||||||
```
|
```
|
||||||
|
|
||||||
## Warum Greedy-Anchor-Merge (Geschichte der Fixes)
|
## Warum Greedy-Anchor-Merge (Geschichte der Fixes)
|
||||||
@@ -26,9 +29,16 @@ flowchart TB
|
|||||||
2. **Union-Find (1. Konsolidierung) instabil:** paarweises Mergen bildet transitive Ketten (A~B~C zieht alles
|
2. **Union-Find (1. Konsolidierung) instabil:** paarweises Mergen bildet transitive Ketten (A~B~C zieht alles
|
||||||
zusammen). Ein Grenzfall-Link um Kosinus ≈ 0,5 kippte je nach FP-Rauschen (Multi-Thread-TitaNet unter
|
zusammen). Ein Grenzfall-Link um Kosinus ≈ 0,5 kippte je nach FP-Rauschen (Multi-Thread-TitaNet unter
|
||||||
CPU-Last) → derselbe Call ergab 2 / 3 / 7 Sprecher in drei Läufen.
|
CPU-Last) → derselbe Call ergab 2 / 3 / 7 Sprecher in drei Läufen.
|
||||||
3. **Greedy-Anchor-Merge (aktuell, `diar.js consolidateSpeakers`):** nur lange Cluster (verlässliche
|
3. **Greedy-Anchor-Merge (`diar.js consolidateSpeakers`):** nur lange Cluster (verlässliche
|
||||||
Embeddings) dürfen Sprecher eröffnen, Abarbeitung längste-zuerst (ordnungsstabil), kurze Cluster können nur
|
Embeddings) dürfen Sprecher eröffnen, Abarbeitung längste-zuerst (ordnungsstabil), kurze Cluster können nur
|
||||||
zugeordnet werden → Rauschen kann nichts mehr fragmentieren. Verifiziert: 5/5 Läufe konstant 27 → 3.
|
zugeordnet werden → Rauschen kann nichts mehr fragmentieren. Verifiziert: 5/5 Läufe konstant 27 → 3.
|
||||||
|
4. **Stufe 3: Weak-Fold (2026-07-16, `.bak-weakfold`):** ein einzelner ≥ 3-s-Streu-Cluster kann trotzdem
|
||||||
|
einen Anchor eröffnen, wenn sein (kurzes, unzuverlässiges) Embedding unter Kosinus 0,5 zu allen echten
|
||||||
|
Sprechern landet — Aufnahme #70 (26 min, 4 Personen) bekam so **3 Phantom-Sprecher mit 2,5–4,8 s**
|
||||||
|
Gesamtredezeit neben echten mit 53–736 s. Fix: jeder FINALE Sprecher unter `DIAR_MIN_TOTAL_SEC` (20 s,
|
||||||
|
gemessen auf Segment-Zeiten, die ~20–40 % über Wort-Summen liegen — 10 s ließ einen 9,2-s-Phantom durch)
|
||||||
|
wird in den akustisch nächsten starken Sprecher gefaltet. Verifiziert: #70 konstant 50 → **exakt 4**.
|
||||||
|
Tradeoff: wer im ganzen Gespräch < 20 s redet, wird einem anderen Sprecher zugeschlagen.
|
||||||
|
|
||||||
## Harte Grenzen / Sackgassen
|
## Harte Grenzen / Sackgassen
|
||||||
|
|
||||||
|
|||||||
+34
-6
@@ -7,7 +7,7 @@
|
|||||||
| Modell | **Gemma-3-4B-it**, `gemma3-4b-w8a8g128-16k.rkllm` |
|
| Modell | **Gemma-3-4B-it**, `gemma3-4b-w8a8g128-16k.rkllm` |
|
||||||
| Runtime | rkllm **1.3.0** (Upgrade von 1.2.1 — API- und Chat-Template-Änderungen) |
|
| Runtime | rkllm **1.3.0** (Upgrade von 1.2.1 — API- und Chat-Template-Änderungen) |
|
||||||
| Quantisierung | **grouped w8a8 (g128)** — plain w8a8 hat Gemma kaputt-quantisiert; w4a16 auf RK3588 nicht unterstützt |
|
| Quantisierung | **grouped w8a8 (g128)** — plain w8a8 hat Gemma kaputt-quantisiert; w4a16 auf RK3588 nicht unterstützt |
|
||||||
| Kontext | 16k konvertiert, **läuft mit 8192** (KV-Cache-RAM auf 8-GB-Board) ≈ 20 000 Zeichen Prompt-Budget |
|
| Kontext | 16k konvertiert, Runtime **12288** (seit 2026-07-16, vorher 8192; KV-Cache-RAM passt). ABER: Whole-Pass-Grenze bleibt **20 000 Zeichen** — das ist eine **Modell-**, keine Kontextgrenze (s. u.) |
|
||||||
| Tempo | ~5 tok/s Decode → Kurzclip ~5 min, 71-min-Aufnahme ~20 min Analyse |
|
| Tempo | ~5 tok/s Decode → Kurzclip ~5 min, 71-min-Aufnahme ~20 min Analyse |
|
||||||
| Historie | Vorher Qwen2.5-3B (fest 4096 Kontext). Gemma ≈ Gemini-Qualität, aber ~3× langsamer. gemma3n-E4B zu groß (10,4 GB). |
|
| Historie | Vorher Qwen2.5-3B (fest 4096 Kontext). Gemma ≈ Gemini-Qualität, aber ~3× langsamer. gemma3n-E4B zu groß (10,4 GB). |
|
||||||
|
|
||||||
@@ -20,19 +20,40 @@ flowchart TB
|
|||||||
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
|
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 -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
|
||||||
A -- nein --> B{"≤ 20 000 Zeichen?"}
|
A -- nein --> B{"≤ 20 000 Zeichen?"}
|
||||||
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)"]
|
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 (Map-Reduce):<br/>Chunks à 8 000 Zeichen"]
|
B -- nein --> LONG["analyzeLong (Map-Reduce):<br/>Chunks à 8 000 Zeichen"]
|
||||||
LONG --> MAP["MAP: pro Chunk Mini-JSON<br/>(zusammenfassung, teilnehmer, todos)<br/>bewusst minimaler Prompt — lange Regelwände<br/>kippen Gemma in englische Prosa"]
|
LONG --> MAP["MAP: pro Chunk Mini-JSON<br/>(zusammenfassung, teilnehmer, todos)<br/>bewusst minimaler Prompt — lange Regelwände<br/>kippen Gemma in englische Prosa<br/>1 RETRY bei Parse-Fehler (Sampling-Münzwurf)"]
|
||||||
MAP --> SALV{"JSON parsebar?"}
|
MAP --> SALV{"JSON parsebar?"}
|
||||||
SALV -- ja --> COLLECT["einsammeln"]
|
SALV -- ja --> COLLECT["einsammeln"]
|
||||||
SALV -- nein --> PROSE["Prosa retten:<br/>Code-Fences UND rohe {...}-Blobs entfernen<br/>(JSON-Leak-Fix), dann als Teil-Summary nutzen"]
|
SALV -- nein --> PROSE["Prosa retten:<br/>Code-Fences UND rohe {...}-Blobs entfernen<br/>(JSON-Leak-Fix), dann als Teil-Summary nutzen"]
|
||||||
PROSE --> COLLECT
|
PROSE --> COLLECT
|
||||||
COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen gekappt)<br/>→ EIN JSON: titel, zusammenfassung,<br/>kernpunkte[], entscheidungen[]<br/>2 Parse-Versuche, Tail wird geloggt"]
|
COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen,<br/>gelabelt 'Abschnitt i von n')<br/>→ EIN JSON: titel, zusammenfassung,<br/>kernpunkte[], entscheidungen[]<br/>Gewichtung nach Abschnitts-Anteil,<br/>Schluss-Smalltalk nie in Titel/Überblick<br/>2 Parse-Versuche, Tail wird geloggt"]
|
||||||
REDUCE --> OUT["Ergebnis"]
|
REDUCE --> OUT["Ergebnis"]
|
||||||
SINGLE --> OUT
|
SINGLE --> OUT
|
||||||
WHOLE --> OUT
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Warum die Whole-Pass-Grenze bei 20 000 Zeichen bleibt (Modellgrenze, 2026-07-16)
|
||||||
|
|
||||||
|
Aufnahme #70 (26 min, 22,6k Zeichen ≈ 6 900 Token) lag knapp über der Grenze. Versuch, sie per
|
||||||
|
Runtime-Kontext 12288 durch `analyzeWhole` zu schicken, scheiterte REPRODUZIERBAR an Gemma-3-4B selbst
|
||||||
|
(vier Iterationen, alle Fehlbilder real beobachtet):
|
||||||
|
|
||||||
|
1. **Instruktion nur oben** → Modell „vergisst" die Aufgabe, antwortet mit englischem Geplauder
|
||||||
|
*über* das Gespräch („Okay! That was a fascinating conversation…"). Fix: Instruktion nach dem
|
||||||
|
Transkript wiederholen (ohne `{"titel": ...}`-Skelett — die `...`-Platzhalter wurden sonst wörtlich kopiert!).
|
||||||
|
2. **Recency-Bias:** Titel/Summary beschreiben den SCHLUSS des Gesprächs (Auto-Smalltalk der letzten
|
||||||
|
3 min) statt der Hauptthemen — mehr Kontextfenster liest nicht gleichmäßiger.
|
||||||
|
3. **„themen"-Inventar-Feld als Gegenmittel** → Repetitionsschleife mit zerfallendem JSON
|
||||||
|
(typografische Anführungszeichen, doppelte Keys, 16-min-Generierung bis Token-Cap).
|
||||||
|
|
||||||
|
Konsequenz: > 20k Zeichen geht IMMER über Map-Reduce; dessen Reduce gewichtet stattdessen nach
|
||||||
|
Abschnitts-Anteil. `WHOLE_MAX_CHARS` nicht erhöhen ohne Inhalts-Revalidierung.
|
||||||
|
|
||||||
## Strukturierte Zusammenfassung (seit 2026-07-15)
|
## Strukturierte Zusammenfassung (seit 2026-07-15)
|
||||||
|
|
||||||
Das LLM liefert getrennte Felder; `composeSummary()` baut daraus den Summary-Text:
|
Das LLM liefert getrennte Felder; `composeSummary()` baut daraus den Summary-Text:
|
||||||
@@ -63,7 +84,10 @@ hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 b
|
|||||||
| Echot Feldbeschreibungen statt Inhalt | `echoed()`-Guard → separater JSON-Call |
|
| Echot Feldbeschreibungen statt Inhalt | `echoed()`-Guard → separater JSON-Call |
|
||||||
| Echot konkrete Beispiele aus dem Prompt (Phantom-Todos, „Kompressor“-Summary) | Korrektur-Regeln bewusst OHNE konkrete Beispiele (`CORRECTION_HINT` abstrakt) |
|
| Echot konkrete Beispiele aus dem Prompt (Phantom-Todos, „Kompressor“-Summary) | Korrektur-Regeln bewusst OHNE konkrete Beispiele (`CORRECTION_HINT` abstrakt) |
|
||||||
| Erfindet Fantasie-Komposita („Audio-Dualeintragung“) | `NO_INVENT`-Regel in allen Summary-Prompts |
|
| Erfindet Fantasie-Komposita („Audio-Dualeintragung“) | `NO_INVENT`-Regel in allen Summary-Prompts |
|
||||||
| Chunk-Parse-Fehler variieren pro Lauf (2–3 von 10) | nichtdeterministisch, wird per Salvage aufgefangen; bei schwachem Ergebnis einfach „Analyse neu“ |
|
| Chunk-Parse-Fehler variieren pro Lauf (Sampling-Münzwurf: 3/3 ok, nächster Lauf 1/3) | 1 Retry pro Chunk (P(fail) → P(fail²)), danach Salvage; bei schwachem Ergebnis „Analyse neu“ |
|
||||||
|
| Typografische Anführungszeichen (“”„) als JSON-Delimiter, trailing commas | `extractJson()`-Sanitizer: erst strikt parsen, bei Fehler Quotes/Kommas reparieren und nochmal |
|
||||||
|
| Whole-Pass > ~6k Token: englisches Geplauber, Recency-Bias, JSON-Zerfall | `WHOLE_MAX_CHARS` 20 000 hart; End-Instruktion; Repair-Call; Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback |
|
||||||
|
| Kopiert `...`-Platzhalter aus Schema-Beispielen wörtlich | End-Instruktion nennt nur Feldnamen, nie ein Skelett mit Platzhaltern |
|
||||||
|
|
||||||
## Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“)
|
## Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“)
|
||||||
|
|
||||||
@@ -84,3 +108,7 @@ keine „ersetze X durch Y“-Anweisung anvertrauen (unzuverlässig) — deshalb
|
|||||||
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
|
||||||
als `rc=-6`). Kurzclips (≤ 30 s) nutzen deshalb den CPU-Whisper-Daemon ohne rkllm-Swap; lange Aufnahmen
|
als `rc=-6`). Kurzclips (≤ 30 s) nutzen deshalb den CPU-Whisper-Daemon ohne rkllm-Swap; lange Aufnahmen
|
||||||
swappen seriell unter dem NPU-Lock.
|
swappen seriell unter dem NPU-Lock.
|
||||||
|
|
||||||
|
**Thermik:** `analyzeRecording()` läuft seit 2026-07-16 unter dem A76-Takt-Cap (`.bak-anacap`) — rkllm
|
||||||
|
zieht neben der NPU ~1,7 CPU-Kerne, eine lange ungedeckelte Gemma-Generierung erreichte **95–97 °C**
|
||||||
|
(lüfterlos, Schutzabschaltungs-Risiko). Gleicher Cap wie `transcribeWav`/`rediarize` (Kap. 03).
|
||||||
|
|||||||
@@ -3,6 +3,36 @@
|
|||||||
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-07-16 — #70-Debug: Phantom-Sprecher, Summary-Fehlgewichtung, Thermal-Leck (Nachmittag)
|
||||||
|
|
||||||
|
Auslöser: Aufnahme #70 (26 min, 4 Personen) — 7 Sprecher erkannt, Titel/Summary aus Randthemen
|
||||||
|
(Auto-Smalltalk der letzten 3 min statt Recruiting-Hauptteil). 9 Analyse-Iterationen auf #70.
|
||||||
|
|
||||||
|
- **Diarization Stufe 3 „Weak-Fold"** (`diar.js`, `.bak-weakfold`): finale Sprecher < `DIAR_MIN_TOTAL_SEC`
|
||||||
|
(20 s, Segment-Zeiten!) werden in den akustisch nächsten starken Sprecher gefaltet — ein einzelner
|
||||||
|
≥ 3-s-Streu-Cluster konnte vorher einen Phantom-Anchor eröffnen (#70: 3 Phantome mit 2,5–4,8 s).
|
||||||
|
10 s reichte NICHT (9,2-s-Phantom kam durch). **Verifiziert: #70 konstant 50 Roh-Cluster → exakt 4.** Kap. 04.
|
||||||
|
- **Whole-Pass-Grenze = Modellgrenze bewiesen** (Runden 2–5): rkllm-Runtime-Kontext 8192 → **12288**
|
||||||
|
(`rkllm_server.cpp`, `.bak-ctx12k`, Rebuild) und `WHOLE_MAX_CHARS` testweise 32k → Gemma-3-4B kippt
|
||||||
|
> ~6k Input-Token reproduzierbar: englisches Geplauder statt Analyse, Recency-Bias (fasst nur das
|
||||||
|
Gesprächsende zusammen), JSON-Zerfall (typografische Quotes, Wiederholungsschleifen). `WHOLE_MAX_CHARS`
|
||||||
|
zurück auf **20 000** (dort validiert gut); ctx 12288 bleibt (Headroom). Kap. 05 neuer Abschnitt.
|
||||||
|
- **Robustheit `llm.js`** (`.bak-ctx12k`): End-Instruktion nach dem Transkript (ohne Platzhalter-Skelett —
|
||||||
|
Gemma kopiert `...` wörtlich!); JSON-Repair-Call (repariert kaputten Output OHNE das große Transkript);
|
||||||
|
Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback statt Hard-Error; `extractJson()`
|
||||||
|
repariert typografische Anführungszeichen + trailing commas; **1 Retry pro Chunk** (Parse = Sampling-
|
||||||
|
Münzwurf: 3/3 ok vs 1/3 im nächsten Lauf; Retry live bestätigt); Reduce-Input mit „Abschnitt i von n"-
|
||||||
|
Labels + Gewichtungsregeln (Hauptthema = mehrere Abschnitte; Nur-letzter-Abschnitt = Smalltalk).
|
||||||
|
- **Regression bestätigt: Chunk-Prompt MUSS minimal bleiben** — eine zusätzliche Anweisungszeile
|
||||||
|
(Namen/Firmen/Zusagen) kippte 2/3 Chunks in Prosa (Runde 6). Zurückgebaut.
|
||||||
|
- **Thermal-Leck geschlossen** (`server.js`, `.bak-anacap`): `analyzeRecording()` lief als einziger
|
||||||
|
schwerer Pfad OHNE A76-Takt-Cap → 95–97 °C bei langer Gemma-Generierung (lüfterlos!). Jetzt Cap wie
|
||||||
|
`transcribeWav`/`rediarize`. Kap. 03/05.
|
||||||
|
- **Ehrliches Ergebnis #70:** Sprecher exakt 4 ✅, Analyse crashfrei in ~9 min ✅, Summary führt jetzt mit
|
||||||
|
Recruiting, ABER Auto-Smalltalk bleibt in Titel/Kernpunkten und konkrete Namen (Com4Tec, Kandidaten)
|
||||||
|
fehlen — **Qualitätsdecke des 4B-Modells im Chunk-Pfad (> 20k Zeichen)**. Aufnahmen ≤ ~23 min laufen
|
||||||
|
über den validierten Whole-Pass.
|
||||||
|
|
||||||
## 2026-07-16
|
## 2026-07-16
|
||||||
- **Codebase ins Repo aufgenommen:** `app/` = Spiegel von `/opt/callrec` (Backend, Frontend-Quellen, Inferenz-Python, Skripte, `rkllm_server.cpp`), `system/` = systemd-Units + WirePlumber/PipeWire-Drop-ins + sudoers. Modelle/Binaries/Aufnahmen bewusst außen vor (`.gitignore`).
|
- **Codebase ins Repo aufgenommen:** `app/` = Spiegel von `/opt/callrec` (Backend, Frontend-Quellen, Inferenz-Python, Skripte, `rkllm_server.cpp`), `system/` = systemd-Units + WirePlumber/PipeWire-Drop-ins + sudoers. Modelle/Binaries/Aufnahmen bewusst außen vor (`.gitignore`).
|
||||||
- **Projekt umbenannt in „Aime“** (vormals CallRec, wie der BT-Name des Boards): Gitea-Repo → `sascha/Aime`,
|
- **Projekt umbenannt in „Aime“** (vormals CallRec, wie der BT-Name des Boards): Gitea-Repo → `sascha/Aime`,
|
||||||
|
|||||||
Reference in New Issue
Block a user