From 10ddf9992ad9752308ecba33d7aa0fb6bd5ccf00 Mon Sep 17 00:00:00 2001 From: Grant Gilliam Date: Sat, 6 Jun 2026 16:48:18 -0500 Subject: [PATCH] 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. --- Ten31Transcripts/Recap/RecapEditModel.swift | 77 +++++++++++++++---- .../Session/SessionController.swift | 4 +- .../UI/TranscriptEditorView.swift | 10 ++- .../SpeakerEditingTests.swift | 3 +- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Ten31Transcripts/Recap/RecapEditModel.swift b/Ten31Transcripts/Recap/RecapEditModel.swift index 2f8bb5a..ebb33ad 100644 --- a/Ten31Transcripts/Recap/RecapEditModel.swift +++ b/Ten31Transcripts/Recap/RecapEditModel.swift @@ -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 diff --git a/Ten31Transcripts/Session/SessionController.swift b/Ten31Transcripts/Session/SessionController.swift index fc4e156..c2a3acd 100644 --- a/Ten31Transcripts/Session/SessionController.swift +++ b/Ten31Transcripts/Session/SessionController.swift @@ -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) } diff --git a/Ten31Transcripts/UI/TranscriptEditorView.swift b/Ten31Transcripts/UI/TranscriptEditorView.swift index 0b72e76..d908d7e 100644 --- a/Ten31Transcripts/UI/TranscriptEditorView.swift +++ b/Ten31Transcripts/UI/TranscriptEditorView.swift @@ -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) } diff --git a/Ten31TranscriptsTests/SpeakerEditingTests.swift b/Ten31TranscriptsTests/SpeakerEditingTests.swift index 702654e..527bd8f 100644 --- a/Ten31TranscriptsTests/SpeakerEditingTests.swift +++ b/Ten31TranscriptsTests/SpeakerEditingTests.swift @@ -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"))