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