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? {