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
+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>")
}
}
}