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
+77 -76
View File
@@ -29,25 +29,27 @@ final class RecapAnalyzer {
// MARK: - Orchestration // MARK: - Orchestration
/// Analyze (topics) extras. Extras are best-effort (nil on failure). /// Analyze (topics) template-driven extras. Extras are best-effort (nil on failure).
func recap(file: SpeakersFile, progress: ((String) async -> Void)? = nil) async throws -> RecapResult { func recap(file: SpeakersFile, template: RecapTemplate,
progress: ((String) async -> Void)? = nil) async throws -> RecapResult {
let entries = Self.entries(from: file) let entries = Self.entries(from: file)
guard !entries.isEmpty else { return RecapResult(sections: [], extras: nil) } guard !entries.isEmpty else { return RecapResult(sections: [], extras: nil) }
await progress?("Finding topics…") await progress?("Finding topics…")
let sections = try await analyze(entries: entries) let sections = try await analyze(entries: entries, granularity: template.topicGranularity)
await progress?("Extracting highlights…") await progress?("Extracting takeaways…")
let extras = try? await self.extras(file: file, entries: entries, sections: sections) let extras = try? await self.extras(file: file, entries: entries, sections: sections, template: template)
return RecapResult(sections: sections, extras: extras) return RecapResult(sections: sections, extras: extras)
} }
// MARK: - Analyze (chunked windows stitched sections) // 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) let windows = Self.planWindows(entries)
var all: [TopicSection] = [] var all: [TopicSection] = []
for w in windows { for w in windows {
let local = Array(entries[w.startIdx...w.endIdx]) 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) let content = try await llm.completeJSON(model: model, system: nil, user: prompt)
for s in Self.parseSections(content) { for s in Self.parseSections(content) {
let gs = w.startIdx + max(0, min(s.startIndex, local.count - 1)) let gs = w.startIdx + max(0, min(s.startIndex, local.count - 1))
@@ -114,10 +116,11 @@ final class RecapAnalyzer {
// MARK: - Extras // MARK: - Extras
func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection]) async throws -> MeetingExtras? { func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection],
let prompt = Self.extrasPrompt(file: file, entries: entries, sections: sections) 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) 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 // MARK: - Entries
@@ -138,14 +141,15 @@ final class RecapAnalyzer {
// MARK: - Prompts // 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() let lines = window.enumerated()
.map { "[\($0.offset)] (\(mmss($0.element.offset))) \($0.element.speaker): \($0.element.text)" } .map { "[\($0.offset)] (\(mmss($0.element.offset))) \($0.element.speaker): \($0.element.text)" }
.joined(separator: "\n") .joined(separator: "\n")
let windowSpan = (window.last?.end ?? 0) - (window.first?.offset ?? 0) let windowSpan = (window.last?.end ?? 0) - (window.first?.offset ?? 0)
let windowMin = max(1, Int((windowSpan / 60).rounded())) let windowMin = max(1, Int((windowSpan / 60).rounded()))
let maxIndex = window.count - 1 let maxIndex = window.count - 1
let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount) let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount, granularity: granularity)
return """ 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). 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 m = totalSec / 60
let total = m < 5 ? 3 : m < 15 ? 4 : m < 30 ? 6 : m < 60 ? 8 : m < 120 ? 12 : 16 let base = m < 5 ? 3.0 : 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 total = base * granularity.multiplier
let per = max(2, Int((total / Double(max(1, windowCount))).rounded()))
return "around \(per) sections" 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 names = orderedSpeakerNames(entries)
let roster = names.isEmpty ? "(unknown)" : names.joined(separator: ", ") let roster = names.isEmpty ? "(unknown)" : names.joined(separator: ", ")
let topics = sections.isEmpty ? "(none)" : let topics = sections.isEmpty ? "(none)" :
sections.enumerated().map { "\($0.offset + 1). \($0.element.title)" }.joined(separator: "\n") sections.enumerated().map { "\($0.offset + 1). \($0.element.title)" }.joined(separator: "\n")
let transcript = cappedTranscript(entries, maxChars: 24_000) 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 """ 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: MEETING METADATA:
- App: \(file.app) - App: \(file.app)
- Duration: \(durationStr) - Duration: \(mmss(file.durationSec))
SPEAKERS: \(roster) SPEAKERS: \(roster)
@@ -212,41 +245,14 @@ final class RecapAnalyzer {
\(transcript) \(transcript)
INSTRUCTIONS: 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. Fields:
- summary: the 2-4 sentence executive summary \(fields.joined(separator: "\n"))
- primary_speakers: array of names who drove the conversation (1-3, in rough order of contribution). Empty array if unclear.
2. DECISIONS — Things explicitly decided/agreed. Only clear commitments, not casual mentions. For each: Respond with ONLY valid JSON in this exact shape, no other text:
- 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:
{ {
"tldr": {"summary": "...", "primary_speakers": []}, \(shape.joined(separator: ",\n "))
"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": "..."}]
} }
""" """
} }
@@ -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 } guard let o = jsonObject(content) else { return nil }
let tldrObj = o["tldr"] as? [String: Any] let tldr = template.includeTLDR ? ((o["tldr"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") : ""
let tldr = MeetingExtras.TLDR( let primary = stringArray(o["primary_speakers"])
summary: (tldrObj?["summary"] as? String) ?? "",
primarySpeakers: stringArray(tldrObj?["primary_speakers"])) var rendered: [RenderedSection] = []
let decisions = (o["decisions"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.Decision? in for (i, sec) in template.sections.enumerated() {
guard let st = nonEmpty(d["statement"]) else { return nil } let v = o["sec_\(i)"]
return .init(statement: st, agreedBy: stringArray(d["agreed_by"]), supportingOffset: intVal(d["supporting_offset"])) 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"]))
} }
let actions = (o["action_items"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.ActionItem? in rendered.append(RenderedSection(title: sec.title, kind: .items, items: items))
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 !tldr.isEmpty || rendered.contains(where: { !$0.isEmpty }) else { return nil }
guard let q = nonEmpty(d["quote"]) else { return nil } return RecapExtras(tldr: tldr, primarySpeakers: primary, sections: rendered)
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)
} }
private static func intVal(_ v: Any?) -> Int? { private static func intVal(_ v: Any?) -> Int? {
+9 -2
View File
@@ -18,6 +18,8 @@ final class RecapEditModel: ObservableObject {
private var originalSpeakers: [String] private var originalSpeakers: [String]
private var renameOps: [(from: String, to: 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 segments: [SpeakersFile.Segment]
@Published private(set) var speakers: [String] @Published private(set) var speakers: [String]
@Published private(set) var dirty = false @Published private(set) var dirty = false
@@ -25,7 +27,8 @@ final class RecapEditModel: ObservableObject {
@Published private(set) var hasRecap: Bool @Published private(set) var hasRecap: Bool
@Published private(set) var status: String? @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") let speakersURL = folder.appendingPathComponent("speakers.json")
guard let data = try? Data(contentsOf: speakersURL), guard let data = try? Data(contentsOf: speakersURL),
let file = try? JSONDecoder().decode(SpeakersFile.self, from: data), let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
@@ -34,6 +37,9 @@ final class RecapEditModel: ObservableObject {
self.voiceprints = voiceprints self.voiceprints = voiceprints
self.baseURL = baseURL self.baseURL = baseURL
self.skipTLS = skipTLS 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.base = file
self.segments = file.segments self.segments = file.segments
self.speakers = SpeakerEditing.orderedSpeakers(file.segments) self.speakers = SpeakerEditing.orderedSpeakers(file.segments)
@@ -102,13 +108,14 @@ final class RecapEditModel: ObservableObject {
defer { regenerating = false } defer { regenerating = false }
let file = commitCorrections() let file = commitCorrections()
let template = templates.first { $0.id == selectedTemplateId } ?? templates.first ?? .internalMeeting
let llm = GatewayLLMClient(baseURL: baseURL, skipTLS: skipTLS) let llm = GatewayLLMClient(baseURL: baseURL, skipTLS: skipTLS)
guard let model = await llm.chatModelId() else { guard let model = await llm.chatModelId() else {
status = "No language model on the gateway — saved corrections only." status = "No language model on the gateway — saved corrections only."
rebaseline(); return rebaseline(); return
} }
let analyzer = RecapAnalyzer(llm: llm, model: model) 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." status = "Recap regeneration failed — corrections were saved."
rebaseline(); return rebaseline(); return
} }
+31 -17
View File
@@ -9,32 +9,46 @@ struct TopicSection: Equatable, Codable {
var endIndex: Int var endIndex: Int
} }
/// Structured "meeting extras" extracted from the named transcript. Mirrors /// Generic, template-driven takeaways extracted from the named transcript: a TLDR
/// recap-relay's schema; speakers are real names (we already have them from /// plus an ordered list of sections whose categories come from the active
/// label-merge), not anonymous cluster ids. /// `RecapTemplate` (Settings), not from code. Speakers are real names (label-merge).
struct MeetingExtras: Equatable, Codable { struct RecapExtras: Equatable, Codable {
struct TLDR: Equatable, Codable { var summary: String; var primarySpeakers: [String] } var tldr: String
struct Decision: Equatable, Codable { var statement: String; var agreedBy: [String]; var supportingOffset: Int? } var primarySpeakers: [String]
struct ActionItem: Equatable, Codable { var description: String; var owner: String?; var dueHint: String?; var supportingOffset: Int? } var sections: [RenderedSection]
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 }
var tldr: TLDR var isEmpty: Bool { tldr.isEmpty && sections.allSatisfy { $0.isEmpty } }
var decisions: [Decision] }
var actionItems: [ActionItem]
var openQuestions: [OpenQuestion]
var keyQuotes: [KeyQuote]
var isEmptyBeyondTLDR: Bool { /// One rendered takeaways section. Only the field matching `kind` is populated.
decisions.isEmpty && actionItems.isEmpty && openQuestions.isEmpty && keyQuotes.isEmpty 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, /// The assembled recap for one session: the topic sections + (optional) extras,
/// over the session's transcript. Rendered to `transcript.md` / `recap.html`. /// over the session's transcript. Rendered to `transcript.md` / `recap.html`.
struct RecapResult: Equatable, Codable { struct RecapResult: Equatable, Codable {
var sections: [TopicSection] var sections: [TopicSection]
var extras: MeetingExtras? var extras: RecapExtras?
} }
/// Persisted `recap.json` the recap result plus its title, so the speaker editor /// Persisted `recap.json` the recap result plus its title, so the speaker editor
+33 -77
View File
@@ -24,53 +24,29 @@ enum RecapRenderer {
out += "*\n\n" out += "*\n\n"
if let x = result.extras { if let x = result.extras {
if !x.tldr.summary.isEmpty { if !x.tldr.isEmpty {
out += "## Summary\n\n\(x.tldr.summary)\n" out += "## Summary\n\n\(x.tldr)\n"
if !x.tldr.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.tldr.primarySpeakers.joined(separator: ", "))*\n" } if !x.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.primarySpeakers.joined(separator: ", "))*\n" }
out += "\n" out += "\n"
} }
if !x.decisions.isEmpty { for section in x.sections where !section.isEmpty {
out += "## Decisions\n\n" out += "## \(section.title)\n\n"
for d in x.decisions { switch section.kind {
var line = "- \(d.statement)" case .paragraph:
if !d.agreedBy.isEmpty { line += " — agreed by \(d.agreedBy.joined(separator: ", "))" } out += "\(section.paragraph)\n\n"
if let o = d.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(o))))*" } case .bullets:
for b in section.bullets { out += "- \(b)\n" }
out += "\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 += line + "\n"
} }
out += "\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: ", "))" }
out += "\n"
if !k.whyNotable.isEmpty { out += ">\n> \(k.whyNotable)\n" }
out += "\n"
}
} }
} }
@@ -125,46 +101,26 @@ enum RecapRenderer {
body += "</header>" body += "</header>"
if let x = result.extras { if let x = result.extras {
if !x.tldr.summary.isEmpty { if !x.tldr.isEmpty {
body += card("Summary", "<p>\(esc(x.tldr.summary))</p>" body += card("Summary", "<p>\(esc(x.tldr))</p>"
+ (x.tldr.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))</p>")) + (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
} }
if !x.decisions.isEmpty { for section in x.sections where !section.isEmpty {
let items = x.decisions.map { d -> String in switch section.kind {
var s = "<li>\(esc(d.statement))" case .paragraph:
if !d.agreedBy.isEmpty { s += " <span class=\"muted\">— agreed by \(d.agreedBy.map(esc).joined(separator: ", "))</span>" } body += card(section.title, "<p>\(esc(section.paragraph))</p>")
if let o = d.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(o)))</span>" } 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>" return s + "</li>"
}.joined() }.joined()
body += card("Decisions", "<ul>\(items)</ul>") body += card(section.title, "<ul>\(lis)</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)
} }
} }
+101
View File
@@ -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."),
])
}
}
+9 -10
View File
@@ -61,16 +61,15 @@ enum SpeakerEditing {
} }
var extras = result.extras var extras = result.extras
if let x = result.extras { if let x = result.extras {
extras = MeetingExtras( let rendered = x.sections.map { sec in
tldr: .init(summary: replaceWords(x.tldr.summary, map), RenderedSection(title: sec.title, kind: sec.kind,
primarySpeakers: exactList(x.tldr.primarySpeakers)), bullets: sec.bullets.map { replaceWords($0, map) },
decisions: x.decisions.map { .init(statement: replaceWords($0.statement, map), items: sec.items.map { RecapItem(text: replaceWords($0.text, map), who: exact($0.who),
agreedBy: exactList($0.agreedBy), supportingOffset: $0.supportingOffset) }, when: $0.when, note: $0.note.map { replaceWords($0, map) }) },
actionItems: x.actionItems.map { .init(description: replaceWords($0.description, map), paragraph: replaceWords(sec.paragraph, 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)) }, extras = RecapExtras(tldr: replaceWords(x.tldr, map),
keyQuotes: x.keyQuotes.map { .init(speaker: exact($0.speaker), offset: $0.offset, primarySpeakers: exactList(x.primarySpeakers), sections: rendered)
quote: replaceWords($0.quote, map), whyNotable: replaceWords($0.whyNotable, map)) })
} }
return RecapResult(sections: sections, extras: extras) return RecapResult(sections: sections, extras: extras)
} }
@@ -400,10 +400,11 @@ final class SessionController: ObservableObject {
/// the gateway LLM. Best-effort: a missing LLM or any failure leaves the /// the gateway LLM. Best-effort: a missing LLM or any failure leaves the
/// transcript intact and just skips the recap. /// transcript intact and just skips the recap.
private func buildRecap(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async { private func buildRecap(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
let template = settings.defaultTemplate
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification) let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
guard let model = await llm.chatModelId() else { return } // no LLM on the gateway skip guard let model = await llm.chatModelId() else { return } // no LLM on the gateway skip
let analyzer = RecapAnalyzer(llm: llm, model: model) let analyzer = RecapAnalyzer(llm: llm, model: model)
guard let result = try? await analyzer.recap(file: speakers) else { return } guard let result = try? await analyzer.recap(file: speakers, template: template) else { return }
let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId) let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId)
try? RecapRenderer.write(file: speakers, result: result, title: title, to: inputs.folder) try? RecapRenderer.write(file: speakers, result: result, title: title, to: inputs.folder)
try? RecapFile(title: title, result: result).write(to: inputs.folder.appendingPathComponent("recap.json")) try? RecapFile(title: title, result: result).write(to: inputs.folder.appendingPathComponent("recap.json"))
@@ -434,7 +435,8 @@ final class SessionController: ObservableObject {
func editLastSession() { func editLastSession() {
guard let folder = lastSession?.folder, guard let folder = lastSession?.folder,
let model = RecapEditModel(folder: folder, voiceprints: voiceprints, let model = RecapEditModel(folder: folder, voiceprints: voiceprints,
baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification) baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification,
templates: settings.recapTemplates, defaultTemplateId: settings.defaultTemplateId)
else { return } else { return }
EditorWindow.shared.show(model: model) EditorWindow.shared.show(model: model)
} }
@@ -53,6 +53,21 @@ final class AppSettings: ObservableObject {
didSet { defaults.set(recapEnabled, forKey: Keys.recapEnabled) } didSet { defaults.set(recapEnabled, forKey: Keys.recapEnabled) }
} }
/// User-editable recap templates (takeaways categories per meeting type).
@Published var recapTemplates: [RecapTemplate] {
didSet { persist(recapTemplates, forKey: Keys.recapTemplates) }
}
/// Id of the template used automatically for new recaps.
@Published var defaultTemplateId: String {
didSet { defaults.set(defaultTemplateId, forKey: Keys.defaultTemplate) }
}
/// The active default template (falls back to the first available / built-in).
var defaultTemplate: RecapTemplate {
recapTemplates.first { $0.id == defaultTemplateId } ?? recapTemplates.first ?? .internalMeeting
}
/// Output folder as a resolved file URL (expands a leading `~`). /// Output folder as a resolved file URL (expands a leading `~`).
var outputFolderURL: URL { var outputFolderURL: URL {
URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath, URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath,
@@ -81,6 +96,16 @@ final class AppSettings: ObservableObject {
self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me" self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me"
self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false
self.recapEnabled = defaults.object(forKey: Keys.recapEnabled) as? Bool ?? true self.recapEnabled = defaults.object(forKey: Keys.recapEnabled) as? Bool ?? true
let loaded = (defaults.data(forKey: Keys.recapTemplates))
.flatMap { try? JSONDecoder().decode([RecapTemplate].self, from: $0) }
self.recapTemplates = (loaded?.isEmpty == false) ? loaded! : RecapTemplate.builtIns
self.defaultTemplateId = defaults.string(forKey: Keys.defaultTemplate)
?? RecapTemplate.builtIns.first!.id
}
private func persist<T: Encodable>(_ value: T, forKey key: String) {
if let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: key) }
} }
private enum Keys { private enum Keys {
@@ -92,5 +117,7 @@ final class AppSettings: ObservableObject {
static let selfName = "selfName" static let selfName = "selfName"
static let autoSend = "autoSendOnStop" static let autoSend = "autoSendOnStop"
static let recapEnabled = "recapEnabled" static let recapEnabled = "recapEnabled"
static let recapTemplates = "recapTemplates"
static let defaultTemplate = "defaultTemplateId"
} }
} }
+26
View File
@@ -30,3 +30,29 @@ final class EditorWindow {
w.makeKeyAndOrderFront(nil) w.makeKeyAndOrderFront(nil)
} }
} }
/// Hosts the recap-templates manager in its own resizable window.
@MainActor
final class TemplatesWindow {
static let shared = TemplatesWindow()
private var window: NSWindow?
func show(settings: AppSettings) {
if let window {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 760, height: 560),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered, defer: false)
w.title = "Recap Templates"
w.isReleasedWhenClosed = false
w.center()
w.contentViewController = NSHostingController(rootView: TemplatesView(settings: settings))
window = w
NSApp.activate(ignoringOtherApps: true)
w.makeKeyAndOrderFront(nil)
}
}
+7 -1
View File
@@ -26,7 +26,13 @@ struct SettingsView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Toggle("Auto-send recordings to backend", isOn: $settings.autoSendOnStop) Toggle("Auto-send recordings to backend", isOn: $settings.autoSendOnStop)
Toggle("Build readable recap (topics + highlights)", isOn: $settings.recapEnabled) Toggle("Build readable recap (topics + highlights)", isOn: $settings.recapEnabled)
Text("Your name labels your mic channel. Auto-send transcribes on stop; the recap writes transcript.md + recap.html.") HStack {
Picker("Default recap template", selection: $settings.defaultTemplateId) {
ForEach(settings.recapTemplates) { Text($0.name).tag($0.id) }
}
Button("Manage…") { TemplatesWindow.shared.show(settings: settings) }
}
Text("Your name labels your mic channel. Auto-send transcribes on stop; the recap writes transcript.md + recap.html. Templates define the takeaways categories per meeting type.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
+177
View File
@@ -0,0 +1,177 @@
import SwiftUI
/// Manage recap templates: the takeaways categories per meeting type. Edit titles,
/// types, and the per-category instructions that drive the LLM and preview the
/// exact prompt that gets sent, so nothing is hidden in code.
struct TemplatesView: View {
@ObservedObject var settings: AppSettings
@State private var selectedId: String?
@State private var previewText: String?
var body: some View {
HSplitView {
sidebar.frame(minWidth: 200, maxWidth: 260)
detail.frame(minWidth: 420)
}
.frame(minWidth: 720, minHeight: 520)
.onAppear { if selectedId == nil { selectedId = settings.defaultTemplateId } }
.sheet(item: Binding(get: { previewText.map { IdString($0) } }, set: { previewText = $0?.value })) { item in
previewSheet(item.value)
}
}
// MARK: - Sidebar (template list + default picker)
private var sidebar: some View {
VStack(alignment: .leading, spacing: 0) {
List(selection: $selectedId) {
ForEach(settings.recapTemplates) { t in
HStack {
Text(t.name)
if t.id == settings.defaultTemplateId {
Spacer(); Image(systemName: "star.fill").foregroundStyle(.yellow).font(.caption)
}
}.tag(t.id)
}
}
Divider()
HStack(spacing: 6) {
Button { addTemplate() } label: { Image(systemName: "plus") }
Button { duplicateSelected() } label: { Image(systemName: "plus.square.on.square") }
.disabled(selected == nil)
Button { deleteSelected() } label: { Image(systemName: "trash") }
.disabled(selected == nil || settings.recapTemplates.count <= 1)
Spacer()
}
.buttonStyle(.borderless).padding(8)
}
}
// MARK: - Detail (edit selected template)
private var detail: some View {
Group {
if let binding = selectedBinding {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack {
TextField("Template name", text: binding.name).textFieldStyle(.roundedBorder)
if binding.wrappedValue.id != settings.defaultTemplateId {
Button("Make default") { settings.defaultTemplateId = binding.wrappedValue.id }
} else {
Label("Default", systemImage: "star.fill").foregroundStyle(.secondary).font(.caption)
}
}
HStack(spacing: 16) {
Toggle("Include summary (TLDR)", isOn: binding.includeTLDR)
Picker("Topic detail", selection: binding.topicGranularity) {
ForEach(TopicGranularity.allCases) { Text($0.label).tag($0) }
}.frame(maxWidth: 240)
}
Divider()
Text("Takeaways sections").font(.headline)
Text("Each becomes a section of the recap. The instruction is the prompt text telling the model what to extract.")
.font(.caption).foregroundStyle(.secondary)
sectionsEditor(binding)
Button { addSection(binding) } label: { Label("Add section", systemImage: "plus") }
Divider()
Button("Preview prompt…") { previewText = buildPreview(binding.wrappedValue) }
}
.padding(16)
}
} else {
Text("Select a template").foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private func sectionsEditor(_ template: Binding<RecapTemplate>) -> some View {
VStack(spacing: 10) {
ForEach(Array(template.sections.enumerated()), id: \.element.id) { idx, section in
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField("Section title", text: section.title).textFieldStyle(.roundedBorder)
Picker("", selection: section.kind) {
ForEach(SectionKind.allCases) { Text($0.label).tag($0) }
}.labelsHidden().frame(width: 150)
Button { move(template, idx, by: -1) } label: { Image(systemName: "arrow.up") }
.disabled(idx == 0).buttonStyle(.borderless)
Button { move(template, idx, by: 1) } label: { Image(systemName: "arrow.down") }
.disabled(idx == template.wrappedValue.sections.count - 1).buttonStyle(.borderless)
Button { removeSection(template, idx) } label: { Image(systemName: "trash") }
.buttonStyle(.borderless).foregroundStyle(.red)
}
TextEditor(text: section.instruction)
.font(.callout).frame(minHeight: 48, maxHeight: 90)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(.quaternary))
}
.padding(10).background(Color(nsColor: .controlBackgroundColor)).cornerRadius(8)
}
}
}
// MARK: - Mutations
private var selected: RecapTemplate? { settings.recapTemplates.first { $0.id == selectedId } }
private var selectedBinding: Binding<RecapTemplate>? {
guard let id = selectedId, let i = settings.recapTemplates.firstIndex(where: { $0.id == id }) else { return nil }
return Binding(get: { settings.recapTemplates[i] }, set: { settings.recapTemplates[i] = $0 })
}
private func addTemplate() {
let t = RecapTemplate(name: "New Template", sections: [
.init(title: "Key Takeaways", kind: .bullets, instruction: "The main points from the conversation.")
])
settings.recapTemplates.append(t); selectedId = t.id
}
private func duplicateSelected() {
guard let s = selected else { return }
var copy = s; copy.id = UUID().uuidString; copy.name = s.name + " copy"
copy.sections = s.sections.map { var c = $0; c.id = UUID().uuidString; return c }
settings.recapTemplates.append(copy); selectedId = copy.id
}
private func deleteSelected() {
guard let id = selectedId else { return }
settings.recapTemplates.removeAll { $0.id == id }
if settings.defaultTemplateId == id { settings.defaultTemplateId = settings.recapTemplates.first?.id ?? "" }
selectedId = settings.recapTemplates.first?.id
}
private func addSection(_ t: Binding<RecapTemplate>) {
t.wrappedValue.sections.append(.init(title: "New Section", kind: .bullets, instruction: ""))
}
private func removeSection(_ t: Binding<RecapTemplate>, _ idx: Int) {
guard t.wrappedValue.sections.indices.contains(idx) else { return }
t.wrappedValue.sections.remove(at: idx)
}
private func move(_ t: Binding<RecapTemplate>, _ idx: Int, by delta: Int) {
let j = idx + delta
guard t.wrappedValue.sections.indices.contains(idx), t.wrappedValue.sections.indices.contains(j) else { return }
t.wrappedValue.sections.swapAt(idx, j)
}
// MARK: - Preview
private func buildPreview(_ template: RecapTemplate) -> String {
let sample = SpeakersFile(sessionId: "sample", app: "meet", durationSec: 600,
speakers: [], segments: [
.init(start: 5, end: 9, speaker: "Grant", text: "Let's review the roadmap."),
.init(start: 10, end: 16, speaker: "Caitlyn", text: "I think we should ship dual-channel first."),
], models: [:])
let entries = RecapAnalyzer.entries(from: sample)
return RecapAnalyzer.extrasPrompt(file: sample, entries: entries, sections: [], template: template)
}
private func previewSheet(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Prompt sent to the model (with sample transcript)").font(.headline)
ScrollView { Text(text).font(.system(.caption, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) }
.frame(width: 560, height: 380)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
HStack { Spacer(); Button("Done") { previewText = nil }.keyboardShortcut(.defaultAction) }
}
.padding(16)
}
}
private struct IdString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } }
@@ -98,8 +98,12 @@ struct TranscriptEditorView: View {
Button("Save corrections") { model.save() } Button("Save corrections") { model.save() }
.keyboardShortcut("s", modifiers: .command) .keyboardShortcut("s", modifiers: .command)
.disabled(!model.dirty || model.regenerating) .disabled(!model.dirty || model.regenerating)
Picker("", selection: $model.selectedTemplateId) {
ForEach(model.templates) { Text($0.name).tag($0.id) }
}
.labelsHidden().frame(maxWidth: 150).disabled(model.regenerating)
Button("Regenerate recap") { Task { await model.regenerate() } } Button("Regenerate recap") { Task { await model.regenerate() } }
.help("Re-run the analysis on the corrected transcript so summaries use the fixed names.") .help("Re-run the analysis on the corrected transcript with the chosen template.")
.disabled(model.regenerating) .disabled(model.regenerating)
if model.regenerating { ProgressView().controlSize(.small) } if model.regenerating { ProgressView().controlSize(.small) }
if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) } if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) }
+55 -24
View File
@@ -23,30 +23,61 @@ final class RecapTests: XCTestCase {
XCTAssertEqual(RecapAnalyzer.parseSections(json).count, 1) XCTAssertEqual(RecapAnalyzer.parseSections(json).count, 1)
} }
func testParseExtras() { private var sampleTemplate: RecapTemplate {
let json = #""" RecapTemplate(id: "t", name: "T", includeTLDR: true, sections: [
{"tldr":{"summary":"They discussed the roadmap.","primary_speakers":["Grant","Caitlyn"]}, .init(id: "a", title: "Decisions", kind: .items, instruction: ""),
"decisions":[{"statement":"Ship dual-channel","agreed_by":["Grant"],"supporting_offset":72}], .init(id: "b", title: "Takeaways", kind: .bullets, instruction: ""),
"action_items":[{"description":"Send the doc","owner":"Caitlyn","due_hint":"by Friday","supporting_offset":120}], ])
"open_questions":[{"question":"What about Teams?","raised_by":"Grant","answered":false}], }
"key_quotes":[{"speaker":"Caitlyn","offset":73,"quote":"Go Bitcoin","why_notable":"sets the tone"}]}
"""# func testParseExtrasGeneric() {
let x = RecapAnalyzer.parseExtras(json) let json = #"{"tldr":"They discussed the roadmap.","primary_speakers":["Grant","Caitlyn"],"sec_0":[{"text":"Ship dual-channel","who":"Grant","when":72,"note":null}],"sec_1":["faster","cheaper"]}"#
let x = RecapAnalyzer.parseExtras(json, template: sampleTemplate)
XCTAssertNotNil(x) XCTAssertNotNil(x)
XCTAssertEqual(x?.tldr.primarySpeakers, ["Grant", "Caitlyn"]) XCTAssertEqual(x?.tldr, "They discussed the roadmap.")
XCTAssertEqual(x?.decisions.first?.supportingOffset, 72) XCTAssertEqual(x?.primarySpeakers, ["Grant", "Caitlyn"])
XCTAssertEqual(x?.actionItems.first?.owner, "Caitlyn") XCTAssertEqual(x?.sections.count, 2)
XCTAssertEqual(x?.actionItems.first?.dueHint, "by Friday") XCTAssertEqual(x?.sections[0].kind, .items)
XCTAssertEqual(x?.openQuestions.first?.question, "What about Teams?") XCTAssertEqual(x?.sections[0].items.first?.text, "Ship dual-channel")
XCTAssertEqual(x?.keyQuotes.first?.quote, "Go Bitcoin") XCTAssertEqual(x?.sections[0].items.first?.who, "Grant")
XCTAssertEqual(x?.sections[0].items.first?.when, 72)
XCTAssertEqual(x?.sections[1].kind, .bullets)
XCTAssertEqual(x?.sections[1].bullets, ["faster", "cheaper"])
} }
func testParseExtrasDropsNullStrings() { func testParseExtrasDropsNullStrings() {
// owner/raised_by "null" or empty must become nil, not a literal "null". let template = RecapTemplate(id: "t", name: "T", sections: [.init(id: "a", title: "Actions", kind: .items, instruction: "")])
let json = #"{"tldr":{"summary":"s","primary_speakers":[]},"action_items":[{"description":"do it","owner":"null","due_hint":""}],"decisions":[],"open_questions":[],"key_quotes":[]}"# let json = #"{"tldr":"s","primary_speakers":[],"sec_0":[{"text":"do it","who":"null","note":""}]}"#
let x = RecapAnalyzer.parseExtras(json) let x = RecapAnalyzer.parseExtras(json, template: template)
XCTAssertNil(x?.actionItems.first?.owner) XCTAssertNil(x?.sections.first?.items.first?.who)
XCTAssertNil(x?.actionItems.first?.dueHint) XCTAssertNil(x?.sections.first?.items.first?.note)
XCTAssertEqual(x?.sections.first?.items.first?.text, "do it")
}
func testExtrasPromptBuildsFromTemplate() {
let template = RecapTemplate(id: "t", name: "T", includeTLDR: true, sections: [
.init(id: "a", title: "Risks", kind: .bullets, instruction: "List the risks."),
.init(id: "b", title: "Decisions", kind: .items, instruction: "List decisions."),
])
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 60, speakers: [],
segments: [.init(start: 0, end: 2, speaker: "A", text: "hi")], models: [:])
let prompt = RecapAnalyzer.extrasPrompt(file: file, entries: RecapAnalyzer.entries(from: file),
sections: [], template: template)
XCTAssertTrue(prompt.contains("sec_0"))
XCTAssertTrue(prompt.contains("sec_1"))
XCTAssertTrue(prompt.contains("Risks"))
XCTAssertTrue(prompt.contains("List the risks."))
XCTAssertTrue(prompt.contains("tldr"))
}
func testExtrasPromptOmitsTLDRWhenDisabled() {
let template = RecapTemplate(id: "t", name: "T", includeTLDR: false,
sections: [.init(id: "a", title: "X", kind: .paragraph, instruction: "y")])
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 60, speakers: [],
segments: [.init(start: 0, end: 2, speaker: "A", text: "hi")], models: [:])
let prompt = RecapAnalyzer.extrasPrompt(file: file, entries: RecapAnalyzer.entries(from: file),
sections: [], template: template)
XCTAssertFalse(prompt.contains("\"tldr\""))
} }
// MARK: - Stitch / windows // MARK: - Stitch / windows
@@ -108,10 +139,10 @@ final class RecapTests: XCTestCase {
.init(start: 0, end: 4, speaker: "Grant", text: "Now we got a call going on."), .init(start: 0, end: 4, speaker: "Grant", text: "Now we got a call going on."),
.init(start: 72, end: 74, speaker: "Caitlyn", text: "Go Bitcoin."), .init(start: 72, end: 74, speaker: "Caitlyn", text: "Go Bitcoin."),
], models: [:]) ], models: [:])
let extras = MeetingExtras( let extras = RecapExtras(tldr: "A quick test call.", primarySpeakers: ["Grant"], sections: [
tldr: .init(summary: "A quick test call.", primarySpeakers: ["Grant"]), RenderedSection(title: "Action Items", kind: .items, items: [RecapItem(text: "Ship it", who: "Grant", when: 3, note: nil)]),
decisions: [], actionItems: [.init(description: "Ship it", owner: "Grant", dueHint: nil, supportingOffset: 3)], RenderedSection(title: "Key Quotes", kind: .items, items: [RecapItem(text: "Go Bitcoin", who: "Caitlyn", when: 72, note: "tone")]),
openQuestions: [], keyQuotes: [.init(speaker: "Caitlyn", offset: 72, quote: "Go Bitcoin", whyNotable: "tone")]) ])
let result = RecapResult(sections: [TopicSection(title: "Call start", summary: "Grant opens.", startIndex: 0, endIndex: 1)], extras: extras) let result = RecapResult(sections: [TopicSection(title: "Call start", summary: "Grant opens.", startIndex: 0, endIndex: 1)], extras: extras)
let entries = RecapAnalyzer.entries(from: file) let entries = RecapAnalyzer.entries(from: file)
let md = RecapRenderer.markdown(file: file, result: result, title: "Meet call", entries: entries) let md = RecapRenderer.markdown(file: file, result: result, title: "Meet call", entries: entries)
+13 -11
View File
@@ -34,20 +34,20 @@ final class SpeakerEditingTests: XCTestCase {
func testRemapStructuredAndWordBoundaryText() { func testRemapStructuredAndWordBoundaryText() {
let result = RecapResult( let result = RecapResult(
sections: [TopicSection(title: "Grant intro", summary: "Grant and Unknown_0 talk; Grantham stays.", startIndex: 0, endIndex: 1)], sections: [TopicSection(title: "Grant intro", summary: "Grant and Unknown_0 talk; Grantham stays.", startIndex: 0, endIndex: 1)],
extras: MeetingExtras( extras: RecapExtras(tldr: "Grant led.", primarySpeakers: ["Grant"], sections: [
tldr: .init(summary: "Grant led.", primarySpeakers: ["Grant"]), RenderedSection(title: "Decisions", kind: .items,
decisions: [.init(statement: "ship", agreedBy: ["Grant", "Unknown_0"], supportingOffset: 1)], items: [RecapItem(text: "Unknown_0 sends doc", who: "Unknown_0", when: 1, note: nil)]),
actionItems: [.init(description: "Unknown_0 sends doc", owner: "Unknown_0", dueHint: nil, supportingOffset: nil)], RenderedSection(title: "Takeaways", kind: .bullets, bullets: ["Unknown_0 and Grant agree"]),
openQuestions: [], ]))
keyQuotes: [.init(speaker: "Unknown_0", offset: 2, quote: "go", whyNotable: "")]))
let map = ["Unknown_0": "Caitlyn", "Grant": "Grant Gilliam"] let map = ["Unknown_0": "Caitlyn", "Grant": "Grant Gilliam"]
let out = SpeakerEditing.remap(result, names: map) let out = SpeakerEditing.remap(result, names: map)
XCTAssertEqual(out.sections[0].title, "Grant Gilliam intro") XCTAssertEqual(out.sections[0].title, "Grant Gilliam intro")
XCTAssertEqual(out.sections[0].summary, "Grant Gilliam and Caitlyn talk; Grantham stays.") // word boundary keeps "Grantham" XCTAssertEqual(out.sections[0].summary, "Grant Gilliam and Caitlyn talk; Grantham stays.") // word boundary keeps "Grantham"
XCTAssertEqual(out.extras?.tldr.primarySpeakers, ["Grant Gilliam"]) XCTAssertEqual(out.extras?.tldr, "Grant Gilliam led.")
XCTAssertEqual(out.extras?.decisions.first?.agreedBy, ["Grant Gilliam", "Caitlyn"]) XCTAssertEqual(out.extras?.primarySpeakers, ["Grant Gilliam"])
XCTAssertEqual(out.extras?.actionItems.first?.owner, "Caitlyn") XCTAssertEqual(out.extras?.sections[0].items.first?.who, "Caitlyn")
XCTAssertEqual(out.extras?.keyQuotes.first?.speaker, "Caitlyn") XCTAssertEqual(out.extras?.sections[0].items.first?.text, "Caitlyn sends doc")
XCTAssertEqual(out.extras?.sections[1].bullets, ["Caitlyn and Grant Gilliam agree"])
} }
@MainActor @MainActor
@@ -69,7 +69,9 @@ final class SpeakerEditingTests: XCTestCase {
let store = VoiceprintStore(fileURL: dir.appendingPathComponent("voiceprints.json")) let store = VoiceprintStore(fileURL: dir.appendingPathComponent("voiceprints.json"))
let model = try XCTUnwrap(RecapEditModel(folder: dir, voiceprints: store, let model = try XCTUnwrap(RecapEditModel(folder: dir, voiceprints: store,
baseURL: "https://localhost:1", skipTLS: true)) baseURL: "https://localhost:1", skipTLS: true,
templates: RecapTemplate.builtIns,
defaultTemplateId: RecapTemplate.builtIns.first!.id))
model.rename("Unknown_0", to: "Caitlyn") model.rename("Unknown_0", to: "Caitlyn")
XCTAssertTrue(model.speakers.contains("Caitlyn")) XCTAssertTrue(model.speakers.contains("Caitlyn"))
XCTAssertFalse(model.speakers.contains("Unknown_0")) XCTAssertFalse(model.speakers.contains("Unknown_0"))