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:
@@ -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)
|
||||
|
||||
@@ -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<Detail?>(null) }
|
||||
var detailError by remember { mutableStateOf<String?>(null) }
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -62,6 +62,7 @@ data class SessionConfig(
|
||||
val liveModel: String?,
|
||||
val liveIntervalSec: Int = 8,
|
||||
val storageUrl: String? = null,
|
||||
val agenda: List<String> = 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")
|
||||
|
||||
@@ -17,6 +17,19 @@ object StorageClient {
|
||||
val language: String,
|
||||
val device: 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. */
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user