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(), 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 } }