"""meetrec-server: storage + orchestration API for meetrec recordings. Single-user service for a private tailnet: stores the recording bundles (wav/txt/srt/json + metadata) uploaded by the meetrec apps and, later in M3c, generates meeting summaries and agenda coverage via Ollama. Storage layout (no database): data/ index.json # list of recording metadata recordings// meeting.wav / .txt / .srt / .json summary.md # (M3c) agenda.json # (M3c) """ 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 from typing import Optional from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse DATA_DIR = Path("/data") REC_DIR = DATA_DIR / "recordings" 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() def _load_index() -> list: if not INDEX_PATH.exists(): return [] with INDEX_PATH.open() as f: return json.load(f) def _save_index(items: list) -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) tmp = INDEX_PATH.with_suffix(".json.tmp") with tmp.open("w") as f: json.dump(items, f, ensure_ascii=False, indent=2) tmp.replace(INDEX_PATH) 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": "", "covered": true oder false, "time": "", "evidence": ""}}] 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: return {"ok": True, "recordings": len(_load_index())} @app.get("/api/recordings") def list_recordings() -> list: with _lock: items = _load_index() return list(reversed(items)) # newest first @app.post("/api/recordings", status_code=201) async def upload( wav: UploadFile = File(...), txt: Optional[UploadFile] = File(None), srt: Optional[UploadFile] = File(None), json_file: Optional[UploadFile] = File(None, alias="json"), started_at: str = Form(""), duration_ms: int = Form(0), language: str = Form(""), device: str = Form(""), agenda: str = Form("[]"), ) -> dict: uploads = [] for name, f in (("wav", wav), ("txt", txt), ("srt", srt), ("json", json_file)): if f is not None and f.filename: uploads.append((name, f)) if not any(name == "wav" for name, _ in uploads): raise HTTPException(400, "a 'wav' file part is required") try: agenda_items = json.loads(agenda or "[]") except json.JSONDecodeError: raise HTTPException(400, "agenda must be a JSON list of strings") if not isinstance(agenda_items, list) or not all(isinstance(a, str) for a in agenda_items): raise HTTPException(400, "agenda must be a JSON list of strings") rid = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] rec_dir = REC_DIR / rid rec_dir.mkdir(parents=True, exist_ok=True) stored = [] for name, f in uploads: dest = rec_dir / f"meeting.{name}" with dest.open("wb") as out: while chunk := await f.read(1 << 20): out.write(chunk) stored.append(dest.name) item = { "id": rid, "started_at": started_at or datetime.now(timezone.utc).isoformat(timespec="seconds"), "duration_ms": duration_ms, "language": language, "device": device, "agenda": agenda_items, "files": sorted(stored), "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)} @app.get("/api/recordings/{rid}") def get_recording(rid: str) -> dict: with _lock: item = _find(_load_index(), rid) if item is None: raise HTTPException(404, "unknown recording id") return item @app.get("/api/recordings/{rid}/files/{name}") def get_file(rid: str, name: str) -> FileResponse: if not RID_RE.match(rid) or "/" in name or ".." in name: raise HTTPException(400, "invalid id or file name") with _lock: item = _find(_load_index(), rid) if item is None: raise HTTPException(404, "unknown recording id") if name not in item["files"] and name not in ("summary.md", "agenda.json"): raise HTTPException(404, f"no such file: {name}") path = REC_DIR / rid / name if not path.is_file(): raise HTTPException(404, f"no such file: {name}") 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): raise HTTPException(400, "invalid id") with _lock: items = _load_index() item = _find(items, rid) if item is None: raise HTTPException(404, "unknown recording id") _save_index([it for it in items if it["id"] != rid]) shutil.rmtree(REC_DIR / rid, ignore_errors=True)