Files
ten31-transcripts/Ten31TranscriptsTests/SpeakerEditingTests.swift
T
Grant Gilliam 4c086251d9 Speaker corrections: rename / merge / reassign + voice learning
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).
2026-06-06 15:12:23 -05:00

90 lines
5.1 KiB
Swift

import XCTest
@testable import Ten31Transcripts
final class SpeakerEditingTests: XCTestCase {
private func seg(_ s: Double, _ e: Double, _ who: String, _ t: String) -> SpeakersFile.Segment {
.init(start: s, end: e, speaker: who, text: t)
}
func testReplaceSpeakerRenamesAll() {
let segs = [seg(0, 1, "A", "x"), seg(1, 2, "B", "y"), seg(2, 3, "A", "z")]
let out = SpeakerEditing.replaceSpeaker("A", with: "Alice", in: segs)
XCTAssertEqual(out.map { $0.speaker }, ["Alice", "B", "Alice"])
}
func testReplaceSpeakerMergesOntoExisting() {
let segs = [seg(0, 1, "A", "x"), seg(1, 2, "B", "y")]
let out = SpeakerEditing.replaceSpeaker("B", with: "A", in: segs) // merge BA
XCTAssertEqual(SpeakerEditing.orderedSpeakers(out), ["A"])
}
func testReassignSingleSegment() {
let segs = [seg(0, 1, "A", "x"), seg(1, 2, "A", "y")]
let out = SpeakerEditing.reassign(1, to: "B", in: segs)
XCTAssertEqual(out.map { $0.speaker }, ["A", "B"])
}
func testNetNameMapComposesChains() {
let net = SpeakerEditing.netNameMap(originals: ["A", "B", "C"], ops: [("A", "B"), ("B", "C")])
XCTAssertEqual(net["A"], "C")
XCTAssertEqual(net["B"], "C")
XCTAssertNil(net["C"])
}
func testRemapStructuredAndWordBoundaryText() {
let result = RecapResult(
sections: [TopicSection(title: "Grant intro", summary: "Grant and Unknown_0 talk; Grantham stays.", startIndex: 0, endIndex: 1)],
extras: MeetingExtras(
tldr: .init(summary: "Grant led.", primarySpeakers: ["Grant"]),
decisions: [.init(statement: "ship", agreedBy: ["Grant", "Unknown_0"], supportingOffset: 1)],
actionItems: [.init(description: "Unknown_0 sends doc", owner: "Unknown_0", dueHint: nil, supportingOffset: nil)],
openQuestions: [],
keyQuotes: [.init(speaker: "Unknown_0", offset: 2, quote: "go", whyNotable: "")]))
let map = ["Unknown_0": "Caitlyn", "Grant": "Grant Gilliam"]
let out = SpeakerEditing.remap(result, names: map)
XCTAssertEqual(out.sections[0].title, "Grant Gilliam intro")
XCTAssertEqual(out.sections[0].summary, "Grant Gilliam and Caitlyn talk; Grantham stays.") // word boundary keeps "Grantham"
XCTAssertEqual(out.extras?.tldr.primarySpeakers, ["Grant Gilliam"])
XCTAssertEqual(out.extras?.decisions.first?.agreedBy, ["Grant Gilliam", "Caitlyn"])
XCTAssertEqual(out.extras?.actionItems.first?.owner, "Caitlyn")
XCTAssertEqual(out.extras?.keyQuotes.first?.speaker, "Caitlyn")
}
@MainActor
func testEditModelRenameSavesArtifactsAndLearnsVoice() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("edit_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let speakers = SpeakersFile(sessionId: "s", app: "meet", durationSec: 6,
speakers: [.init(name: "Grant", source: "mic_channel", overlapConfidence: nil, matchSimilarity: nil),
.init(name: "Unknown_0", source: "unmatched", overlapConfidence: nil, matchSimilarity: nil)],
segments: [seg(0, 2, "Grant", "hi"), seg(3, 5, "Unknown_0", "hello there")], models: [:])
try speakers.write(to: dir.appendingPathComponent("speakers.json"))
try RecapFile(title: "Meet call",
result: RecapResult(sections: [TopicSection(title: "Intro", summary: "Unknown_0 greets the room.", startIndex: 0, endIndex: 1)], extras: nil))
.write(to: dir.appendingPathComponent("recap.json"))
try JSONSerialization.data(withJSONObject: ["Unknown_0": [0.5, 0.6], "Grant": [0.1, 0.2]])
.write(to: dir.appendingPathComponent("cluster_fingerprints.json"))
let store = VoiceprintStore(fileURL: dir.appendingPathComponent("voiceprints.json"))
let model = try XCTUnwrap(RecapEditModel(folder: dir, voiceprints: store))
model.rename("Unknown_0", to: "Caitlyn")
XCTAssertTrue(model.speakers.contains("Caitlyn"))
XCTAssertFalse(model.speakers.contains("Unknown_0"))
model.save()
let reloaded = try JSONDecoder().decode(SpeakersFile.self,
from: Data(contentsOf: dir.appendingPathComponent("speakers.json")))
XCTAssertTrue(reloaded.segments.contains { $0.speaker == "Caitlyn" })
XCTAssertFalse(reloaded.segments.contains { $0.speaker == "Unknown_0" })
XCTAssertTrue(FileManager.default.fileExists(atPath: dir.appendingPathComponent("recap.html").path))
XCTAssertTrue(FileManager.default.fileExists(atPath: dir.appendingPathComponent("transcript.md").path))
// The renamed Unknown taught the store a voice for "Caitlyn".
XCTAssertEqual(store.knownVoiceprints()["Caitlyn"], [0.5, 0.6])
// recap.json summary remapped Unknown_0 Caitlyn.
let rf = try XCTUnwrap(RecapFile.read(from: dir.appendingPathComponent("recap.json")))
XCTAssertTrue(rf.result.sections[0].summary.contains("Caitlyn"))
}
}