c539b78a58
Takeaways categories are no longer hardcoded — they're editable templates. A
template = the always-on TLDR + an ordered list of sections, each with a title, a
type (attributed items / bulleted list / paragraph), and an instruction (the prompt
text for that category). The analyzer assembles the LLM prompt FROM the template
and parses generically, so adding/removing/renaming a category needs zero code and
the output always renders.
- RecapTemplate / TemplateSection / SectionKind + TopicGranularity; built-in
defaults (Internal Meeting, 1:1, Company/Sales Call), all editable.
- Generic extras: RecapExtras{tldr, primarySpeakers, sections:[RenderedSection]} +
RecapItem{text,who,when,note} replaces the fixed MeetingExtras. Analyzer builds
per-section sec_N fields + parses by kind; renderer + remap are generic.
- Topic granularity (coarse/auto/fine) answers 'should chunking be configurable' —
it scales the target topic count; raw window sizes stay as tuned defaults.
- AppSettings persists templates + defaultTemplateId (seeded once). Settings gets a
default-template picker + 'Manage…' → TemplatesView (CRUD, edit sections/
instructions, set default, **Preview prompt** for full transparency).
- Recap editor gains a template picker; Regenerate uses the chosen template. Auto
recap uses the default template.
54/54 XCTest (template prompt build, generic parse/remap/render updated).
93 lines
5.4 KiB
Swift
93 lines
5.4 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 B→A
|
|
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: RecapExtras(tldr: "Grant led.", primarySpeakers: ["Grant"], sections: [
|
|
RenderedSection(title: "Decisions", kind: .items,
|
|
items: [RecapItem(text: "Unknown_0 sends doc", who: "Unknown_0", when: 1, note: nil)]),
|
|
RenderedSection(title: "Takeaways", kind: .bullets, bullets: ["Unknown_0 and Grant agree"]),
|
|
]))
|
|
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, "Grant Gilliam led.")
|
|
XCTAssertEqual(out.extras?.primarySpeakers, ["Grant Gilliam"])
|
|
XCTAssertEqual(out.extras?.sections[0].items.first?.who, "Caitlyn")
|
|
XCTAssertEqual(out.extras?.sections[0].items.first?.text, "Caitlyn sends doc")
|
|
XCTAssertEqual(out.extras?.sections[1].bullets, ["Caitlyn and Grant Gilliam agree"])
|
|
}
|
|
|
|
@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,
|
|
baseURL: "https://localhost:1", skipTLS: true,
|
|
templates: RecapTemplate.builtIns,
|
|
defaultTemplateId: RecapTemplate.builtIns.first!.id))
|
|
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"))
|
|
}
|
|
}
|