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:
@@ -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>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user