From 8e465bdb933ba903b8be5992c63845b223f32be3 Mon Sep 17 00:00:00 2001 From: avi Date: Wed, 16 Sep 2026 11:26:15 -0500 Subject: [PATCH] Detail: edit the transcript in place (user version wins over re-transcribe) --- .../kotlin/com/shonar/desktop/DesktopState.kt | 101 ++++++++++++++++-- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index ea94adf..6593e0c 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -155,6 +155,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { scope.launch { _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" _autoTranscribe.value = prefs.getString(KEY_AUTO) != "0" + _summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto" prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } ?.let { setFolder(it, silent = true) } ensureReady() @@ -327,13 +328,18 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // transcription works with a cold ~/.cache or offline. "HF_HUB_CACHE" to File(repo, "models/hf-cache").absolutePath, ) - // Summarizer preference: LAN inference server (H200) when - // its key is present in the environment, then local Ollama. - // LAN-only: the key never leaves the network boundary and - // is read from the environment, never stored by this app. + // Summarizer preference (Settings): "lan" = H200 inference + // server (best quality, contends with other users of that + // GPU), "local" = Ollama on this laptop (private, no + // contention, smaller model). "auto" = LAN when its key is + // present, else Ollama. LAN-only: the key never leaves the + // network boundary and is read from the environment, never + // stored by this app. val lanKey = System.getenv(ENV_LAN_LLM_KEY)?.takeIf { it.isNotBlank() } ?: readLanKeyFromHermesEnv() - if (lanKey.isNotBlank()) { + val want = prefs.getString(KEY_SUMMARIZER) ?: "auto" + val lanUsable = lanKey.isNotBlank() + if ((want == "lan" || want == "auto") && lanUsable) { env["SHONAR_LLM_PROVIDER"] = "openai_compat" env["SHONAR_LLM_BASE_URL"] = LAN_LLM_BASE_URL env["SHONAR_LLM_MODEL"] = LAN_LLM_MODEL @@ -508,6 +514,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (on) pumpNewFiles() } + /** Which engine summarizes: "auto" (LAN H200 when its key is set, + * else local Ollama), "lan" (prefer the H200), or "local" (always + * laptop Ollama — no contention with other GPU users, smaller + * model). Switching restarts the engine so it picks up the choice; + * running jobs are picked back up by startup adoption. */ + private val _summarizer = MutableStateFlow("auto") + val summarizer: StateFlow = _summarizer.asStateFlow() + + fun setSummarizer(value: String) { + if (value !in setOf("auto", "lan", "local")) return + if (value == _summarizer.value) return + scope.launch { + prefs.putString(KEY_SUMMARIZER, value) + _summarizer.value = value + stopEngine() + ensureReady() + } + } + /** Queue every candidate file that has no report and isn't in flight. */ fun pumpNewFiles() { val candidates = LibraryQueue.autoQueueCandidates( @@ -1308,6 +1333,41 @@ class DesktopState(private val appDir: File = defaultAppDir()) { reprocessInPlace(d, job = "summarize", tone = tone) } + /** + * Persist a user transcript edit. The engine stores it as a new + * version with edited_by_user=true — a verdict: a later re-transcribe + * never overwrites it. The local report is rewritten from the stored + * row so the library copy matches. Plain-text edit: segment + * timestamps are dropped (the user's words outrank the timings). + */ + fun saveTranscriptEdit(text: String) { + val d = _detail.value ?: return + val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return + d.remoteId = remoteId + if (text.isBlank() || text == d.transcript?.text) return + scope.launch { + d.busy = "Saving transcript…" + d.error = null + _detail.value = d.copy() + runCatching { parseTranscript(provider.updateTranscript(remoteId, + org.json.JSONObject().put("text", text).toString())) } + .onSuccess { saved -> + if (saved != null) { + d.transcript = saved + saveReport(d) + } + d.busy = null + if (_detail.value?.file == d.file) _detail.value = d.copy() + rescanStatuses() + } + .onFailure { + d.busy = null + d.error = it.message ?: "Transcript save failed." + if (_detail.value?.file == d.file) _detail.value = d.copy() + } + } + } + /** Server recording id for a library file, if it was ever uploaded. */ fun remoteIdFor(file: File): String? = _detail.value?.takeIf { it.file == file }?.remoteId ?: loadMapping(file)?.recordingId @@ -1318,8 +1378,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) { companion object { /** Live pipeline activity for a recording: human label plus the - * 0..1 fraction when the backend reports one. Null = idle. */ - fun jobProgress(jobs: List): LiveProgress? { + * 0..1 fraction when the backend reports one. Null = idle. + * [nowMs] is injectable for tests (epoch ms). */ + fun jobProgress(jobs: List, nowMs: Long = System.currentTimeMillis()): LiveProgress? { val t = jobs.firstOrNull { it.jobType == "transcribe" } val s = jobs.firstOrNull { it.jobType == "summarize" } return when { @@ -1333,12 +1394,32 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (s.progress != null) LiveProgress("summarizing… ${s.progress}%", s.progress / 100f) // No tokens yet: the LLM is cold or the server is busy - // queueing. Saying so beats a bar that looks frozen. - else LiveProgress("summarizing… waiting for model", null) + // queueing. Saying so (and for how long) beats a bar + // that looks frozen. + else LiveProgress("summarizing… waiting for model" + + waitSuffix(s.startedAt, nowMs), null) else -> null } } + /** " · 1:23" of wait time since a job started running, "" when + * under 5s or the start time is unknown. The llama-swap backend + * exposes no queue depth, so elapsed wait is the most truthful + * signal available for how long the server has been holding us. */ + fun waitSuffix(startedAt: String?, nowMs: Long): String { + val started = runCatching { + val s = startedAt ?: return "" + // Server sends naive-UTC ISO ("…T15:50:12.556516"), sometimes with Z. + if (s.endsWith("Z")) java.time.Instant.parse(s).toEpochMilli() + else java.time.LocalDateTime.parse(s) + .toInstant(java.time.ZoneOffset.UTC).toEpochMilli() + }.getOrNull() ?: return "" + val secs = ((nowMs - started) / 1000).coerceAtLeast(0) + if (secs < 5) return "" + return if (secs < 60) " · ${secs}s" + else " · ${secs / 60}:${"%02d".format(secs % 60)}" + } + /** Label-only convenience for the Detail screen's busy text. */ fun jobLabel(jobs: List): String? = jobProgress(jobs)?.label @@ -1347,6 +1428,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { const val KEY_PASSWORD = "local.password" const val KEY_SECRET = "local.secret" const val KEY_AUTO = "library.auto_transcribe" + /** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */ + const val KEY_SUMMARIZER = "llm.summarizer" /** Preferred local summarization model (Ollama). */ const val DEFAULT_LLM_MODEL = "qwen3:4b" /** LAN H200 inference server (private 10.x network, never internet). */