Detail: edit the transcript in place (user version wins over re-transcribe)

This commit is contained in:
avi 2026-09-16 11:26:15 -05:00
commit 8e465bdb93

View file

@ -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<String> = _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<JobInfo>): LiveProgress? {
* 0..1 fraction when the backend reports one. Null = idle.
* [nowMs] is injectable for tests (epoch ms). */
fun jobProgress(jobs: List<JobInfo>, 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<JobInfo>): 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). */