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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user