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
+79 -78
View File
@@ -29,25 +29,27 @@ final class RecapAnalyzer {
// MARK: - Orchestration
/// Analyze (topics) extras. Extras are best-effort (nil on failure).
func recap(file: SpeakersFile, progress: ((String) async -> Void)? = nil) async throws -> RecapResult {
/// Analyze (topics) template-driven extras. Extras are best-effort (nil on failure).
func recap(file: SpeakersFile, template: RecapTemplate,
progress: ((String) async -> Void)? = nil) async throws -> RecapResult {
let entries = Self.entries(from: file)
guard !entries.isEmpty else { return RecapResult(sections: [], extras: nil) }
await progress?("Finding topics…")
let sections = try await analyze(entries: entries)
await progress?("Extracting highlights…")
let extras = try? await self.extras(file: file, entries: entries, sections: sections)
let sections = try await analyze(entries: entries, granularity: template.topicGranularity)
await progress?("Extracting takeaways…")
let extras = try? await self.extras(file: file, entries: entries, sections: sections, template: template)
return RecapResult(sections: sections, extras: extras)
}
// MARK: - Analyze (chunked windows stitched sections)
func analyze(entries: [Entry]) async throws -> [TopicSection] {
func analyze(entries: [Entry], granularity: TopicGranularity = .auto) async throws -> [TopicSection] {
let windows = Self.planWindows(entries)
var all: [TopicSection] = []
for w in windows {
let local = Array(entries[w.startIdx...w.endIdx])
let prompt = Self.analyzePrompt(local, totalSec: entries.last?.end ?? 0, windowCount: windows.count)
let prompt = Self.analyzePrompt(local, totalSec: entries.last?.end ?? 0,
windowCount: windows.count, granularity: granularity)
let content = try await llm.completeJSON(model: model, system: nil, user: prompt)
for s in Self.parseSections(content) {
let gs = w.startIdx + max(0, min(s.startIndex, local.count - 1))
@@ -114,10 +116,11 @@ final class RecapAnalyzer {
// MARK: - Extras
func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection]) async throws -> MeetingExtras? {
let prompt = Self.extrasPrompt(file: file, entries: entries, sections: sections)
func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection],
template: RecapTemplate) async throws -> RecapExtras? {
let prompt = Self.extrasPrompt(file: file, entries: entries, sections: sections, template: template)
let content = try await llm.completeJSON(model: model, system: nil, user: prompt, maxTokens: 4096)
return Self.parseExtras(content)
return Self.parseExtras(content, template: template)
}
// MARK: - Entries
@@ -138,14 +141,15 @@ final class RecapAnalyzer {
// MARK: - Prompts
private static func analyzePrompt(_ window: [Entry], totalSec: Double, windowCount: Int) -> String {
private static func analyzePrompt(_ window: [Entry], totalSec: Double, windowCount: Int,
granularity: TopicGranularity) -> String {
let lines = window.enumerated()
.map { "[\($0.offset)] (\(mmss($0.element.offset))) \($0.element.speaker): \($0.element.text)" }
.joined(separator: "\n")
let windowSpan = (window.last?.end ?? 0) - (window.first?.offset ?? 0)
let windowMin = max(1, Int((windowSpan / 60).rounded()))
let maxIndex = window.count - 1
let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount)
let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount, granularity: granularity)
return """
You are analyzing a ~\(windowMin)-minute section of a longer transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections — aim for \(targetSections).
@@ -182,26 +186,55 @@ final class RecapAnalyzer {
"""
}
private static func targetSectionsPhrase(totalSec: Double, windowCount: Int) -> String {
private static func targetSectionsPhrase(totalSec: Double, windowCount: Int,
granularity: TopicGranularity) -> String {
let m = totalSec / 60
let total = m < 5 ? 3 : m < 15 ? 4 : m < 30 ? 6 : m < 60 ? 8 : m < 120 ? 12 : 16
let per = max(2, Int((Double(total) / Double(max(1, windowCount))).rounded()))
let base = m < 5 ? 3.0 : m < 15 ? 4 : m < 30 ? 6 : m < 60 ? 8 : m < 120 ? 12 : 16
let total = base * granularity.multiplier
let per = max(2, Int((total / Double(max(1, windowCount))).rounded()))
return "around \(per) sections"
}
private static func extrasPrompt(file: SpeakersFile, entries: [Entry], sections: [TopicSection]) -> String {
/// Assemble the extras prompt FROM the template, so prompt + parsing + rendering
/// always agree. Each section becomes a `sec_<i>` field shaped by its kind.
/// Exposed so Settings can show the user the exact prompt that will be sent.
static func extrasPrompt(file: SpeakersFile, entries: [Entry], sections: [TopicSection],
template: RecapTemplate) -> String {
let names = orderedSpeakerNames(entries)
let roster = names.isEmpty ? "(unknown)" : names.joined(separator: ", ")
let topics = sections.isEmpty ? "(none)" :
sections.enumerated().map { "\($0.offset + 1). \($0.element.title)" }.joined(separator: "\n")
let transcript = cappedTranscript(entries, maxChars: 24_000)
let durationStr = mmss(file.durationSec)
var fields: [String] = []
var shape: [String] = []
if template.includeTLDR {
fields.append("- \"tldr\": a 2-4 sentence executive summary string (what it was about, the arc, the outcome; past tense, dense; skip pleasantries; one factual sentence if there's no substance).")
fields.append("- \"primary_speakers\": array of the 1-3 names who drove the conversation, in rough order of contribution (empty if unclear).")
shape.append("\"tldr\": \"...\"")
shape.append("\"primary_speakers\": []")
}
for (i, sec) in template.sections.enumerated() {
let key = "sec_\(i)"
switch sec.kind {
case .bullets:
fields.append("- \"\(key)\" (\(sec.title)): array of short strings. \(sec.instruction)")
shape.append("\"\(key)\": [\"...\"]")
case .paragraph:
fields.append("- \"\(key)\" (\(sec.title)): a string. \(sec.instruction)")
shape.append("\"\(key)\": \"...\"")
case .items:
fields.append("- \"\(key)\" (\(sec.title)): array of {\"text\": string, \"who\": name or null, \"when\": integer seconds or null, \"note\": string or null}. \(sec.instruction)")
shape.append("\"\(key)\": [{\"text\": \"...\", \"who\": null, \"when\": null, \"note\": null}]")
}
}
return """
You are extracting structured information from an internal team meeting transcript. The transcript below is labeled with the speakers' real names where known.
You are extracting structured takeaways from a call transcript. The transcript is labeled with the speakers' real names where known.
MEETING METADATA:
- App: \(file.app)
- Duration: \(durationStr)
- Duration: \(mmss(file.durationSec))
SPEAKERS: \(roster)
@@ -212,41 +245,14 @@ final class RecapAnalyzer {
\(transcript)
INSTRUCTIONS:
Extract FIVE categories of information. Return EMPTY ARRAYS for categories that don't apply — do NOT invent items. Use the speakers' names exactly as shown above; use null/empty when a person is unclear.
Produce ONLY the JSON object below. Use the speakers' names exactly as shown above; use null/empty when a person or value is unclear. For any \"when\"/offset, convert the [<name> <MM:SS>] timestamp to total seconds. Be conservative — return EMPTY arrays (or null) rather than inventing anything.
1. TLDR — A 2-4 sentence executive summary of the entire meeting: what it was about, the key discussion arc, and the bottom-line outcome. Past tense, third person, dense. Skip pleasantries. If the meeting was genuinely substanceless, write one factual sentence. This is the only required category.
- summary: the 2-4 sentence executive summary
- primary_speakers: array of names who drove the conversation (1-3, in rough order of contribution). Empty array if unclear.
Fields:
\(fields.joined(separator: "\n"))
2. DECISIONS — Things explicitly decided/agreed. Only clear commitments, not casual mentions. For each:
- statement: the decision in one sentence
- agreed_by: array of names who explicitly agreed (empty if unclear)
- supporting_offset: integer SECONDS where it was decided (convert the [<name> <MM:SS>] timestamp to total seconds)
3. ACTION_ITEMS — Explicit ownership ("I'll send the doc", "Matt will follow up"), not vague "someone should". For each:
- description: the action in imperative form
- owner: the person's name, or null if unclear
- due_hint: deadline string if mentioned ("by Friday"), or null
- supporting_offset: integer seconds where the commitment was made
4. OPEN_QUESTIONS — Questions raised that were NOT clearly answered. Skip rhetorical/answered ones. For each:
- question: rephrased to be self-contained
- raised_by: the person's name, or null
- answered: false (always)
5. KEY_QUOTES — 3-6 max. Pivotal/insightful/strong-opinion statements worth surfacing verbatim. For each:
- speaker: the person's name (or null)
- offset: integer seconds where the quote occurs
- quote: the verbatim quote (4-30 words)
- why_notable: one short clause
Be conservative — better an empty array than a fabrication. Respond with ONLY valid JSON in this exact shape, no other text:
Respond with ONLY valid JSON in this exact shape, no other text:
{
"tldr": {"summary": "...", "primary_speakers": []},
"decisions": [{"statement": "...", "agreed_by": [], "supporting_offset": 0}],
"action_items": [{"description": "...", "owner": null, "due_hint": null, "supporting_offset": 0}],
"open_questions": [{"question": "...", "raised_by": null, "answered": false}],
"key_quotes": [{"speaker": null, "offset": 0, "quote": "...", "why_notable": "..."}]
\(shape.joined(separator: ",\n "))
}
"""
}
@@ -288,35 +294,30 @@ final class RecapAnalyzer {
}
}
static func parseExtras(_ content: String) -> MeetingExtras? {
static func parseExtras(_ content: String, template: RecapTemplate) -> RecapExtras? {
guard let o = jsonObject(content) else { return nil }
let tldrObj = o["tldr"] as? [String: Any]
let tldr = MeetingExtras.TLDR(
summary: (tldrObj?["summary"] as? String) ?? "",
primarySpeakers: stringArray(tldrObj?["primary_speakers"]))
let decisions = (o["decisions"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.Decision? in
guard let st = nonEmpty(d["statement"]) else { return nil }
return .init(statement: st, agreedBy: stringArray(d["agreed_by"]), supportingOffset: intVal(d["supporting_offset"]))
let tldr = template.includeTLDR ? ((o["tldr"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") : ""
let primary = stringArray(o["primary_speakers"])
var rendered: [RenderedSection] = []
for (i, sec) in template.sections.enumerated() {
let v = o["sec_\(i)"]
switch sec.kind {
case .bullets:
rendered.append(RenderedSection(title: sec.title, kind: .bullets, bullets: stringArray(v)))
case .paragraph:
let p = (v as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
rendered.append(RenderedSection(title: sec.title, kind: .paragraph, paragraph: p))
case .items:
let items = (v as? [[String: Any]] ?? []).compactMap { d -> RecapItem? in
guard let t = nonEmpty(d["text"]) else { return nil }
return RecapItem(text: t, who: nonEmpty(d["who"]), when: intVal(d["when"]), note: nonEmpty(d["note"]))
}
rendered.append(RenderedSection(title: sec.title, kind: .items, items: items))
}
}
let actions = (o["action_items"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.ActionItem? in
guard let desc = nonEmpty(d["description"]) else { return nil }
return .init(description: desc, owner: nonEmpty(d["owner"]), dueHint: nonEmpty(d["due_hint"]),
supportingOffset: intVal(d["supporting_offset"]))
}
let questions = (o["open_questions"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.OpenQuestion? in
guard let q = nonEmpty(d["question"]) else { return nil }
return .init(question: q, raisedBy: nonEmpty(d["raised_by"]))
}
let quotes = (o["key_quotes"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.KeyQuote? in
guard let q = nonEmpty(d["quote"]) else { return nil }
return .init(speaker: nonEmpty(d["speaker"]), offset: intVal(d["offset"]), quote: q,
whyNotable: nonEmpty(d["why_notable"]) ?? "")
}
// Require at least a TLDR to consider extras present.
guard !tldr.summary.isEmpty || !decisions.isEmpty || !actions.isEmpty || !questions.isEmpty || !quotes.isEmpty
else { return nil }
return MeetingExtras(tldr: tldr, decisions: decisions, actionItems: actions,
openQuestions: questions, keyQuotes: quotes)
guard !tldr.isEmpty || rendered.contains(where: { !$0.isEmpty }) else { return nil }
return RecapExtras(tldr: tldr, primarySpeakers: primary, sections: rendered)
}
private static func intVal(_ v: Any?) -> Int? {
+9 -2
View File
@@ -18,6 +18,8 @@ final class RecapEditModel: ObservableObject {
private var originalSpeakers: [String]
private var renameOps: [(from: String, to: String)] = []
let templates: [RecapTemplate]
@Published var selectedTemplateId: String
@Published private(set) var segments: [SpeakersFile.Segment]
@Published private(set) var speakers: [String]
@Published private(set) var dirty = false
@@ -25,7 +27,8 @@ final class RecapEditModel: ObservableObject {
@Published private(set) var hasRecap: Bool
@Published private(set) var status: String?
init?(folder: URL, voiceprints: VoiceprintStore, baseURL: String, skipTLS: Bool) {
init?(folder: URL, voiceprints: VoiceprintStore, baseURL: String, skipTLS: Bool,
templates: [RecapTemplate], defaultTemplateId: String) {
let speakersURL = folder.appendingPathComponent("speakers.json")
guard let data = try? Data(contentsOf: speakersURL),
let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
@@ -34,6 +37,9 @@ final class RecapEditModel: ObservableObject {
self.voiceprints = voiceprints
self.baseURL = baseURL
self.skipTLS = skipTLS
self.templates = templates.isEmpty ? RecapTemplate.builtIns : templates
self.selectedTemplateId = (templates.contains { $0.id == defaultTemplateId } ? defaultTemplateId : templates.first?.id)
?? RecapTemplate.builtIns.first!.id
self.base = file
self.segments = file.segments
self.speakers = SpeakerEditing.orderedSpeakers(file.segments)
@@ -102,13 +108,14 @@ final class RecapEditModel: ObservableObject {
defer { regenerating = false }
let file = commitCorrections()
let template = templates.first { $0.id == selectedTemplateId } ?? templates.first ?? .internalMeeting
let llm = GatewayLLMClient(baseURL: baseURL, skipTLS: skipTLS)
guard let model = await llm.chatModelId() else {
status = "No language model on the gateway — saved corrections only."
rebaseline(); return
}
let analyzer = RecapAnalyzer(llm: llm, model: model)
guard let result = try? await analyzer.recap(file: file) else {
guard let result = try? await analyzer.recap(file: file, template: template) else {
status = "Recap regeneration failed — corrections were saved."
rebaseline(); return
}
+31 -17
View File
@@ -9,32 +9,46 @@ struct TopicSection: Equatable, Codable {
var endIndex: Int
}
/// Structured "meeting extras" extracted from the named transcript. Mirrors
/// recap-relay's schema; speakers are real names (we already have them from
/// label-merge), not anonymous cluster ids.
struct MeetingExtras: Equatable, Codable {
struct TLDR: Equatable, Codable { var summary: String; var primarySpeakers: [String] }
struct Decision: Equatable, Codable { var statement: String; var agreedBy: [String]; var supportingOffset: Int? }
struct ActionItem: Equatable, Codable { var description: String; var owner: String?; var dueHint: String?; var supportingOffset: Int? }
struct OpenQuestion: Equatable, Codable { var question: String; var raisedBy: String? }
struct KeyQuote: Equatable, Codable { var speaker: String?; var offset: Int?; var quote: String; var whyNotable: String }
/// Generic, template-driven takeaways extracted from the named transcript: a TLDR
/// plus an ordered list of sections whose categories come from the active
/// `RecapTemplate` (Settings), not from code. Speakers are real names (label-merge).
struct RecapExtras: Equatable, Codable {
var tldr: String
var primarySpeakers: [String]
var sections: [RenderedSection]
var tldr: TLDR
var decisions: [Decision]
var actionItems: [ActionItem]
var openQuestions: [OpenQuestion]
var keyQuotes: [KeyQuote]
var isEmpty: Bool { tldr.isEmpty && sections.allSatisfy { $0.isEmpty } }
}
var isEmptyBeyondTLDR: Bool {
decisions.isEmpty && actionItems.isEmpty && openQuestions.isEmpty && keyQuotes.isEmpty
/// One rendered takeaways section. Only the field matching `kind` is populated.
struct RenderedSection: Equatable, Codable {
var title: String
var kind: SectionKind
var bullets: [String]
var items: [RecapItem]
var paragraph: String
var isEmpty: Bool { bullets.isEmpty && items.isEmpty && paragraph.isEmpty }
init(title: String, kind: SectionKind, bullets: [String] = [], items: [RecapItem] = [], paragraph: String = "") {
self.title = title; self.kind = kind; self.bullets = bullets; self.items = items; self.paragraph = paragraph
}
}
/// A single attributed point: the statement plus optional speaker / timestamp / note.
/// Subsumes decisions, action items, questions, quotes, etc.
struct RecapItem: Equatable, Codable {
var text: String
var who: String?
var when: Int? // seconds
var note: String?
}
/// The assembled recap for one session: the topic sections + (optional) extras,
/// over the session's transcript. Rendered to `transcript.md` / `recap.html`.
struct RecapResult: Equatable, Codable {
var sections: [TopicSection]
var extras: MeetingExtras?
var extras: RecapExtras?
}
/// Persisted `recap.json` the recap result plus its title, so the speaker editor
+37 -81
View File
@@ -24,51 +24,27 @@ enum RecapRenderer {
out += "*\n\n"
if let x = result.extras {
if !x.tldr.summary.isEmpty {
out += "## Summary\n\n\(x.tldr.summary)\n"
if !x.tldr.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.tldr.primarySpeakers.joined(separator: ", "))*\n" }
if !x.tldr.isEmpty {
out += "## Summary\n\n\(x.tldr)\n"
if !x.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.primarySpeakers.joined(separator: ", "))*\n" }
out += "\n"
}
if !x.decisions.isEmpty {
out += "## Decisions\n\n"
for d in x.decisions {
var line = "- \(d.statement)"
if !d.agreedBy.isEmpty { line += " — agreed by \(d.agreedBy.joined(separator: ", "))" }
if let o = d.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(o))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.actionItems.isEmpty {
out += "## Action Items\n\n"
for a in x.actionItems {
var line = "- [ ] \(a.description)"
if let o = a.owner { line += " — **\(o)**" }
if let due = a.dueHint { line += " (\(due))" }
if let off = a.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(off))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.openQuestions.isEmpty {
out += "## Open Questions\n\n"
for q in x.openQuestions {
var line = "- \(q.question)"
if let r = q.raisedBy { line += " — *\(r)*" }
out += line + "\n"
}
out += "\n"
}
if !x.keyQuotes.isEmpty {
out += "## Key Quotes\n\n"
for k in x.keyQuotes {
out += "> \"\(k.quote)\""
var attr: [String] = []
if let s = k.speaker { attr.append(s) }
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
if !attr.isEmpty { out += "\(attr.joined(separator: ", "))" }
for section in x.sections where !section.isEmpty {
out += "## \(section.title)\n\n"
switch section.kind {
case .paragraph:
out += "\(section.paragraph)\n\n"
case .bullets:
for b in section.bullets { out += "- \(b)\n" }
out += "\n"
if !k.whyNotable.isEmpty { out += ">\n> \(k.whyNotable)\n" }
case .items:
for item in section.items {
var line = "- \(item.text)"
if let who = item.who { line += " — **\(who)**" }
if let note = item.note { line += " (\(note))" }
if let when = item.when { line += " *(\(RecapAnalyzer.mmss(Double(when))))*" }
out += line + "\n"
}
out += "\n"
}
}
@@ -125,46 +101,26 @@ enum RecapRenderer {
body += "</header>"
if let x = result.extras {
if !x.tldr.summary.isEmpty {
body += card("Summary", "<p>\(esc(x.tldr.summary))</p>"
+ (x.tldr.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
if !x.tldr.isEmpty {
body += card("Summary", "<p>\(esc(x.tldr))</p>"
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
}
if !x.decisions.isEmpty {
let items = x.decisions.map { d -> String in
var s = "<li>\(esc(d.statement))"
if !d.agreedBy.isEmpty { s += " <span class=\"muted\">— agreed by \(d.agreedBy.map(esc).joined(separator: ", "))</span>" }
if let o = d.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(o)))</span>" }
return s + "</li>"
}.joined()
body += card("Decisions", "<ul>\(items)</ul>")
}
if !x.actionItems.isEmpty {
let items = x.actionItems.map { a -> String in
var s = "<li>☐ \(esc(a.description))"
if let o = a.owner { s += " <strong>\(esc(o))</strong>" }
if let due = a.dueHint { s += " <span class=\"muted\">(\(esc(due)))</span>" }
if let off = a.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(off)))</span>" }
return s + "</li>"
}.joined()
body += card("Action Items", "<ul class=\"actions\">\(items)</ul>")
}
if !x.openQuestions.isEmpty {
let items = x.openQuestions.map { q -> String in
"<li>\(esc(q.question))" + (q.raisedBy.map { " <span class=\"muted\">— \(esc($0))</span>" } ?? "") + "</li>"
}.joined()
body += card("Open Questions", "<ul>\(items)</ul>")
}
if !x.keyQuotes.isEmpty {
let items = x.keyQuotes.map { k -> String in
var attr: [String] = []
if let s = k.speaker { attr.append(esc(s)) }
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
var s = "<blockquote>“\(esc(k.quote))"
if !attr.isEmpty { s += "<cite>— \(attr.joined(separator: ", "))</cite>" }
if !k.whyNotable.isEmpty { s += "<div class=\"muted\">\(esc(k.whyNotable))</div>" }
return s + "</blockquote>"
}.joined()
body += card("Key Quotes", items)
for section in x.sections where !section.isEmpty {
switch section.kind {
case .paragraph:
body += card(section.title, "<p>\(esc(section.paragraph))</p>")
case .bullets:
body += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
case .items:
let lis = section.items.map { item -> String in
var s = "<li>\(esc(item.text))"
if let who = item.who { s += " <strong>\(esc(who))</strong>" }
if let note = item.note { s += " <span class=\"muted\">(\(esc(note)))</span>" }
if let when = item.when { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
return s + "</li>"
}.joined()
body += card(section.title, "<ul>\(lis)</ul>")
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import Foundation
/// How a takeaways section is shaped (drives the LLM output shape + rendering).
enum SectionKind: String, Codable, CaseIterable, Identifiable {
case items // point + optional who / timestamp / note (decisions, actions, quotes, questions)
case bullets // plain short points
case paragraph // a prose block
var id: String { rawValue }
var label: String {
switch self {
case .items: return "Attributed items"
case .bullets: return "Bulleted list"
case .paragraph: return "Paragraph"
}
}
}
/// One configurable takeaways category in a template.
struct TemplateSection: Codable, Identifiable, Equatable {
var id: String
var title: String
var kind: SectionKind
var instruction: String // editable prompt text: what the LLM should extract
init(id: String = UUID().uuidString, title: String, kind: SectionKind, instruction: String) {
self.id = id; self.title = title; self.kind = kind; self.instruction = instruction
}
}
/// How finely to break the transcript into topic sections.
enum TopicGranularity: String, Codable, CaseIterable, Identifiable {
case coarse, auto, fine
var id: String { rawValue }
var label: String {
switch self { case .coarse: return "Fewer, broader topics"; case .auto: return "Automatic"; case .fine: return "More, finer topics" }
}
/// Multiplier applied to the auto target-section count.
var multiplier: Double {
switch self { case .coarse: return 0.6; case .auto: return 1.0; case .fine: return 1.7 }
}
}
/// A recap template: the always-on TLDR + an ordered list of takeaways sections.
/// Fully user-editable in Settings (titles, types, instructions); the analyzer builds
/// the LLM prompt from it and the renderer renders from it, so categories are data,
/// not code.
struct RecapTemplate: Codable, Identifiable, Equatable {
var id: String
var name: String
var includeTLDR: Bool
var topicGranularity: TopicGranularity
var sections: [TemplateSection]
init(id: String = UUID().uuidString, name: String, includeTLDR: Bool = true,
topicGranularity: TopicGranularity = .auto, sections: [TemplateSection]) {
self.id = id; self.name = name; self.includeTLDR = includeTLDR
self.topicGranularity = topicGranularity; self.sections = sections
}
// MARK: - Built-in defaults (seeded once; all editable thereafter)
static var builtIns: [RecapTemplate] { [internalMeeting, oneOnOne, companyCall] }
static var internalMeeting: RecapTemplate {
RecapTemplate(id: "builtin.internal", name: "Internal Meeting", sections: [
.init(id: "internal.decisions", title: "Decisions", kind: .items,
instruction: "Things explicitly decided or agreed. Only clear commitments (\"we'll do X\", \"let's go with Y\"), not casual mentions. text = the decision; who = who agreed; when = the timestamp."),
.init(id: "internal.actions", title: "Action Items", kind: .items,
instruction: "Explicit ownership (\"I'll send the doc\", \"Matt will follow up\"), not vague \"someone should\". text = the action in imperative form; who = the owner; note = a due date if mentioned; when = the timestamp."),
.init(id: "internal.questions", title: "Open Questions", kind: .items,
instruction: "Questions raised that were NOT clearly answered. Skip rhetorical or answered ones. text = the question, self-contained; who = who raised it."),
.init(id: "internal.quotes", title: "Key Quotes", kind: .items,
instruction: "3-6 pivotal or insightful statements worth surfacing verbatim. text = the quote (4-30 words); who = the speaker; when = the timestamp; note = why it's notable."),
])
}
static var oneOnOne: RecapTemplate {
RecapTemplate(id: "builtin.oneonone", name: "1:1", sections: [
.init(id: "1on1.takeaways", title: "Key Takeaways", kind: .bullets,
instruction: "The main points and conclusions from the conversation, as concise bullets."),
.init(id: "1on1.actions", title: "Action Items", kind: .items,
instruction: "Anything either person committed to do. text = the action; who = the owner; note = a due date if mentioned."),
.init(id: "1on1.followups", title: "Follow-ups", kind: .bullets,
instruction: "Things to revisit or circle back on next time."),
])
}
static var companyCall: RecapTemplate {
RecapTemplate(id: "builtin.company", name: "Company / Sales Call", sections: [
.init(id: "company.takeaways", title: "Key Takeaways", kind: .bullets,
instruction: "The most important points from the call, as concise bullets."),
.init(id: "company.needs", title: "Their Asks & Needs", kind: .bullets,
instruction: "What the other party wants, needs, or is trying to solve."),
.init(id: "company.objections", title: "Objections & Concerns", kind: .items,
instruction: "Concerns, hesitations, or objections raised. text = the concern; who = who raised it."),
.init(id: "company.next", title: "Next Steps", kind: .items,
instruction: "Agreed next steps. text = the step; who = the owner; note = timing if mentioned; when = the timestamp."),
])
}
}
+9 -10
View File
@@ -61,16 +61,15 @@ enum SpeakerEditing {
}
var extras = result.extras
if let x = result.extras {
extras = MeetingExtras(
tldr: .init(summary: replaceWords(x.tldr.summary, map),
primarySpeakers: exactList(x.tldr.primarySpeakers)),
decisions: x.decisions.map { .init(statement: replaceWords($0.statement, map),
agreedBy: exactList($0.agreedBy), supportingOffset: $0.supportingOffset) },
actionItems: x.actionItems.map { .init(description: replaceWords($0.description, map),
owner: exact($0.owner), dueHint: $0.dueHint, supportingOffset: $0.supportingOffset) },
openQuestions: x.openQuestions.map { .init(question: replaceWords($0.question, map), raisedBy: exact($0.raisedBy)) },
keyQuotes: x.keyQuotes.map { .init(speaker: exact($0.speaker), offset: $0.offset,
quote: replaceWords($0.quote, map), whyNotable: replaceWords($0.whyNotable, map)) })
let rendered = x.sections.map { sec in
RenderedSection(title: sec.title, kind: sec.kind,
bullets: sec.bullets.map { replaceWords($0, map) },
items: sec.items.map { RecapItem(text: replaceWords($0.text, map), who: exact($0.who),
when: $0.when, note: $0.note.map { replaceWords($0, map) }) },
paragraph: replaceWords(sec.paragraph, map))
}
extras = RecapExtras(tldr: replaceWords(x.tldr, map),
primarySpeakers: exactList(x.primarySpeakers), sections: rendered)
}
return RecapResult(sections: sections, extras: extras)
}
@@ -400,10 +400,11 @@ final class SessionController: ObservableObject {
/// the gateway LLM. Best-effort: a missing LLM or any failure leaves the
/// transcript intact and just skips the recap.
private func buildRecap(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
let template = settings.defaultTemplate
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
guard let model = await llm.chatModelId() else { return } // no LLM on the gateway skip
let analyzer = RecapAnalyzer(llm: llm, model: model)
guard let result = try? await analyzer.recap(file: speakers) else { return }
guard let result = try? await analyzer.recap(file: speakers, template: template) else { return }
let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId)
try? RecapRenderer.write(file: speakers, result: result, title: title, to: inputs.folder)
try? RecapFile(title: title, result: result).write(to: inputs.folder.appendingPathComponent("recap.json"))
@@ -434,7 +435,8 @@ final class SessionController: ObservableObject {
func editLastSession() {
guard let folder = lastSession?.folder,
let model = RecapEditModel(folder: folder, voiceprints: voiceprints,
baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification,
templates: settings.recapTemplates, defaultTemplateId: settings.defaultTemplateId)
else { return }
EditorWindow.shared.show(model: model)
}
@@ -53,6 +53,21 @@ final class AppSettings: ObservableObject {
didSet { defaults.set(recapEnabled, forKey: Keys.recapEnabled) }
}
/// User-editable recap templates (takeaways categories per meeting type).
@Published var recapTemplates: [RecapTemplate] {
didSet { persist(recapTemplates, forKey: Keys.recapTemplates) }
}
/// Id of the template used automatically for new recaps.
@Published var defaultTemplateId: String {
didSet { defaults.set(defaultTemplateId, forKey: Keys.defaultTemplate) }
}
/// The active default template (falls back to the first available / built-in).
var defaultTemplate: RecapTemplate {
recapTemplates.first { $0.id == defaultTemplateId } ?? recapTemplates.first ?? .internalMeeting
}
/// Output folder as a resolved file URL (expands a leading `~`).
var outputFolderURL: URL {
URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath,
@@ -81,6 +96,16 @@ final class AppSettings: ObservableObject {
self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me"
self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false
self.recapEnabled = defaults.object(forKey: Keys.recapEnabled) as? Bool ?? true
let loaded = (defaults.data(forKey: Keys.recapTemplates))
.flatMap { try? JSONDecoder().decode([RecapTemplate].self, from: $0) }
self.recapTemplates = (loaded?.isEmpty == false) ? loaded! : RecapTemplate.builtIns
self.defaultTemplateId = defaults.string(forKey: Keys.defaultTemplate)
?? RecapTemplate.builtIns.first!.id
}
private func persist<T: Encodable>(_ value: T, forKey key: String) {
if let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: key) }
}
private enum Keys {
@@ -92,5 +117,7 @@ final class AppSettings: ObservableObject {
static let selfName = "selfName"
static let autoSend = "autoSendOnStop"
static let recapEnabled = "recapEnabled"
static let recapTemplates = "recapTemplates"
static let defaultTemplate = "defaultTemplateId"
}
}
+26
View File
@@ -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)
}
}
+7 -1
View File
@@ -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)
}
+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 } }
@@ -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) }