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).
This commit is contained in:
Grant Gilliam
2026-06-06 19:26:03 -05:00
parent 10ddf9992a
commit c539b78a58
14 changed files with 580 additions and 227 deletions
+177
View File
@@ -0,0 +1,177 @@
import SwiftUI
/// Manage recap templates: the takeaways categories per meeting type. Edit titles,
/// types, and the per-category instructions that drive the LLM and preview the
/// exact prompt that gets sent, so nothing is hidden in code.
struct TemplatesView: View {
@ObservedObject var settings: AppSettings
@State private var selectedId: String?
@State private var previewText: String?
var body: some View {
HSplitView {
sidebar.frame(minWidth: 200, maxWidth: 260)
detail.frame(minWidth: 420)
}
.frame(minWidth: 720, minHeight: 520)
.onAppear { if selectedId == nil { selectedId = settings.defaultTemplateId } }
.sheet(item: Binding(get: { previewText.map { IdString($0) } }, set: { previewText = $0?.value })) { item in
previewSheet(item.value)
}
}
// MARK: - Sidebar (template list + default picker)
private var sidebar: some View {
VStack(alignment: .leading, spacing: 0) {
List(selection: $selectedId) {
ForEach(settings.recapTemplates) { t in
HStack {
Text(t.name)
if t.id == settings.defaultTemplateId {
Spacer(); Image(systemName: "star.fill").foregroundStyle(.yellow).font(.caption)
}
}.tag(t.id)
}
}
Divider()
HStack(spacing: 6) {
Button { addTemplate() } label: { Image(systemName: "plus") }
Button { duplicateSelected() } label: { Image(systemName: "plus.square.on.square") }
.disabled(selected == nil)
Button { deleteSelected() } label: { Image(systemName: "trash") }
.disabled(selected == nil || settings.recapTemplates.count <= 1)
Spacer()
}
.buttonStyle(.borderless).padding(8)
}
}
// MARK: - Detail (edit selected template)
private var detail: some View {
Group {
if let binding = selectedBinding {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack {
TextField("Template name", text: binding.name).textFieldStyle(.roundedBorder)
if binding.wrappedValue.id != settings.defaultTemplateId {
Button("Make default") { settings.defaultTemplateId = binding.wrappedValue.id }
} else {
Label("Default", systemImage: "star.fill").foregroundStyle(.secondary).font(.caption)
}
}
HStack(spacing: 16) {
Toggle("Include summary (TLDR)", isOn: binding.includeTLDR)
Picker("Topic detail", selection: binding.topicGranularity) {
ForEach(TopicGranularity.allCases) { Text($0.label).tag($0) }
}.frame(maxWidth: 240)
}
Divider()
Text("Takeaways sections").font(.headline)
Text("Each becomes a section of the recap. The instruction is the prompt text telling the model what to extract.")
.font(.caption).foregroundStyle(.secondary)
sectionsEditor(binding)
Button { addSection(binding) } label: { Label("Add section", systemImage: "plus") }
Divider()
Button("Preview prompt…") { previewText = buildPreview(binding.wrappedValue) }
}
.padding(16)
}
} else {
Text("Select a template").foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private func sectionsEditor(_ template: Binding<RecapTemplate>) -> some View {
VStack(spacing: 10) {
ForEach(Array(template.sections.enumerated()), id: \.element.id) { idx, section in
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField("Section title", text: section.title).textFieldStyle(.roundedBorder)
Picker("", selection: section.kind) {
ForEach(SectionKind.allCases) { Text($0.label).tag($0) }
}.labelsHidden().frame(width: 150)
Button { move(template, idx, by: -1) } label: { Image(systemName: "arrow.up") }
.disabled(idx == 0).buttonStyle(.borderless)
Button { move(template, idx, by: 1) } label: { Image(systemName: "arrow.down") }
.disabled(idx == template.wrappedValue.sections.count - 1).buttonStyle(.borderless)
Button { removeSection(template, idx) } label: { Image(systemName: "trash") }
.buttonStyle(.borderless).foregroundStyle(.red)
}
TextEditor(text: section.instruction)
.font(.callout).frame(minHeight: 48, maxHeight: 90)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(.quaternary))
}
.padding(10).background(Color(nsColor: .controlBackgroundColor)).cornerRadius(8)
}
}
}
// MARK: - Mutations
private var selected: RecapTemplate? { settings.recapTemplates.first { $0.id == selectedId } }
private var selectedBinding: Binding<RecapTemplate>? {
guard let id = selectedId, let i = settings.recapTemplates.firstIndex(where: { $0.id == id }) else { return nil }
return Binding(get: { settings.recapTemplates[i] }, set: { settings.recapTemplates[i] = $0 })
}
private func addTemplate() {
let t = RecapTemplate(name: "New Template", sections: [
.init(title: "Key Takeaways", kind: .bullets, instruction: "The main points from the conversation.")
])
settings.recapTemplates.append(t); selectedId = t.id
}
private func duplicateSelected() {
guard let s = selected else { return }
var copy = s; copy.id = UUID().uuidString; copy.name = s.name + " copy"
copy.sections = s.sections.map { var c = $0; c.id = UUID().uuidString; return c }
settings.recapTemplates.append(copy); selectedId = copy.id
}
private func deleteSelected() {
guard let id = selectedId else { return }
settings.recapTemplates.removeAll { $0.id == id }
if settings.defaultTemplateId == id { settings.defaultTemplateId = settings.recapTemplates.first?.id ?? "" }
selectedId = settings.recapTemplates.first?.id
}
private func addSection(_ t: Binding<RecapTemplate>) {
t.wrappedValue.sections.append(.init(title: "New Section", kind: .bullets, instruction: ""))
}
private func removeSection(_ t: Binding<RecapTemplate>, _ idx: Int) {
guard t.wrappedValue.sections.indices.contains(idx) else { return }
t.wrappedValue.sections.remove(at: idx)
}
private func move(_ t: Binding<RecapTemplate>, _ idx: Int, by delta: Int) {
let j = idx + delta
guard t.wrappedValue.sections.indices.contains(idx), t.wrappedValue.sections.indices.contains(j) else { return }
t.wrappedValue.sections.swapAt(idx, j)
}
// MARK: - Preview
private func buildPreview(_ template: RecapTemplate) -> String {
let sample = SpeakersFile(sessionId: "sample", app: "meet", durationSec: 600,
speakers: [], segments: [
.init(start: 5, end: 9, speaker: "Grant", text: "Let's review the roadmap."),
.init(start: 10, end: 16, speaker: "Caitlyn", text: "I think we should ship dual-channel first."),
], models: [:])
let entries = RecapAnalyzer.entries(from: sample)
return RecapAnalyzer.extrasPrompt(file: sample, entries: entries, sections: [], template: template)
}
private func previewSheet(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Prompt sent to the model (with sample transcript)").font(.headline)
ScrollView { Text(text).font(.system(.caption, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) }
.frame(width: 560, height: 380)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
HStack { Spacer(); Button("Done") { previewText = nil }.keyboardShortcut(.defaultAction) }
}
.padding(16)
}
}
private struct IdString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } }