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:
@@ -30,3 +30,29 @@ final class EditorWindow {
|
||||
w.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts the recap-templates manager in its own resizable window.
|
||||
@MainActor
|
||||
final class TemplatesWindow {
|
||||
static let shared = TemplatesWindow()
|
||||
private var window: NSWindow?
|
||||
|
||||
func show(settings: AppSettings) {
|
||||
if let window {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
let w = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 760, height: 560),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered, defer: false)
|
||||
w.title = "Recap Templates"
|
||||
w.isReleasedWhenClosed = false
|
||||
w.center()
|
||||
w.contentViewController = NSHostingController(rootView: TemplatesView(settings: settings))
|
||||
window = w
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
w.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@ struct SettingsView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Auto-send recordings to backend", isOn: $settings.autoSendOnStop)
|
||||
Toggle("Build readable recap (topics + highlights)", isOn: $settings.recapEnabled)
|
||||
Text("Your name labels your mic channel. Auto-send transcribes on stop; the recap writes transcript.md + recap.html.")
|
||||
HStack {
|
||||
Picker("Default recap template", selection: $settings.defaultTemplateId) {
|
||||
ForEach(settings.recapTemplates) { Text($0.name).tag($0.id) }
|
||||
}
|
||||
Button("Manage…") { TemplatesWindow.shared.show(settings: settings) }
|
||||
}
|
||||
Text("Your name labels your mic channel. Auto-send transcribes on stop; the recap writes transcript.md + recap.html. Templates define the takeaways categories per meeting type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
@@ -98,8 +98,12 @@ struct TranscriptEditorView: View {
|
||||
Button("Save corrections") { model.save() }
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.disabled(!model.dirty || model.regenerating)
|
||||
Picker("", selection: $model.selectedTemplateId) {
|
||||
ForEach(model.templates) { Text($0.name).tag($0.id) }
|
||||
}
|
||||
.labelsHidden().frame(maxWidth: 150).disabled(model.regenerating)
|
||||
Button("Regenerate recap") { Task { await model.regenerate() } }
|
||||
.help("Re-run the analysis on the corrected transcript so summaries use the fixed names.")
|
||||
.help("Re-run the analysis on the corrected transcript with the chosen template.")
|
||||
.disabled(model.regenerating)
|
||||
if model.regenerating { ProgressView().controlSize(.small) }
|
||||
if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) }
|
||||
|
||||
Reference in New Issue
Block a user