From 293ec8bdec988d86ae18ad09a6c9ac94510832a1 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 8 Sep 2026 20:53:33 +0200 Subject: [PATCH] Fix meeting summaries: long transcripts overflowed the LLM context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced on the 68-min meeting: the timestamped transcript filled the whole 32k window (prompt_eval 32451), gemma4 hit the length limit and returned an EMPTY answer (done_reason=length) — which the server wrote as an empty summary.md and marked "done". Fixes: - num_ctx 32768 -> 65536 and transcript cap 90k -> 100k chars (env OLLAMA_NUM_CTX / TRANSCRIPT_MAX_CHARS); ~33k tokens now fit with plenty of room for the answer - _ollama_chat treats an empty response as an ERROR (with done_reason and prompt_eval in the message) instead of producing a done-with-empty summary - truncated transcripts get a note appended to the summary - startup recovery: a container restart resets stale "pending" statuses so the UI can never stick on "wird erstellt" from dead threads - phone: the summary/agenda poll survives transient fetch errors (previously one network hiccup stopped the poll forever) --- .../java/com/meetrec/android/LibraryScreen.kt | 17 ++++--- server/meetrec-server/main.py | 47 ++++++++++++++++--- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt index 346f190..af29e7a 100644 --- a/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt +++ b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt @@ -205,17 +205,20 @@ private fun LibraryDetail( } } - // poll while the server is generating (summary + agenda run via Ollama) - LaunchedEffect(meta?.summary, meta?.agendaStatus) { - val m = meta ?: return@LaunchedEffect + // poll while the server is generating (summary + agenda run via Ollama); + // transient fetch errors must not stop the poll — the UI would stick on + // "wird erstellt" forever + LaunchedEffect(item) { + val m0 = (item as? LibraryItem.Server)?.meta ?: return@LaunchedEffect + var m = m0 val url = storageUrl.trim().trimEnd('/') - if (m.summary == "pending" || m.agendaStatus == "pending") { + while (m.summary == "pending" || m.agendaStatus == "pending") { delay(5_000) try { - meta = withContext(Dispatchers.IO) { - StorageClient.fetchMeta(url, m.id) - } + m = withContext(Dispatchers.IO) { StorageClient.fetchMeta(url, m.id) } + meta = m } catch (_: Exception) { + // network hiccup — retry on the next loop iteration } } } diff --git a/server/meetrec-server/main.py b/server/meetrec-server/main.py index 1b6eee9..16c1b68 100644 --- a/server/meetrec-server/main.py +++ b/server/meetrec-server/main.py @@ -37,11 +37,12 @@ RID_RE = re.compile(r"^[A-Za-z0-9-]+$") OLLAMA_URL = os.environ.get("OLLAMA_URL", "").rstrip("/") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:12b") -OLLAMA_NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "32768")) +OLLAMA_NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "65536")) OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "600")) -# rough token guard: ~4 chars per token; 90k chars fit 32k context -TRANSCRIPT_MAX_CHARS = 90_000 +# char guard so the prompt leaves room for the answer: ~3 chars/token for +# German + per-line timestamps, so 100k chars ~ 33k tokens in a 64k window +TRANSCRIPT_MAX_CHARS = int(os.environ.get("TRANSCRIPT_MAX_CHARS", "100000")) app = FastAPI(title="meetrec-server", version="0.1.0") _lock = threading.Lock() @@ -94,7 +95,16 @@ def _ollama_chat(prompt: str) -> str: ) with urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT) as r: data = json.load(r) - return data["message"]["content"].strip() + content = data["message"]["content"].strip() + if not content: + # e.g. a prompt that fills the context window yields done_reason + # "length" with an empty answer — treat as failure, never as "done" + raise RuntimeError( + "empty model response (done_reason=%s, prompt_eval=%s) — " + "transcript too long for the context window?" + % (data.get("done_reason"), data.get("prompt_eval_count")), + ) + return content def _timestamped_transcript(rec_dir: Path) -> str: @@ -152,11 +162,18 @@ def _generate_summary(rid: str) -> None: transcript = _timestamped_transcript(rec_dir) if not transcript.strip(): raise RuntimeError("no transcript available") + truncated = len(transcript) > TRANSCRIPT_MAX_CHARS prompt = _SUMMARY_PROMPT.format(transcript=transcript[:TRANSCRIPT_MAX_CHARS]) - (rec_dir / "summary.md").write_text(_ollama_chat(prompt), encoding="utf-8") + summary = _ollama_chat(prompt) + if truncated: + summary += ( + "\n\n---\n\n*(Hinweis: sehr langes Meeting — das Transkript " + "wurde für die Zusammenfassung gekürzt.)*" + ) + (rec_dir / "summary.md").write_text(summary, encoding="utf-8") _update_item(rid, summary="done") except Exception as e: # noqa: BLE001 — report in index, never crash the thread - _update_item(rid, summary=f"error: {str(e)[:120]}") + _update_item(rid, summary=f"error: {str(e)[:160]}") def _check_agenda(rid: str) -> None: @@ -203,6 +220,24 @@ def _postprocess(rid: str) -> None: _check_agenda(rid) +@app.on_event("startup") +def startup() -> None: + """Recover stale state: a container restart kills in-flight Ollama + threads, which would leave recordings "pending" forever.""" + with _lock: + items = _load_index() + dirty = False + for it in items: + if it.get("summary") == "pending": + it["summary"] = "error: interrupted by restart" + dirty = True + if it.get("agenda_status") == "pending": + it["agenda_status"] = "error: interrupted by restart" + dirty = True + if dirty: + _save_index(items) + + @app.get("/api/health") def health() -> dict: with _lock: