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:
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user