4c086251d9
Native editor to fix speaker-ID errors after transcription (modeled on recap-relay's correction UX): rename a speaker in the legend, merge two speakers, or reassign an individual transcript line. Saving rewrites speakers.json, re-renders transcript.md + recap.html, and updates the voiceprint memory — so a correction compounds: naming an "Unknown" speaker teaches that voice for future calls. - SpeakerEditing (pure, tested): replaceSpeaker (rename = merge-onto-existing), reassign, netNameMap (compose ops), and remap (apply a name map to a recap's structured fields + whole-word free text, so summaries/extras update without re-LLM). - RecapEditModel (@MainActor): loads speakers.json (+ optional recap.json + cluster_fingerprints.json); on save writes the resolved speakers.json, re-renders, and reconciles voiceprints — merge keeps the survivor's print; rename/name-an-Unknown enrolls the cluster's fingerprint under the new name. - TranscriptEditorView (SwiftUI) + EditorWindow (AppKit window for the LSUIElement app); menu gains "Edit speakers". - Pipeline now persists cluster_fingerprints.json (every cluster incl. Unknown) and recap.json (RecapFile) so the editor can learn voices + re-render offline. - RecapModels made Codable; TranscriptAssembler exposes allFingerprints; VoiceprintStore gains enroll() + merge(). 52/52 XCTest (6 new, incl. a full rename→artifacts→voiceprint round-trip on disk).
92 lines
4.8 KiB
Swift
92 lines
4.8 KiB
Swift
import Foundation
|
|
|
|
/// Pure transforms for speaker corrections: rename, merge (rename onto an existing
|
|
/// name), and per-segment reassignment, plus remapping speaker names through a
|
|
/// recap's text/structured fields. No UI/IO — fully unit-testable.
|
|
enum SpeakerEditing {
|
|
typealias Segment = SpeakersFile.Segment
|
|
|
|
/// Distinct speakers in first-appearance order (the legend).
|
|
static func orderedSpeakers(_ segments: [Segment]) -> [String] {
|
|
var seen = Set<String>(), order: [String] = []
|
|
for s in segments where !s.speaker.isEmpty && !seen.contains(s.speaker) {
|
|
seen.insert(s.speaker); order.append(s.speaker)
|
|
}
|
|
return order
|
|
}
|
|
|
|
/// Replace every `from` with `to` across segments. Rename when `to` is new; a
|
|
/// merge when `to` already exists — same primitive either way.
|
|
static func replaceSpeaker(_ from: String, with to: String, in segments: [Segment]) -> [Segment] {
|
|
guard from != to, !to.isEmpty else { return segments }
|
|
return segments.map {
|
|
$0.speaker == from ? Segment(start: $0.start, end: $0.end, speaker: to, text: $0.text) : $0
|
|
}
|
|
}
|
|
|
|
/// Reassign a single segment to another speaker.
|
|
static func reassign(_ index: Int, to speaker: String, in segments: [Segment]) -> [Segment] {
|
|
guard segments.indices.contains(index), !speaker.isEmpty else { return segments }
|
|
var out = segments
|
|
let s = out[index]
|
|
out[index] = Segment(start: s.start, end: s.end, speaker: speaker, text: s.text)
|
|
return out
|
|
}
|
|
|
|
/// Compose an ordered list of (from → to) rename/merge ops into the net
|
|
/// original→final map (per-segment reassignments are NOT renames, so they don't
|
|
/// appear here). Only entries that actually changed are returned.
|
|
static func netNameMap(originals: [String], ops: [(from: String, to: String)]) -> [String: String] {
|
|
var cur = Dictionary(uniqueKeysWithValues: originals.map { ($0, $0) })
|
|
for op in ops {
|
|
for (k, v) in cur where v == op.from { cur[k] = op.to }
|
|
}
|
|
return cur.filter { $0.key != $0.value }
|
|
}
|
|
|
|
// MARK: - Recap remapping
|
|
|
|
/// Apply a name map to a recap's structured fields (exact) and free text
|
|
/// (whole-word), so a rename/merge is reflected in summaries, the TLDR, and the
|
|
/// extras attributions without re-running the LLM.
|
|
static func remap(_ result: RecapResult, names map: [String: String]) -> RecapResult {
|
|
guard !map.isEmpty else { return result }
|
|
func exact(_ s: String?) -> String? { s.flatMap { map[$0] ?? $0 } }
|
|
func exactList(_ a: [String]) -> [String] { a.map { map[$0] ?? $0 } }
|
|
|
|
let sections = result.sections.map {
|
|
TopicSection(title: replaceWords($0.title, map),
|
|
summary: replaceWords($0.summary, map),
|
|
startIndex: $0.startIndex, endIndex: $0.endIndex)
|
|
}
|
|
var extras = result.extras
|
|
if let x = result.extras {
|
|
extras = MeetingExtras(
|
|
tldr: .init(summary: replaceWords(x.tldr.summary, map),
|
|
primarySpeakers: exactList(x.tldr.primarySpeakers)),
|
|
decisions: x.decisions.map { .init(statement: replaceWords($0.statement, map),
|
|
agreedBy: exactList($0.agreedBy), supportingOffset: $0.supportingOffset) },
|
|
actionItems: x.actionItems.map { .init(description: replaceWords($0.description, map),
|
|
owner: exact($0.owner), dueHint: $0.dueHint, supportingOffset: $0.supportingOffset) },
|
|
openQuestions: x.openQuestions.map { .init(question: replaceWords($0.question, map), raisedBy: exact($0.raisedBy)) },
|
|
keyQuotes: x.keyQuotes.map { .init(speaker: exact($0.speaker), offset: $0.offset,
|
|
quote: replaceWords($0.quote, map), whyNotable: replaceWords($0.whyNotable, map)) })
|
|
}
|
|
return RecapResult(sections: sections, extras: extras)
|
|
}
|
|
|
|
/// Whole-word replace each `from`→`to` in free text (case-sensitive). Used so a
|
|
/// renamed speaker's name updates inside summaries without clobbering substrings.
|
|
static func replaceWords(_ text: String, _ map: [String: String]) -> String {
|
|
var out = text
|
|
for (from, to) in map where from != to && !from.isEmpty {
|
|
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: from) + "\\b"
|
|
guard let re = try? NSRegularExpression(pattern: pattern) else { continue }
|
|
let range = NSRange(out.startIndex..., in: out)
|
|
out = re.stringByReplacingMatches(in: out, range: range,
|
|
withTemplate: NSRegularExpression.escapedTemplate(for: to))
|
|
}
|
|
return out
|
|
}
|
|
}
|