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