Fix meeting summaries: long transcripts overflowed the LLM context

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)
This commit is contained in:
2026-09-08 20:53:33 +02:00
parent 76611af7e0
commit 293ec8bdec
2 changed files with 51 additions and 13 deletions
@@ -205,17 +205,20 @@ private fun LibraryDetail(
} }
} }
// poll while the server is generating (summary + agenda run via Ollama) // poll while the server is generating (summary + agenda run via Ollama);
LaunchedEffect(meta?.summary, meta?.agendaStatus) { // transient fetch errors must not stop the poll — the UI would stick on
val m = meta ?: return@LaunchedEffect // "wird erstellt" forever
LaunchedEffect(item) {
val m0 = (item as? LibraryItem.Server)?.meta ?: return@LaunchedEffect
var m = m0
val url = storageUrl.trim().trimEnd('/') val url = storageUrl.trim().trimEnd('/')
if (m.summary == "pending" || m.agendaStatus == "pending") { while (m.summary == "pending" || m.agendaStatus == "pending") {
delay(5_000) delay(5_000)
try { try {
meta = withContext(Dispatchers.IO) { m = withContext(Dispatchers.IO) { StorageClient.fetchMeta(url, m.id) }
StorageClient.fetchMeta(url, m.id) meta = m
}
} catch (_: Exception) { } catch (_: Exception) {
// network hiccup — retry on the next loop iteration
} }
} }
} }
+41 -6
View File
@@ -37,11 +37,12 @@ RID_RE = re.compile(r"^[A-Za-z0-9-]+$")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "").rstrip("/") OLLAMA_URL = os.environ.get("OLLAMA_URL", "").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:12b") 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")) OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "600"))
# rough token guard: ~4 chars per token; 90k chars fit 32k context # char guard so the prompt leaves room for the answer: ~3 chars/token for
TRANSCRIPT_MAX_CHARS = 90_000 # 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") app = FastAPI(title="meetrec-server", version="0.1.0")
_lock = threading.Lock() _lock = threading.Lock()
@@ -94,7 +95,16 @@ def _ollama_chat(prompt: str) -> str:
) )
with urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT) as r: with urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT) as r:
data = json.load(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: def _timestamped_transcript(rec_dir: Path) -> str:
@@ -152,11 +162,18 @@ def _generate_summary(rid: str) -> None:
transcript = _timestamped_transcript(rec_dir) transcript = _timestamped_transcript(rec_dir)
if not transcript.strip(): if not transcript.strip():
raise RuntimeError("no transcript available") raise RuntimeError("no transcript available")
truncated = len(transcript) > TRANSCRIPT_MAX_CHARS
prompt = _SUMMARY_PROMPT.format(transcript=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") _update_item(rid, summary="done")
except Exception as e: # noqa: BLE001 — report in index, never crash the thread 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: def _check_agenda(rid: str) -> None:
@@ -203,6 +220,24 @@ def _postprocess(rid: str) -> None:
_check_agenda(rid) _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") @app.get("/api/health")
def health() -> dict: def health() -> dict:
with _lock: with _lock: