diff --git a/android/README.md b/android/README.md index fd68e44..7213801 100644 --- a/android/README.md +++ b/android/README.md @@ -37,6 +37,21 @@ The **Library** tab browses everything: the server or from the local file), share, and delete with confirmation. Refresh reloads both lists. +## Summaries and agenda (Ollama) + +Enter **agenda items on the Record tab (one per line, persisted)** before +recording. After the recording uploads, meetrec-server automatically: + +1. writes a German **summary** (gemma4:12b via your Ollama server): + topic, key points, decisions, to-dos — shown on the recording's + detail page +2. checks **which agenda items were actually discussed** — the detail + page shows a ✓/✗ checklist with the timestamp and a quote as evidence + +Both can be re-triggered from the detail page ("Neu erstellen" / +"Agenda neu prüfen"). Note: very long meetings may exceed the LLM's +context window (the transcript is then truncated). + ## Requirements - Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1) diff --git a/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt index 5a26036..346f190 100644 --- a/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt +++ b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import java.io.File import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject @@ -177,6 +178,8 @@ private fun LibraryDetail( var detail by remember { mutableStateOf(null) } var detailError by remember { mutableStateOf(null) } var confirmDelete by remember { mutableStateOf(false) } + var meta by remember { mutableStateOf((item as? LibraryItem.Server)?.meta) } + var summaryText by remember { mutableStateOf(null) } // simple streaming player for the WAV var playing by remember { mutableStateOf(false) } @@ -202,6 +205,38 @@ private fun LibraryDetail( } } + // poll while the server is generating (summary + agenda run via Ollama) + LaunchedEffect(meta?.summary, meta?.agendaStatus) { + val m = meta ?: return@LaunchedEffect + val url = storageUrl.trim().trimEnd('/') + if (m.summary == "pending" || m.agendaStatus == "pending") { + delay(5_000) + try { + meta = withContext(Dispatchers.IO) { + StorageClient.fetchMeta(url, m.id) + } + } catch (_: Exception) { + } + } + } + + // fetch the summary once it is marked done + LaunchedEffect(meta?.summary) { + val m = meta ?: return@LaunchedEffect + val url = storageUrl.trim().trimEnd('/') + summaryText = if (m.summary == "done") { + try { + withContext(Dispatchers.IO) { + StorageClient.fetchFile(url, m.id, "summary.md").decodeToString() + } + } catch (e: Exception) { + "(summary unavailable: ${e.message})" + } + } else { + null + } + } + LaunchedEffect(item) { detail = null detailError = null @@ -339,6 +374,86 @@ private fun LibraryDetail( ) } + // summary + agenda (server recordings only) + meta?.let { m -> + val url = storageUrl.trim().trimEnd('/') + Text("Zusammenfassung", style = MaterialTheme.typography.titleSmall) + when { + m.summary == "pending" -> Text("wird erstellt …", + style = MaterialTheme.typography.bodySmall) + summaryText != null -> Text(summaryText!!, + style = MaterialTheme.typography.bodyMedium) + m.summary != null && m.summary.startsWith("error") -> { + Text(m.summary, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + OutlinedButton(onClick = { + scope.launch { + try { + withContext(Dispatchers.IO) { + StorageClient.triggerSummary(url, m.id) + } + meta = withContext(Dispatchers.IO) { + StorageClient.fetchMeta(url, m.id) + } + } catch (e: Exception) { + detailError = "summary trigger failed: ${e.message}" + } + } + }) { + Text(if (m.summary == null) "Zusammenfassung erstellen" else "Neu erstellen") + } + + if (m.agenda.isNotEmpty()) { + Text("Agenda", style = MaterialTheme.typography.titleSmall) + if (m.agendaStatus == "pending") { + Text("wird geprüft …", style = MaterialTheme.typography.bodySmall) + } + m.agendaResults?.forEach { r -> + if (r.error != null) { + Text("Agenda-Prüfung fehlgeschlagen: ${r.error}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + if (r.covered) "✓" else "✗", + color = if (r.covered) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + Text( + r.item + if (r.time.isNotBlank()) " (${r.time})" else "", + style = MaterialTheme.typography.bodyMedium, + ) + } + if (r.evidence.isNotBlank()) { + Text("\u201C${r.evidence}\u201D", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + OutlinedButton(onClick = { + scope.launch { + try { + withContext(Dispatchers.IO) { + StorageClient.triggerAgenda(url, m.id) + } + meta = withContext(Dispatchers.IO) { + StorageClient.fetchMeta(url, m.id) + } + } catch (e: Exception) { + detailError = "agenda trigger failed: ${e.message}" + } + } + }) { Text("Agenda neu prüfen") } + } + } + detailError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) @@ -356,7 +471,7 @@ private fun LibraryDetail( } else { Text("Transcript (${d.segments.size} segments)", style = MaterialTheme.typography.titleSmall) - LazyColumn(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { items(d.segments) { (startMs, text) -> Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall) diff --git a/android/app/src/main/java/com/meetrec/android/RecordScreen.kt b/android/app/src/main/java/com/meetrec/android/RecordScreen.kt index b189fe2..b033afc 100644 --- a/android/app/src/main/java/com/meetrec/android/RecordScreen.kt +++ b/android/app/src/main/java/com/meetrec/android/RecordScreen.kt @@ -76,6 +76,7 @@ fun RecordScreen() { ?: "http://100.103.83.12:8090", ) } + var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") } LaunchedEffect(transcribeOn) { prefs.edit().putString("transcribe_on", transcribeOn).apply() @@ -86,6 +87,9 @@ fun RecordScreen() { LaunchedEffect(storageUrl) { prefs.edit().putString("storage_url", storageUrl).apply() } + LaunchedEffect(agendaText) { + prefs.edit().putString("agenda", agendaText).apply() + } var model by remember { mutableStateOf("tiny") } var modelMenu by remember { mutableStateOf(false) } @@ -207,6 +211,7 @@ fun RecordScreen() { null }, storageUrl = storageUrl.trim().ifBlank { null }, + agenda = agendaText.lines().map { it.trim() }.filter { it.isNotBlank() }, ) if (transcribeOn == "server") { status = "Recording — live and final pass on ${serverUrl.trim()}" @@ -301,6 +306,17 @@ fun RecordScreen() { modifier = Modifier.fillMaxWidth(), ) + // agenda: one item per line, checked against the transcript after upload + OutlinedTextField( + value = agendaText, + onValueChange = { agendaText = it }, + label = { Text("Agenda (one item per line)") }, + placeholder = { Text("Budget\nZeitplan\n…") }, + minLines = 1, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + // model + language Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { ExposedDropdownMenuBox( diff --git a/android/app/src/main/java/com/meetrec/android/RecorderService.kt b/android/app/src/main/java/com/meetrec/android/RecorderService.kt index 168e80c..7e13e0a 100644 --- a/android/app/src/main/java/com/meetrec/android/RecorderService.kt +++ b/android/app/src/main/java/com/meetrec/android/RecorderService.kt @@ -62,6 +62,7 @@ data class SessionConfig( val liveModel: String?, val liveIntervalSec: Int = 8, val storageUrl: String? = null, + val agenda: List = emptyList(), ) /** Recording state published to the UI (collect RecorderService.state). */ @@ -326,7 +327,7 @@ class RecorderService : Service() { durationMs = durationMs, language = cfg.language ?: "", device = "Android", - agenda = emptyList(), + agenda = cfg.agenda, ) } Log.i(TAG, "upload done: $id") diff --git a/android/app/src/main/java/com/meetrec/android/StorageClient.kt b/android/app/src/main/java/com/meetrec/android/StorageClient.kt index 5442644..19ffd64 100644 --- a/android/app/src/main/java/com/meetrec/android/StorageClient.kt +++ b/android/app/src/main/java/com/meetrec/android/StorageClient.kt @@ -17,6 +17,19 @@ object StorageClient { val language: String, val device: String, val files: List, + val agenda: List = emptyList(), + val summary: String? = null, // null | "pending" | "done" | "error: ..." + val agendaStatus: String? = null, + val agendaResults: List? = null, + ) + + /** Agenda coverage as judged by the LLM. */ + data class AgendaResult( + val item: String, + val covered: Boolean, + val time: String, + val evidence: String, + val error: String? = null, ) /** Lists recordings, newest first. */ @@ -34,25 +47,62 @@ object StorageClient { files = o.optJSONArray("files")?.let { f -> (0 until f.length()).map { j -> f.getString(j) } } ?: emptyList(), + agenda = o.optJSONArray("agenda")?.let { a -> + (0 until a.length()).map { j -> a.getString(j) } + } ?: emptyList(), + summary = if (o.isNull("summary")) null else o.optString("summary"), + agendaStatus = if (o.isNull("agenda_status")) null else o.optString("agenda_status"), + agendaResults = o.optJSONArray("agenda_results")?.let { a -> + (0 until a.length()).mapNotNull { j -> + val r = a.optJSONObject(j) ?: return@mapNotNull null + AgendaResult( + item = r.optString("item", ""), + covered = r.optBoolean("covered", false), + time = r.optString("time", ""), + evidence = r.optString("evidence", ""), + error = r.optString("error", "").ifBlank { null }, + ) + } + }, ) } } + /** Fetches a single recording's metadata (for polling summaries). */ + fun fetchMeta(baseUrl: String, id: String): RecordingMeta = + list(baseUrl).first { it.id == id } + /** Downloads one of the recording's files (e.g. meeting.json). */ fun fetchFile(baseUrl: String, id: String, name: String): ByteArray = get(url(baseUrl, "/api/recordings/$id/files/$name")) /** Deletes a recording (204 expected). */ fun delete(baseUrl: String, id: String) { - val conn = (URL(url(baseUrl, "/api/recordings/$id")) - .openConnection() as HttpURLConnection).apply { - requestMethod = "DELETE" + post(baseUrl, "/api/recordings/$id", method = "DELETE") + } + + /** Asks the server to (re)generate the summary via Ollama. */ + fun triggerSummary(baseUrl: String, id: String) { + post(baseUrl, "/api/recordings/$id/summary") + } + + /** Asks the server to (re)check agenda coverage via Ollama. */ + fun triggerAgenda(baseUrl: String, id: String) { + post(baseUrl, "/api/recordings/$id/agenda") + } + + private fun post(baseUrl: String, path: String, method: String = "POST") { + val conn = (URL(url(baseUrl, path)).openConnection() as HttpURLConnection).apply { + requestMethod = method connectTimeout = 15_000 - readTimeout = 30_000 + readTimeout = 60_000 } try { - if (conn.responseCode !in 200..299) { - throw IOException("server returned ${conn.responseCode}") + val code = conn.responseCode + if (code !in 200..299) { + val resp = conn.errorStream?.use { it.readBytes() } ?: ByteArray(0) + throw IOException("server returned $code: " + + String(resp, 0, minOf(resp.size, 200))) } } finally { conn.disconnect() diff --git a/server/meetrec-server/main.py b/server/meetrec-server/main.py index 7d0a79f..1b6eee9 100644 --- a/server/meetrec-server/main.py +++ b/server/meetrec-server/main.py @@ -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": "", "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: @@ -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):