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