Detail: personality picker beside Re-summarize

Chips: Neutral / Sarcastic / Funny / Dry wit. The chosen voice rides
the reprocess call and the button echoes it ('Re-summarize · Sarcastic');
an existing summary shows '· in a sarcastic voice' next to its heading.
compileKotlin + 39 unit tests green.
This commit is contained in:
avi 2026-09-15 14:24:36 -05:00
commit 994dbc3690
4 changed files with 61 additions and 10 deletions

View file

@ -1154,7 +1154,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
* endpoint as the upload path. [model] is sent for "transcribe" so a * endpoint as the upload path. [model] is sent for "transcribe" so a
* model override switch persists on the server row. * model override switch persists on the server row.
*/ */
fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null) { fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null,
tone: String? = null) {
val remoteId = d0.remoteId ?: return val remoteId = d0.remoteId ?: return
pollJob?.cancel() pollJob?.cancel()
pollJob = scope.launch { pollJob = scope.launch {
@ -1164,7 +1165,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d0.uploadProgress = null d0.uploadProgress = null
d0.error = null d0.error = null
_detail.value = d0.copy() _detail.value = d0.copy()
runCatching { provider.reprocess(remoteId, job, model) }.onFailure { runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure {
if (job == "transcribe" && it is ProviderError.NotFound) { if (job == "transcribe" && it is ProviderError.NotFound) {
// Mapping points at a deleted recording: drop it so the // Mapping points at a deleted recording: drop it so the
// next attempt re-uploads instead of failing forever. // next attempt re-uploads instead of failing forever.
@ -1223,12 +1224,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
/** "Summarize" button: regenerate the summary in place from the /** "Summarize" button: regenerate the summary in place from the
* current transcript. Requires a known server recording. */ * current transcript. Requires a known server recording.
fun summarize() { * [tone] (e.g. "sarcastic") restyles the voice; null = neutral. */
fun summarize(tone: String? = null) {
val d = _detail.value ?: return val d = _detail.value ?: return
val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return
d.remoteId = remoteId d.remoteId = remoteId
reprocessInPlace(d, job = "summarize") reprocessInPlace(d, job = "summarize", tone = tone)
} }
/** Server recording id for a library file, if it was ever uploaded. */ /** Server recording id for a library file, if it was ever uploaded. */

View file

@ -28,6 +28,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@ -514,6 +515,16 @@ fun LibraryScreen(state: DesktopState) {
// ---- detail ----------------------------------------------------------------- // ---- detail -----------------------------------------------------------------
/** Personalities offered beside Re-summarize: (label, tone sent to the
* engine). The tone string is interpolated into "in a <tone> tone of
* voice", so it must read naturally there. null = neutral house voice. */
private val SUMMARIZE_PERSONAS = listOf(
"Neutral" to null,
"Sarcastic" to "sarcastic",
"Funny" to "funny",
"Dry wit" to "dry, witty",
)
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
fun DetailScreen(state: DesktopState) { fun DetailScreen(state: DesktopState) {
val detail = state.detail.collectAsState().value ?: run { val detail = state.detail.collectAsState().value ?: run {
@ -525,6 +536,8 @@ fun DetailScreen(state: DesktopState) {
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
var deleteError by remember { mutableStateOf<String?>(null) } var deleteError by remember { mutableStateOf<String?>(null) }
var recQuery by remember { mutableStateOf("") } var recQuery by remember { mutableStateOf("") }
// Voice for the next summarize: null = Neutral (server default).
var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) }
val playingFile by state.playingFile.collectAsState() val playingFile by state.playingFile.collectAsState()
val playError by state.playError.collectAsState() val playError by state.playError.collectAsState()
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
@ -718,7 +731,16 @@ fun DetailScreen(state: DesktopState) {
} }
detail.summary?.let { s -> detail.summary?.let { s ->
if (s.short.isNotBlank()) { if (s.short.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Summary", style = MaterialTheme.typography.titleMedium) Text("Summary", style = MaterialTheme.typography.titleMedium)
// Honest tag: which voice this summary was written in.
s.tone?.let {
Spacer(Modifier.width(8.dp))
Text("· in a $it voice",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary)
}
}
Text(s.short) Text(s.short)
} }
} }
@ -748,13 +770,32 @@ fun DetailScreen(state: DesktopState) {
val hasSummary = detail.summary != null || val hasSummary = detail.summary != null ||
(report?.contains("## Summary") == true) (report?.contains("## Summary") == true)
val remoteKnown = state.remoteIdFor(detail.file) != null val remoteKnown = state.remoteIdFor(detail.file) != null
// Personality for the next summary: pick a voice, then hit
// Re-summarize. Neutral = the plain house voice.
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Summarize in the voice of",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
FilterChip(
selected = sumTone == tone,
onClick = { sumTone = tone },
label = { Text(label) },
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton({ state.transcribe() }) { TextButton({ state.transcribe() }) {
Text(if (hasTranscript) "Re-transcribe" else "Transcribe") Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
} }
TextButton({ state.summarize() }, TextButton({ state.summarize(sumTone) },
enabled = remoteKnown && hasTranscript) { enabled = remoteKnown && hasTranscript) {
Text(if (hasSummary) "Re-summarize" else "Summarize") Text(buildString {
append(if (hasSummary) "Re-summarize" else "Summarize")
sumTone?.let { append(" · $it") }
})
} }
} }
if (!remoteKnown || !hasTranscript) { if (!remoteKnown || !hasTranscript) {

View file

@ -311,11 +311,16 @@ class CustomShonarProvider(
* per-recording override). 409 = stage already running or nothing to * per-recording override). 409 = stage already running or nothing to
* summarize; surfaced as ProviderError.Transient with the server's * summarize; surfaced as ProviderError.Transient with the server's
* detail message. */ * detail message. */
suspend fun reprocess(recordingId: String, job: String, model: String? = null): String = suspend fun reprocess(recordingId: String, job: String, model: String? = null,
tone: String? = null): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ensureConnected() ensureConnected()
val base = currentOrigin() val base = currentOrigin()
val q = if (model != null) "&model=$model" else "" val q = buildString {
if (model != null) append("&model=$model")
if (tone != null)
append("&tone=").append(java.net.URLEncoder.encode(tone, "UTF-8"))
}
executeAuthed(base) { token -> executeAuthed(base) { token ->
post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}") post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}")
}.use { resp -> }.use { resp ->

View file

@ -37,6 +37,8 @@ data class SummaryData(
val version: Int, val version: Int,
val provider: String = "", val provider: String = "",
val model: String? = null, val model: String? = null,
/** Voice the summary was written in ("sarcastic", …); null = neutral. */
val tone: String? = null,
/** Raw content dict; known keys read via [summaryString]/[summaryList]. */ /** Raw content dict; known keys read via [summaryString]/[summaryList]. */
val content: Map<String, Any?> = emptyMap(), val content: Map<String, Any?> = emptyMap(),
val editedByUser: Boolean = false, val editedByUser: Boolean = false,
@ -112,6 +114,7 @@ fun parseSummary(raw: String): SummaryData? = runCatching {
version = json.optInt("version", 0), version = json.optInt("version", 0),
provider = json.optString("provider", ""), provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
tone = json.optString("tone", null).takeUnless { it.isNullOrBlank() },
content = content, content = content,
editedByUser = json.optBoolean("edited_by_user", false), editedByUser = json.optBoolean("edited_by_user", false),
) )