Files
Grant Gilliam c539b78a58 Configurable recap templates (categories per meeting type, in Settings)
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).
2026-06-06 19:26:03 -05:00

161 lines
8.2 KiB
Swift

import XCTest
@testable import Ten31Transcripts
final class RecapTests: XCTestCase {
private func entry(_ off: Double, _ end: Double, _ who: String, _ text: String) -> RecapAnalyzer.Entry {
.init(offset: off, end: end, speaker: who, text: text)
}
// MARK: - Parsing
func testParseSectionsHandlesStringIndices() {
let json = #"{"sections":[{"title":"Intro","summary":"hi","startIndex":"0","endIndex":3},{"title":"Topic","summary":"x","startIndex":4,"endIndex":9}]}"#
let secs = RecapAnalyzer.parseSections(json)
XCTAssertEqual(secs.count, 2)
XCTAssertEqual(secs[0].title, "Intro")
XCTAssertEqual(secs[0].startIndex, 0)
XCTAssertEqual(secs[1].endIndex, 9)
}
func testParseSectionsStripsCodeFence() {
let json = "```json\n{\"sections\":[{\"title\":\"A\",\"summary\":\"\",\"startIndex\":0,\"endIndex\":1}]}\n```"
XCTAssertEqual(RecapAnalyzer.parseSections(json).count, 1)
}
private var sampleTemplate: RecapTemplate {
RecapTemplate(id: "t", name: "T", includeTLDR: true, sections: [
.init(id: "a", title: "Decisions", kind: .items, instruction: ""),
.init(id: "b", title: "Takeaways", kind: .bullets, instruction: ""),
])
}
func testParseExtrasGeneric() {
let json = #"{"tldr":"They discussed the roadmap.","primary_speakers":["Grant","Caitlyn"],"sec_0":[{"text":"Ship dual-channel","who":"Grant","when":72,"note":null}],"sec_1":["faster","cheaper"]}"#
let x = RecapAnalyzer.parseExtras(json, template: sampleTemplate)
XCTAssertNotNil(x)
XCTAssertEqual(x?.tldr, "They discussed the roadmap.")
XCTAssertEqual(x?.primarySpeakers, ["Grant", "Caitlyn"])
XCTAssertEqual(x?.sections.count, 2)
XCTAssertEqual(x?.sections[0].kind, .items)
XCTAssertEqual(x?.sections[0].items.first?.text, "Ship dual-channel")
XCTAssertEqual(x?.sections[0].items.first?.who, "Grant")
XCTAssertEqual(x?.sections[0].items.first?.when, 72)
XCTAssertEqual(x?.sections[1].kind, .bullets)
XCTAssertEqual(x?.sections[1].bullets, ["faster", "cheaper"])
}
func testParseExtrasDropsNullStrings() {
let template = RecapTemplate(id: "t", name: "T", sections: [.init(id: "a", title: "Actions", kind: .items, instruction: "")])
let json = #"{"tldr":"s","primary_speakers":[],"sec_0":[{"text":"do it","who":"null","note":""}]}"#
let x = RecapAnalyzer.parseExtras(json, template: template)
XCTAssertNil(x?.sections.first?.items.first?.who)
XCTAssertNil(x?.sections.first?.items.first?.note)
XCTAssertEqual(x?.sections.first?.items.first?.text, "do it")
}
func testExtrasPromptBuildsFromTemplate() {
let template = RecapTemplate(id: "t", name: "T", includeTLDR: true, sections: [
.init(id: "a", title: "Risks", kind: .bullets, instruction: "List the risks."),
.init(id: "b", title: "Decisions", kind: .items, instruction: "List decisions."),
])
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 60, speakers: [],
segments: [.init(start: 0, end: 2, speaker: "A", text: "hi")], models: [:])
let prompt = RecapAnalyzer.extrasPrompt(file: file, entries: RecapAnalyzer.entries(from: file),
sections: [], template: template)
XCTAssertTrue(prompt.contains("sec_0"))
XCTAssertTrue(prompt.contains("sec_1"))
XCTAssertTrue(prompt.contains("Risks"))
XCTAssertTrue(prompt.contains("List the risks."))
XCTAssertTrue(prompt.contains("tldr"))
}
func testExtrasPromptOmitsTLDRWhenDisabled() {
let template = RecapTemplate(id: "t", name: "T", includeTLDR: false,
sections: [.init(id: "a", title: "X", kind: .paragraph, instruction: "y")])
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 60, speakers: [],
segments: [.init(start: 0, end: 2, speaker: "A", text: "hi")], models: [:])
let prompt = RecapAnalyzer.extrasPrompt(file: file, entries: RecapAnalyzer.entries(from: file),
sections: [], template: template)
XCTAssertFalse(prompt.contains("\"tldr\""))
}
// MARK: - Stitch / windows
func testStitchDropsContainedAndTrimsOverlap() {
let secs = [
TopicSection(title: "A", summary: "", startIndex: 0, endIndex: 5),
TopicSection(title: "B-contained", summary: "", startIndex: 2, endIndex: 4),
TopicSection(title: "C-overlap", summary: "", startIndex: 4, endIndex: 9),
]
let out = RecapAnalyzer.stitch(secs)
XCTAssertEqual(out.map { $0.title }, ["A", "C-overlap"])
XCTAssertEqual(out[0].startIndex, 0); XCTAssertEqual(out[0].endIndex, 5)
XCTAssertEqual(out[1].startIndex, 6); XCTAssertEqual(out[1].endIndex, 9) // trimmed front
}
func testPlanWindowsSingleForShortCall() {
let entries = (0..<10).map { entry(Double($0 * 10), Double($0 * 10 + 5), "A", "x") } // ~100s
let w = RecapAnalyzer.planWindows(entries)
XCTAssertEqual(w.count, 1)
XCTAssertEqual(w[0].startIdx, 0); XCTAssertEqual(w[0].endIdx, 9)
}
func testPlanWindowsMultipleForLongCall() {
// 40 entries at 60s spacing ~39 min, over the 25-min cutoff.
let entries = (0..<40).map { entry(Double($0 * 60), Double($0 * 60 + 30), "A", "x") }
let w = RecapAnalyzer.planWindows(entries)
XCTAssertGreaterThan(w.count, 1)
XCTAssertEqual(w.first?.startIdx, 0)
XCTAssertEqual(w.last?.endIdx, 39) // last window reaches the end
for i in 1..<w.count { XCTAssertGreaterThan(w[i].bodyStartIdx, w[i - 1].bodyStartIdx) }
}
// MARK: - Entries + formatting
func testEntriesFiltersEmptyAndSorts() {
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 30,
speakers: [],
segments: [
.init(start: 10, end: 12, speaker: "B", text: "second"),
.init(start: 0, end: 2, speaker: "A", text: "first"),
.init(start: 5, end: 6, speaker: "A", text: " "), // empty dropped
], models: [:])
let e = RecapAnalyzer.entries(from: file)
XCTAssertEqual(e.map { $0.text }, ["first", "second"])
}
func testMmss() {
XCTAssertEqual(RecapAnalyzer.mmss(72), "1:12")
XCTAssertEqual(RecapAnalyzer.mmss(3725), "1:02:05")
}
// MARK: - Render smoke test
func testMarkdownRenders() {
let file = SpeakersFile(sessionId: "2026-06-06T11-43-12_meet", app: "meet", durationSec: 80,
speakers: [.init(name: "Grant", source: "mic_channel", overlapConfidence: nil, matchSimilarity: nil)],
segments: [
.init(start: 0, end: 4, speaker: "Grant", text: "Now we got a call going on."),
.init(start: 72, end: 74, speaker: "Caitlyn", text: "Go Bitcoin."),
], models: [:])
let extras = RecapExtras(tldr: "A quick test call.", primarySpeakers: ["Grant"], sections: [
RenderedSection(title: "Action Items", kind: .items, items: [RecapItem(text: "Ship it", who: "Grant", when: 3, note: nil)]),
RenderedSection(title: "Key Quotes", kind: .items, items: [RecapItem(text: "Go Bitcoin", who: "Caitlyn", when: 72, note: "tone")]),
])
let result = RecapResult(sections: [TopicSection(title: "Call start", summary: "Grant opens.", startIndex: 0, endIndex: 1)], extras: extras)
let entries = RecapAnalyzer.entries(from: file)
let md = RecapRenderer.markdown(file: file, result: result, title: "Meet call", entries: entries)
XCTAssertTrue(md.contains("## Summary"))
XCTAssertTrue(md.contains("## Action Items"))
XCTAssertTrue(md.contains("## Topics"))
XCTAssertTrue(md.contains("## Full Transcript"))
XCTAssertTrue(md.contains("Go Bitcoin"))
XCTAssertTrue(md.contains("Grant"))
// HTML smoke
let html = RecapRenderer.html(file: file, result: result, title: "Meet call", entries: entries)
XCTAssertTrue(html.contains("<!DOCTYPE html>"))
XCTAssertTrue(html.contains("Go Bitcoin"))
}
}