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