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