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
+15
View File
@@ -37,6 +37,21 @@ The **Library** tab browses everything:
the server or from the local file), share, and delete with the server or from the local file), share, and delete with
confirmation. Refresh reloads both lists. 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 ## Requirements
- Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1) - Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1)
@@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
@@ -177,6 +178,8 @@ private fun LibraryDetail(
var detail by remember { mutableStateOf<Detail?>(null) } var detail by remember { mutableStateOf<Detail?>(null) }
var detailError by remember { mutableStateOf<String?>(null) } var detailError by remember { mutableStateOf<String?>(null) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
var meta by remember { mutableStateOf((item as? LibraryItem.Server)?.meta) }
var summaryText by remember { mutableStateOf<String?>(null) }
// simple streaming player for the WAV // simple streaming player for the WAV
var playing by remember { mutableStateOf(false) } 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) { LaunchedEffect(item) {
detail = null detail = null
detailError = 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 { detailError?.let {
Text(it, color = MaterialTheme.colorScheme.error, Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
@@ -356,7 +471,7 @@ private fun LibraryDetail(
} else { } else {
Text("Transcript (${d.segments.size} segments)", Text("Transcript (${d.segments.size} segments)",
style = MaterialTheme.typography.titleSmall) style = MaterialTheme.typography.titleSmall)
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
items(d.segments) { (startMs, text) -> items(d.segments) { (startMs, text) ->
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall) Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall)
@@ -76,6 +76,7 @@ fun RecordScreen() {
?: "http://100.103.83.12:8090", ?: "http://100.103.83.12:8090",
) )
} }
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
LaunchedEffect(transcribeOn) { LaunchedEffect(transcribeOn) {
prefs.edit().putString("transcribe_on", transcribeOn).apply() prefs.edit().putString("transcribe_on", transcribeOn).apply()
@@ -86,6 +87,9 @@ fun RecordScreen() {
LaunchedEffect(storageUrl) { LaunchedEffect(storageUrl) {
prefs.edit().putString("storage_url", storageUrl).apply() prefs.edit().putString("storage_url", storageUrl).apply()
} }
LaunchedEffect(agendaText) {
prefs.edit().putString("agenda", agendaText).apply()
}
var model by remember { mutableStateOf("tiny") } var model by remember { mutableStateOf("tiny") }
var modelMenu by remember { mutableStateOf(false) } var modelMenu by remember { mutableStateOf(false) }
@@ -207,6 +211,7 @@ fun RecordScreen() {
null null
}, },
storageUrl = storageUrl.trim().ifBlank { null }, storageUrl = storageUrl.trim().ifBlank { null },
agenda = agendaText.lines().map { it.trim() }.filter { it.isNotBlank() },
) )
if (transcribeOn == "server") { if (transcribeOn == "server") {
status = "Recording — live and final pass on ${serverUrl.trim()}" status = "Recording — live and final pass on ${serverUrl.trim()}"
@@ -301,6 +306,17 @@ fun RecordScreen() {
modifier = Modifier.fillMaxWidth(), 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 // model + language
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
@@ -62,6 +62,7 @@ data class SessionConfig(
val liveModel: String?, val liveModel: String?,
val liveIntervalSec: Int = 8, val liveIntervalSec: Int = 8,
val storageUrl: String? = null, val storageUrl: String? = null,
val agenda: List<String> = emptyList(),
) )
/** Recording state published to the UI (collect RecorderService.state). */ /** Recording state published to the UI (collect RecorderService.state). */
@@ -326,7 +327,7 @@ class RecorderService : Service() {
durationMs = durationMs, durationMs = durationMs,
language = cfg.language ?: "", language = cfg.language ?: "",
device = "Android", device = "Android",
agenda = emptyList(), agenda = cfg.agenda,
) )
} }
Log.i(TAG, "upload done: $id") Log.i(TAG, "upload done: $id")
@@ -17,6 +17,19 @@ object StorageClient {
val language: String, val language: String,
val device: String, val device: String,
val files: List<String>, val files: List<String>,
val agenda: List<String> = emptyList(),
val summary: String? = null, // null | "pending" | "done" | "error: ..."
val agendaStatus: String? = null,
val agendaResults: List<AgendaResult>? = 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. */ /** Lists recordings, newest first. */
@@ -34,25 +47,62 @@ object StorageClient {
files = o.optJSONArray("files")?.let { f -> files = o.optJSONArray("files")?.let { f ->
(0 until f.length()).map { j -> f.getString(j) } (0 until f.length()).map { j -> f.getString(j) }
} ?: emptyList(), } ?: 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). */ /** Downloads one of the recording's files (e.g. meeting.json). */
fun fetchFile(baseUrl: String, id: String, name: String): ByteArray = fun fetchFile(baseUrl: String, id: String, name: String): ByteArray =
get(url(baseUrl, "/api/recordings/$id/files/$name")) get(url(baseUrl, "/api/recordings/$id/files/$name"))
/** Deletes a recording (204 expected). */ /** Deletes a recording (204 expected). */
fun delete(baseUrl: String, id: String) { fun delete(baseUrl: String, id: String) {
val conn = (URL(url(baseUrl, "/api/recordings/$id")) post(baseUrl, "/api/recordings/$id", method = "DELETE")
.openConnection() as HttpURLConnection).apply { }
requestMethod = "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 connectTimeout = 15_000
readTimeout = 30_000 readTimeout = 60_000
} }
try { try {
if (conn.responseCode !in 200..299) { val code = conn.responseCode
throw IOException("server returned ${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 { } finally {
conn.disconnect() conn.disconnect()
+180 -1
View File
@@ -14,10 +14,12 @@ Storage layout (no database):
""" """
import json import json
import os
import re import re
import shutil import shutil
import threading import threading
import time import time
import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -33,6 +35,14 @@ INDEX_PATH = DATA_DIR / "index.json"
ALLOWED_FILES = ("wav", "txt", "srt", "json") ALLOWED_FILES = ("wav", "txt", "srt", "json")
RID_RE = re.compile(r"^[A-Za-z0-9-]+$") 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") app = FastAPI(title="meetrec-server", version="0.1.0")
_lock = threading.Lock() _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) 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") @app.get("/api/health")
def health() -> dict: def health() -> dict:
with _lock: with _lock:
@@ -115,14 +262,18 @@ async def upload(
"device": device, "device": device,
"agenda": agenda_items, "agenda": agenda_items,
"files": sorted(stored), "files": sorted(stored),
"summary": None, # set in M3c "summary": None, # none | pending | done | error: ...
"agenda_results": None, "agenda_results": None,
"agenda_status": None,
"uploaded_at": time.time(), "uploaded_at": time.time(),
} }
with _lock: with _lock:
items = _load_index() items = _load_index()
items.append(item) items.append(item)
_save_index(items) _save_index(items)
if OLLAMA_URL:
threading.Thread(target=_postprocess, args=(rid,), daemon=True).start()
return {"id": rid, "files": sorted(stored)} return {"id": rid, "files": sorted(stored)}
@@ -151,6 +302,34 @@ def get_file(rid: str, name: str) -> FileResponse:
return FileResponse(path) 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) @app.delete("/api/recordings/{rid}", status_code=204)
def delete_recording(rid: str) -> None: def delete_recording(rid: str) -> None:
if not RID_RE.match(rid): if not RID_RE.match(rid):