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
* 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
pollJob?.cancel()
pollJob = scope.launch {
@ -1164,7 +1165,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d0.uploadProgress = null
d0.error = null
_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) {
// Mapping points at a deleted recording: drop it so the
// 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
* current transcript. Requires a known server recording. */
fun summarize() {
* current transcript. Requires a known server recording.
* [tone] (e.g. "sarcastic") restyles the voice; null = neutral. */
fun summarize(tone: String? = null) {
val d = _detail.value ?: return
val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return
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. */

View file

@ -28,6 +28,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
@ -514,6 +515,16 @@ fun LibraryScreen(state: DesktopState) {
// ---- 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
fun DetailScreen(state: DesktopState) {
val detail = state.detail.collectAsState().value ?: run {
@ -525,6 +536,8 @@ fun DetailScreen(state: DesktopState) {
var confirmDelete by remember { mutableStateOf(false) }
var deleteError by remember { mutableStateOf<String?>(null) }
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 playError by state.playError.collectAsState()
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
@ -718,7 +731,16 @@ fun DetailScreen(state: DesktopState) {
}
detail.summary?.let { s ->
if (s.short.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) {
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)
}
}
@ -748,13 +770,32 @@ fun DetailScreen(state: DesktopState) {
val hasSummary = detail.summary != null ||
(report?.contains("## Summary") == true)
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)) {
TextButton({ state.transcribe() }) {
Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
}
TextButton({ state.summarize() },
TextButton({ state.summarize(sumTone) },
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) {

View file

@ -311,11 +311,16 @@ class CustomShonarProvider(
* per-recording override). 409 = stage already running or nothing to
* summarize; surfaced as ProviderError.Transient with the server's
* 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) {
ensureConnected()
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 ->
post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}")
}.use { resp ->

View file

@ -37,6 +37,8 @@ data class SummaryData(
val version: Int,
val provider: String = "",
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]. */
val content: Map<String, Any?> = emptyMap(),
val editedByUser: Boolean = false,
@ -112,6 +114,7 @@ fun parseSummary(raw: String): SummaryData? = runCatching {
version = json.optInt("version", 0),
provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
tone = json.optString("tone", null).takeUnless { it.isNullOrBlank() },
content = content,
editedByUser = json.optBoolean("edited_by_user", false),
)