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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user