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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user