M3c: meeting summaries and agenda coverage via Ollama

- meetrec-server: Ollama integration (chat API, gemma4:12b, num_ctx
  32768); German structured summary (topic/points/decisions/to-dos)
  written to summary.md; agenda coverage returns strict JSON (covered,
  time, evidence) parsed defensively; both run automatically in a
  background thread after upload plus manual trigger endpoints
  (POST /summary, POST /agenda) with status tracking in the index
- phone: agenda input on the Record tab (one item per line, persisted)
  is uploaded with the recording; Library detail shows the summary and
  a per-item agenda checklist with timestamps and evidence quotes,
  with polling while the server generates and manual re-trigger buttons
- validated end-to-end against the live Ollama server: crafted German
  test meeting produced a correct structured summary and perfect agenda
  discrimination (covered items with correct timestamps + quotes,
  undiscussed item correctly false)
This commit is contained in:
2026-09-08 12:32:20 +02:00
parent 4a2aeb60d8
commit 2cf785746e
6 changed files with 385 additions and 9 deletions
+180 -1
View File
@@ -14,10 +14,12 @@ Storage layout (no database):
"""
import json
import os
import re
import shutil
import threading
import time
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -33,6 +35,14 @@ INDEX_PATH = DATA_DIR / "index.json"
ALLOWED_FILES = ("wav", "txt", "srt", "json")
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_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
app = FastAPI(title="meetrec-server", version="0.1.0")
_lock = threading.Lock()
@@ -56,6 +66,143 @@ def _find(items: list, rid: str) -> Optional[dict]:
return next((it for it in items if it["id"] == rid), None)
def _update_item(rid: str, **fields) -> Optional[dict]:
"""Thread-safe index field update for one recording."""
with _lock:
items = _load_index()
item = _find(items, rid)
if item is None:
return None
item.update(fields)
_save_index(items)
return dict(item)
# ------------------------------------------------------------------ Ollama
def _ollama_chat(prompt: str) -> str:
body = json.dumps({
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_ctx": OLLAMA_NUM_CTX, "temperature": 0.2},
}).encode()
req = urllib.request.Request(
OLLAMA_URL + "/api/chat", data=body,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT) as r:
data = json.load(r)
return data["message"]["content"].strip()
def _timestamped_transcript(rec_dir: Path) -> str:
"""'[h:]mm:ss text' lines from meeting.json, meeting.txt as fallback."""
js = rec_dir / "meeting.json"
if js.is_file():
try:
with js.open() as f:
segments = json.load(f).get("segments", [])
lines = []
for s in segments:
t = int(float(s.get("start", 0)))
stamp = f"{t // 3600}:{t % 3600 // 60:02d}:{t % 60:02d}" if t >= 3600 \
else f"{t // 60:02d}:{t % 60:02d}"
lines.append(f"[{stamp}] {s.get('text', '').strip()}")
if lines:
return "\n".join(lines)
except (json.JSONDecodeError, OSError, ValueError):
pass
txt = rec_dir / "meeting.txt"
return txt.read_text() if txt.is_file() else ""
_SUMMARY_PROMPT = """Du erstellst ein Meetingprotokoll auf Deutsch.\n\
Fasse das folgende Transkript in Markdown zusammen, mit den Abschnitten:
- **Thema** — das Thema des Meetings in einem Satz
- **Wichtigste Punkte** — Stichpunkte
- **Entscheidungen** — falls erkennbar
- **Aufgaben / To-dos** — wer macht was, falls erkennbar
Transkript:
{transcript}
Antworte nur mit der Zusammenfassung, ohne Vorwort.
"""
_AGENDA_PROMPT = """Prüfe für jeden Agendapunkt, ob er im folgenden Transkript\n\
besprochen wurde.
Antworte AUSSCHLIESSLICH mit JSON — kein Markdown, keine Erklärung:
[{{"item": "<Agendapunkt>", "covered": true oder false, "time": "<mm:ss des ersten Belegs, sonst leer>", "evidence": "<kurzes Zitat, maximal 15 Wörter, sonst leer>"}}]
Agenda:
{agenda}
Transkript:
{transcript}
"""
def _generate_summary(rid: str) -> None:
rec_dir = REC_DIR / rid
try:
transcript = _timestamped_transcript(rec_dir)
if not transcript.strip():
raise RuntimeError("no transcript available")
prompt = _SUMMARY_PROMPT.format(transcript=transcript[:TRANSCRIPT_MAX_CHARS])
(rec_dir / "summary.md").write_text(_ollama_chat(prompt), 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]}")
def _check_agenda(rid: str) -> None:
item = _update_item(rid) # read-only pass
items = item.get("agenda") if item else None
if not items:
return
rec_dir = REC_DIR / rid
try:
transcript = _timestamped_transcript(rec_dir)
if not transcript.strip():
raise RuntimeError("no transcript available")
agenda = "\n".join(f"- {a}" for a in items)
raw = _ollama_chat(
_AGENDA_PROMPT.format(agenda=agenda,
transcript=transcript[:TRANSCRIPT_MAX_CHARS]),
)
start, end = raw.find("["), raw.rfind("]")
if start < 0 or end <= start:
raise RuntimeError("model returned no JSON")
results = json.loads(raw[start:end + 1])
if not isinstance(results, list):
raise RuntimeError("model returned unexpected JSON")
clean = [
{
"item": str(r.get("item", ""))[:300],
"covered": bool(r.get("covered", False)),
"time": str(r.get("time", ""))[:20],
"evidence": str(r.get("evidence", ""))[:200],
}
for r in results if isinstance(r, dict)
]
(rec_dir / "agenda.json").write_text(
json.dumps(clean, ensure_ascii=False, indent=2), encoding="utf-8")
_update_item(rid, agenda_results=clean, agenda_status="done")
except Exception as e: # noqa: BLE001
_update_item(rid, agenda_results=[{"error": str(e)[:120]}],
agenda_status=f"error: {str(e)[:120]}")
def _postprocess(rid: str) -> None:
"""Background pipeline after an upload: summary, then agenda check."""
_generate_summary(rid)
_check_agenda(rid)
@app.get("/api/health")
def health() -> dict:
with _lock:
@@ -115,14 +262,18 @@ async def upload(
"device": device,
"agenda": agenda_items,
"files": sorted(stored),
"summary": None, # set in M3c
"summary": None, # none | pending | done | error: ...
"agenda_results": None,
"agenda_status": None,
"uploaded_at": time.time(),
}
with _lock:
items = _load_index()
items.append(item)
_save_index(items)
if OLLAMA_URL:
threading.Thread(target=_postprocess, args=(rid,), daemon=True).start()
return {"id": rid, "files": sorted(stored)}
@@ -151,6 +302,34 @@ def get_file(rid: str, name: str) -> FileResponse:
return FileResponse(path)
@app.post("/api/recordings/{rid}/summary", status_code=202)
def trigger_summary(rid: str, force: bool = False) -> dict:
with _lock:
item = _find(_load_index(), rid)
if item is None:
raise HTTPException(404, "unknown recording id")
if item.get("summary") == "pending" and not force:
return {"status": "pending"}
_update_item(rid, summary="pending")
threading.Thread(target=_generate_summary, args=(rid,), daemon=True).start()
return {"status": "started"}
@app.post("/api/recordings/{rid}/agenda", status_code=202)
def trigger_agenda(rid: str, force: bool = False) -> dict:
with _lock:
item = _find(_load_index(), rid)
if item is None:
raise HTTPException(404, "unknown recording id")
if not item.get("agenda"):
raise HTTPException(400, "recording has no agenda items")
if item.get("agenda_status") == "pending" and not force:
return {"status": "pending"}
_update_item(rid, agenda_status="pending")
threading.Thread(target=_check_agenda, args=(rid,), daemon=True).start()
return {"status": "started"}
@app.delete("/api/recordings/{rid}", status_code=204)
def delete_recording(rid: str) -> None:
if not RID_RE.match(rid):