Detail: edit the transcript in place (user version wins over re-transcribe)
This commit is contained in:
parent
ba54cb853b
commit
8e465bdb93
1 changed files with 92 additions and 9 deletions
|
|
@ -155,6 +155,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000"
|
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000"
|
||||||
_autoTranscribe.value = prefs.getString(KEY_AUTO) != "0"
|
_autoTranscribe.value = prefs.getString(KEY_AUTO) != "0"
|
||||||
|
_summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto"
|
||||||
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
||||||
?.let { setFolder(it, silent = true) }
|
?.let { setFolder(it, silent = true) }
|
||||||
ensureReady()
|
ensureReady()
|
||||||
|
|
@ -327,13 +328,18 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// transcription works with a cold ~/.cache or offline.
|
// transcription works with a cold ~/.cache or offline.
|
||||||
"HF_HUB_CACHE" to File(repo, "models/hf-cache").absolutePath,
|
"HF_HUB_CACHE" to File(repo, "models/hf-cache").absolutePath,
|
||||||
)
|
)
|
||||||
// Summarizer preference: LAN inference server (H200) when
|
// Summarizer preference (Settings): "lan" = H200 inference
|
||||||
// its key is present in the environment, then local Ollama.
|
// server (best quality, contends with other users of that
|
||||||
// LAN-only: the key never leaves the network boundary and
|
// GPU), "local" = Ollama on this laptop (private, no
|
||||||
// is read from the environment, never stored by this app.
|
// 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() }
|
val lanKey = System.getenv(ENV_LAN_LLM_KEY)?.takeIf { it.isNotBlank() }
|
||||||
?: readLanKeyFromHermesEnv()
|
?: 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_PROVIDER"] = "openai_compat"
|
||||||
env["SHONAR_LLM_BASE_URL"] = LAN_LLM_BASE_URL
|
env["SHONAR_LLM_BASE_URL"] = LAN_LLM_BASE_URL
|
||||||
env["SHONAR_LLM_MODEL"] = LAN_LLM_MODEL
|
env["SHONAR_LLM_MODEL"] = LAN_LLM_MODEL
|
||||||
|
|
@ -508,6 +514,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (on) pumpNewFiles()
|
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. */
|
/** Queue every candidate file that has no report and isn't in flight. */
|
||||||
fun pumpNewFiles() {
|
fun pumpNewFiles() {
|
||||||
val candidates = LibraryQueue.autoQueueCandidates(
|
val candidates = LibraryQueue.autoQueueCandidates(
|
||||||
|
|
@ -1308,6 +1333,41 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
reprocessInPlace(d, job = "summarize", tone = tone)
|
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. */
|
/** Server recording id for a library file, if it was ever uploaded. */
|
||||||
fun remoteIdFor(file: File): String? =
|
fun remoteIdFor(file: File): String? =
|
||||||
_detail.value?.takeIf { it.file == file }?.remoteId ?: loadMapping(file)?.recordingId
|
_detail.value?.takeIf { it.file == file }?.remoteId ?: loadMapping(file)?.recordingId
|
||||||
|
|
@ -1318,8 +1378,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Live pipeline activity for a recording: human label plus the
|
/** Live pipeline activity for a recording: human label plus the
|
||||||
* 0..1 fraction when the backend reports one. Null = idle. */
|
* 0..1 fraction when the backend reports one. Null = idle.
|
||||||
fun jobProgress(jobs: List<JobInfo>): LiveProgress? {
|
* [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 t = jobs.firstOrNull { it.jobType == "transcribe" }
|
||||||
val s = jobs.firstOrNull { it.jobType == "summarize" }
|
val s = jobs.firstOrNull { it.jobType == "summarize" }
|
||||||
return when {
|
return when {
|
||||||
|
|
@ -1333,12 +1394,32 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (s.progress != null)
|
if (s.progress != null)
|
||||||
LiveProgress("summarizing… ${s.progress}%", s.progress / 100f)
|
LiveProgress("summarizing… ${s.progress}%", s.progress / 100f)
|
||||||
// No tokens yet: the LLM is cold or the server is busy
|
// No tokens yet: the LLM is cold or the server is busy
|
||||||
// queueing. Saying so beats a bar that looks frozen.
|
// queueing. Saying so (and for how long) beats a bar
|
||||||
else LiveProgress("summarizing… waiting for model", null)
|
// that looks frozen.
|
||||||
|
else LiveProgress("summarizing… waiting for model" +
|
||||||
|
waitSuffix(s.startedAt, nowMs), null)
|
||||||
else -> 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. */
|
/** Label-only convenience for the Detail screen's busy text. */
|
||||||
fun jobLabel(jobs: List<JobInfo>): String? = jobProgress(jobs)?.label
|
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_PASSWORD = "local.password"
|
||||||
const val KEY_SECRET = "local.secret"
|
const val KEY_SECRET = "local.secret"
|
||||||
const val KEY_AUTO = "library.auto_transcribe"
|
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). */
|
/** Preferred local summarization model (Ollama). */
|
||||||
const val DEFAULT_LLM_MODEL = "qwen3:4b"
|
const val DEFAULT_LLM_MODEL = "qwen3:4b"
|
||||||
/** LAN H200 inference server (private 10.x network, never internet). */
|
/** LAN H200 inference server (private 10.x network, never internet). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue