Recap editor: Regenerate recap (re-run LLM on corrected transcript)

Adds a 'Regenerate recap' action so corrected speaker names flow into freshly
written summaries/extras (not just find-replaced). regenerate() commits the
corrections (rewrite speakers.json + reconcile voiceprints), re-runs RecapAnalyzer
on the corrected transcript via the gateway LLM, and rewrites recap.json +
transcript.md + recap.html. save() and regenerate() share commitCorrections();
both rebaseline the speaker set afterward so further edits map cleanly. Editor view
gains the button + progress spinner; RecapEditModel takes the gateway baseURL/skipTLS.

52/52 XCTest; builds clean.
This commit is contained in:
Grant Gilliam
2026-06-06 16:48:18 -05:00
parent 4c086251d9
commit 10ddf9992a
4 changed files with 76 additions and 18 deletions
+64 -13
View File
@@ -10,31 +10,39 @@ final class RecapEditModel: ObservableObject {
let folder: URL
let title: String
private let voiceprints: VoiceprintStore
private let baseURL: String
private let skipTLS: Bool
private let base: SpeakersFile
private var recapFile: RecapFile?
private let clusterFingerprints: [String: [Float]]
private let originalSpeakers: [String]
private var originalSpeakers: [String]
private var renameOps: [(from: String, to: String)] = []
@Published private(set) var segments: [SpeakersFile.Segment]
@Published private(set) var speakers: [String]
@Published private(set) var dirty = false
@Published private(set) var regenerating = false
@Published private(set) var hasRecap: Bool
@Published private(set) var status: String?
init?(folder: URL, voiceprints: VoiceprintStore) {
init?(folder: URL, voiceprints: VoiceprintStore, baseURL: String, skipTLS: Bool) {
let speakersURL = folder.appendingPathComponent("speakers.json")
guard let data = try? Data(contentsOf: speakersURL),
let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
!file.segments.isEmpty else { return nil }
self.folder = folder
self.voiceprints = voiceprints
self.baseURL = baseURL
self.skipTLS = skipTLS
self.base = file
self.segments = file.segments
self.speakers = SpeakerEditing.orderedSpeakers(file.segments)
self.originalSpeakers = SpeakerEditing.orderedSpeakers(file.segments)
self.recapFile = RecapFile.read(from: folder.appendingPathComponent("recap.json"))
let rf = RecapFile.read(from: folder.appendingPathComponent("recap.json"))
self.recapFile = rf
self.hasRecap = rf != nil
self.clusterFingerprints = Self.loadFingerprints(folder.appendingPathComponent("cluster_fingerprints.json"))
self.title = recapFile?.title ?? file.app.capitalized + " call"
self.title = rf?.title ?? file.app.capitalized + " call"
}
// MARK: - Edits
@@ -68,21 +76,59 @@ final class RecapEditModel: ObservableObject {
// MARK: - Save
/// Persist corrections: rewrite speakers.json, re-render artifacts, update voiceprints.
/// Persist corrections: rewrite speakers.json, re-render the recap with names
/// remapped in place (fast, no LLM), and update voiceprints.
func save() {
let newSpeakers = buildSpeakerList()
let file = SpeakersFile(sessionId: base.sessionId, app: base.app, durationSec: base.durationSec,
speakers: newSpeakers, segments: segments, models: base.models)
try? file.write(to: folder.appendingPathComponent("speakers.json"))
let file = commitCorrections()
let net = SpeakerEditing.netNameMap(originals: originalSpeakers, ops: renameOps)
let result = recapFile.map { SpeakerEditing.remap($0.result, names: net) } ?? RecapResult(sections: [], extras: nil)
if recapFile != nil {
try? RecapFile(title: title, result: result).write(to: folder.appendingPathComponent("recap.json"))
let rf = RecapFile(title: title, result: result)
recapFile = rf
try? rf.write(to: folder.appendingPathComponent("recap.json"))
}
try? RecapRenderer.write(file: file, result: result, title: title, to: folder)
rebaseline()
status = "Saved — recap.html & transcript.md updated."
}
// Voiceprints: reconcile per the net rename/merge map.
/// Re-run the LLM analysis on the CORRECTED transcript, so summaries/extras are
/// freshly written with the corrected names (not just find-replaced). Commits the
/// corrections first; needs the gateway LLM (no-op message if unavailable).
func regenerate() async {
guard !regenerating else { return }
regenerating = true
status = "Regenerating recap…"
defer { regenerating = false }
let file = commitCorrections()
let llm = GatewayLLMClient(baseURL: baseURL, skipTLS: skipTLS)
guard let model = await llm.chatModelId() else {
status = "No language model on the gateway — saved corrections only."
rebaseline(); return
}
let analyzer = RecapAnalyzer(llm: llm, model: model)
guard let result = try? await analyzer.recap(file: file) else {
status = "Recap regeneration failed — corrections were saved."
rebaseline(); return
}
let rf = RecapFile(title: title, result: result)
recapFile = rf
hasRecap = true
try? rf.write(to: folder.appendingPathComponent("recap.json"))
try? RecapRenderer.write(file: file, result: result, title: title, to: folder)
rebaseline()
status = "Recap regenerated with corrected names."
}
/// Write the corrected speakers.json and reconcile the voiceprint store. Shared by
/// save() and regenerate(); does NOT clear renameOps (caller rebaselines after).
private func commitCorrections() -> SpeakersFile {
let file = SpeakersFile(sessionId: base.sessionId, app: base.app, durationSec: base.durationSec,
speakers: buildSpeakerList(), segments: segments, models: base.models)
try? file.write(to: folder.appendingPathComponent("speakers.json"))
let net = SpeakerEditing.netNameMap(originals: originalSpeakers, ops: renameOps)
let stored = voiceprints.knownVoiceprints()
for (orig, final) in net where !LabelMergeResponse.isUnknownName(final) {
let finalHasPrint = clusterFingerprints[final] != nil || stored[final] != nil
@@ -97,10 +143,15 @@ final class RecapEditModel: ObservableObject {
voiceprints.rename(orig, to: final)
}
}
return file
}
/// After a commit, the corrected names become the new baseline so further edits
/// map cleanly (and the now-baked-in recap isn't double-remapped).
private func rebaseline() {
originalSpeakers = SpeakerEditing.orderedSpeakers(segments)
renameOps.removeAll()
dirty = false
status = "Saved — recap.html & transcript.md updated."
}
/// Speaker roster from the edited segments: keep the original source where the
@@ -433,7 +433,9 @@ final class SessionController: ObservableObject {
/// Open the speaker-correction editor for the last session.
func editLastSession() {
guard let folder = lastSession?.folder,
let model = RecapEditModel(folder: folder, voiceprints: voiceprints) else { return }
let model = RecapEditModel(folder: folder, voiceprints: voiceprints,
baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
else { return }
EditorWindow.shared.show(model: model)
}
@@ -94,11 +94,15 @@ struct TranscriptEditorView: View {
// MARK: - Footer
private var footer: some View {
HStack {
HStack(spacing: 10) {
Button("Save corrections") { model.save() }
.keyboardShortcut("s", modifiers: .command)
.disabled(!model.dirty)
if let s = model.status { Text(s).font(.caption).foregroundStyle(.green) }
.disabled(!model.dirty || model.regenerating)
Button("Regenerate recap") { Task { await model.regenerate() } }
.help("Re-run the analysis on the corrected transcript so summaries use the fixed names.")
.disabled(model.regenerating)
if model.regenerating { ProgressView().controlSize(.small) }
if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) }
Spacer()
if let recap = recapURL {
Button("Open recap") { NSWorkspace.shared.open(recap) }
@@ -68,7 +68,8 @@ final class SpeakerEditingTests: XCTestCase {
.write(to: dir.appendingPathComponent("cluster_fingerprints.json"))
let store = VoiceprintStore(fileURL: dir.appendingPathComponent("voiceprints.json"))
let model = try XCTUnwrap(RecapEditModel(folder: dir, voiceprints: store))
let model = try XCTUnwrap(RecapEditModel(folder: dir, voiceprints: store,
baseURL: "https://localhost:1", skipTLS: true))
model.rename("Unknown_0", to: "Caitlyn")
XCTAssertTrue(model.speakers.contains("Caitlyn"))
XCTAssertFalse(model.speakers.contains("Unknown_0"))