Configurable recap templates (categories per meeting type, in Settings)
Takeaways categories are no longer hardcoded — they're editable templates. A
template = the always-on TLDR + an ordered list of sections, each with a title, a
type (attributed items / bulleted list / paragraph), and an instruction (the prompt
text for that category). The analyzer assembles the LLM prompt FROM the template
and parses generically, so adding/removing/renaming a category needs zero code and
the output always renders.
- RecapTemplate / TemplateSection / SectionKind + TopicGranularity; built-in
defaults (Internal Meeting, 1:1, Company/Sales Call), all editable.
- Generic extras: RecapExtras{tldr, primarySpeakers, sections:[RenderedSection]} +
RecapItem{text,who,when,note} replaces the fixed MeetingExtras. Analyzer builds
per-section sec_N fields + parses by kind; renderer + remap are generic.
- Topic granularity (coarse/auto/fine) answers 'should chunking be configurable' —
it scales the target topic count; raw window sizes stay as tuned defaults.
- AppSettings persists templates + defaultTemplateId (seeded once). Settings gets a
default-template picker + 'Manage…' → TemplatesView (CRUD, edit sections/
instructions, set default, **Preview prompt** for full transparency).
- Recap editor gains a template picker; Regenerate uses the chosen template. Auto
recap uses the default template.
54/54 XCTest (template prompt build, generic parse/remap/render updated).
This commit is contained in:
@@ -29,25 +29,27 @@ final class RecapAnalyzer {
|
||||
|
||||
// MARK: - Orchestration
|
||||
|
||||
/// Analyze (topics) → extras. Extras are best-effort (nil on failure).
|
||||
func recap(file: SpeakersFile, progress: ((String) async -> Void)? = nil) async throws -> RecapResult {
|
||||
/// Analyze (topics) → template-driven extras. Extras are best-effort (nil on failure).
|
||||
func recap(file: SpeakersFile, template: RecapTemplate,
|
||||
progress: ((String) async -> Void)? = nil) async throws -> RecapResult {
|
||||
let entries = Self.entries(from: file)
|
||||
guard !entries.isEmpty else { return RecapResult(sections: [], extras: nil) }
|
||||
await progress?("Finding topics…")
|
||||
let sections = try await analyze(entries: entries)
|
||||
await progress?("Extracting highlights…")
|
||||
let extras = try? await self.extras(file: file, entries: entries, sections: sections)
|
||||
let sections = try await analyze(entries: entries, granularity: template.topicGranularity)
|
||||
await progress?("Extracting takeaways…")
|
||||
let extras = try? await self.extras(file: file, entries: entries, sections: sections, template: template)
|
||||
return RecapResult(sections: sections, extras: extras)
|
||||
}
|
||||
|
||||
// MARK: - Analyze (chunked windows → stitched sections)
|
||||
|
||||
func analyze(entries: [Entry]) async throws -> [TopicSection] {
|
||||
func analyze(entries: [Entry], granularity: TopicGranularity = .auto) async throws -> [TopicSection] {
|
||||
let windows = Self.planWindows(entries)
|
||||
var all: [TopicSection] = []
|
||||
for w in windows {
|
||||
let local = Array(entries[w.startIdx...w.endIdx])
|
||||
let prompt = Self.analyzePrompt(local, totalSec: entries.last?.end ?? 0, windowCount: windows.count)
|
||||
let prompt = Self.analyzePrompt(local, totalSec: entries.last?.end ?? 0,
|
||||
windowCount: windows.count, granularity: granularity)
|
||||
let content = try await llm.completeJSON(model: model, system: nil, user: prompt)
|
||||
for s in Self.parseSections(content) {
|
||||
let gs = w.startIdx + max(0, min(s.startIndex, local.count - 1))
|
||||
@@ -114,10 +116,11 @@ final class RecapAnalyzer {
|
||||
|
||||
// MARK: - Extras
|
||||
|
||||
func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection]) async throws -> MeetingExtras? {
|
||||
let prompt = Self.extrasPrompt(file: file, entries: entries, sections: sections)
|
||||
func extras(file: SpeakersFile, entries: [Entry], sections: [TopicSection],
|
||||
template: RecapTemplate) async throws -> RecapExtras? {
|
||||
let prompt = Self.extrasPrompt(file: file, entries: entries, sections: sections, template: template)
|
||||
let content = try await llm.completeJSON(model: model, system: nil, user: prompt, maxTokens: 4096)
|
||||
return Self.parseExtras(content)
|
||||
return Self.parseExtras(content, template: template)
|
||||
}
|
||||
|
||||
// MARK: - Entries
|
||||
@@ -138,14 +141,15 @@ final class RecapAnalyzer {
|
||||
|
||||
// MARK: - Prompts
|
||||
|
||||
private static func analyzePrompt(_ window: [Entry], totalSec: Double, windowCount: Int) -> String {
|
||||
private static func analyzePrompt(_ window: [Entry], totalSec: Double, windowCount: Int,
|
||||
granularity: TopicGranularity) -> String {
|
||||
let lines = window.enumerated()
|
||||
.map { "[\($0.offset)] (\(mmss($0.element.offset))) \($0.element.speaker): \($0.element.text)" }
|
||||
.joined(separator: "\n")
|
||||
let windowSpan = (window.last?.end ?? 0) - (window.first?.offset ?? 0)
|
||||
let windowMin = max(1, Int((windowSpan / 60).rounded()))
|
||||
let maxIndex = window.count - 1
|
||||
let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount)
|
||||
let targetSections = targetSectionsPhrase(totalSec: totalSec, windowCount: windowCount, granularity: granularity)
|
||||
return """
|
||||
You are analyzing a ~\(windowMin)-minute section of a longer transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections — aim for \(targetSections).
|
||||
|
||||
@@ -182,26 +186,55 @@ final class RecapAnalyzer {
|
||||
"""
|
||||
}
|
||||
|
||||
private static func targetSectionsPhrase(totalSec: Double, windowCount: Int) -> String {
|
||||
private static func targetSectionsPhrase(totalSec: Double, windowCount: Int,
|
||||
granularity: TopicGranularity) -> String {
|
||||
let m = totalSec / 60
|
||||
let total = m < 5 ? 3 : m < 15 ? 4 : m < 30 ? 6 : m < 60 ? 8 : m < 120 ? 12 : 16
|
||||
let per = max(2, Int((Double(total) / Double(max(1, windowCount))).rounded()))
|
||||
let base = m < 5 ? 3.0 : m < 15 ? 4 : m < 30 ? 6 : m < 60 ? 8 : m < 120 ? 12 : 16
|
||||
let total = base * granularity.multiplier
|
||||
let per = max(2, Int((total / Double(max(1, windowCount))).rounded()))
|
||||
return "around \(per) sections"
|
||||
}
|
||||
|
||||
private static func extrasPrompt(file: SpeakersFile, entries: [Entry], sections: [TopicSection]) -> String {
|
||||
/// Assemble the extras prompt FROM the template, so prompt + parsing + rendering
|
||||
/// always agree. Each section becomes a `sec_<i>` field shaped by its kind.
|
||||
/// Exposed so Settings can show the user the exact prompt that will be sent.
|
||||
static func extrasPrompt(file: SpeakersFile, entries: [Entry], sections: [TopicSection],
|
||||
template: RecapTemplate) -> String {
|
||||
let names = orderedSpeakerNames(entries)
|
||||
let roster = names.isEmpty ? "(unknown)" : names.joined(separator: ", ")
|
||||
let topics = sections.isEmpty ? "(none)" :
|
||||
sections.enumerated().map { "\($0.offset + 1). \($0.element.title)" }.joined(separator: "\n")
|
||||
let transcript = cappedTranscript(entries, maxChars: 24_000)
|
||||
let durationStr = mmss(file.durationSec)
|
||||
|
||||
var fields: [String] = []
|
||||
var shape: [String] = []
|
||||
if template.includeTLDR {
|
||||
fields.append("- \"tldr\": a 2-4 sentence executive summary string (what it was about, the arc, the outcome; past tense, dense; skip pleasantries; one factual sentence if there's no substance).")
|
||||
fields.append("- \"primary_speakers\": array of the 1-3 names who drove the conversation, in rough order of contribution (empty if unclear).")
|
||||
shape.append("\"tldr\": \"...\"")
|
||||
shape.append("\"primary_speakers\": []")
|
||||
}
|
||||
for (i, sec) in template.sections.enumerated() {
|
||||
let key = "sec_\(i)"
|
||||
switch sec.kind {
|
||||
case .bullets:
|
||||
fields.append("- \"\(key)\" (\(sec.title)): array of short strings. \(sec.instruction)")
|
||||
shape.append("\"\(key)\": [\"...\"]")
|
||||
case .paragraph:
|
||||
fields.append("- \"\(key)\" (\(sec.title)): a string. \(sec.instruction)")
|
||||
shape.append("\"\(key)\": \"...\"")
|
||||
case .items:
|
||||
fields.append("- \"\(key)\" (\(sec.title)): array of {\"text\": string, \"who\": name or null, \"when\": integer seconds or null, \"note\": string or null}. \(sec.instruction)")
|
||||
shape.append("\"\(key)\": [{\"text\": \"...\", \"who\": null, \"when\": null, \"note\": null}]")
|
||||
}
|
||||
}
|
||||
|
||||
return """
|
||||
You are extracting structured information from an internal team meeting transcript. The transcript below is labeled with the speakers' real names where known.
|
||||
You are extracting structured takeaways from a call transcript. The transcript is labeled with the speakers' real names where known.
|
||||
|
||||
MEETING METADATA:
|
||||
- App: \(file.app)
|
||||
- Duration: \(durationStr)
|
||||
- Duration: \(mmss(file.durationSec))
|
||||
|
||||
SPEAKERS: \(roster)
|
||||
|
||||
@@ -212,41 +245,14 @@ final class RecapAnalyzer {
|
||||
\(transcript)
|
||||
|
||||
INSTRUCTIONS:
|
||||
Extract FIVE categories of information. Return EMPTY ARRAYS for categories that don't apply — do NOT invent items. Use the speakers' names exactly as shown above; use null/empty when a person is unclear.
|
||||
Produce ONLY the JSON object below. Use the speakers' names exactly as shown above; use null/empty when a person or value is unclear. For any \"when\"/offset, convert the [<name> <MM:SS>] timestamp to total seconds. Be conservative — return EMPTY arrays (or null) rather than inventing anything.
|
||||
|
||||
1. TLDR — A 2-4 sentence executive summary of the entire meeting: what it was about, the key discussion arc, and the bottom-line outcome. Past tense, third person, dense. Skip pleasantries. If the meeting was genuinely substanceless, write one factual sentence. This is the only required category.
|
||||
- summary: the 2-4 sentence executive summary
|
||||
- primary_speakers: array of names who drove the conversation (1-3, in rough order of contribution). Empty array if unclear.
|
||||
Fields:
|
||||
\(fields.joined(separator: "\n"))
|
||||
|
||||
2. DECISIONS — Things explicitly decided/agreed. Only clear commitments, not casual mentions. For each:
|
||||
- statement: the decision in one sentence
|
||||
- agreed_by: array of names who explicitly agreed (empty if unclear)
|
||||
- supporting_offset: integer SECONDS where it was decided (convert the [<name> <MM:SS>] timestamp to total seconds)
|
||||
|
||||
3. ACTION_ITEMS — Explicit ownership ("I'll send the doc", "Matt will follow up"), not vague "someone should". For each:
|
||||
- description: the action in imperative form
|
||||
- owner: the person's name, or null if unclear
|
||||
- due_hint: deadline string if mentioned ("by Friday"), or null
|
||||
- supporting_offset: integer seconds where the commitment was made
|
||||
|
||||
4. OPEN_QUESTIONS — Questions raised that were NOT clearly answered. Skip rhetorical/answered ones. For each:
|
||||
- question: rephrased to be self-contained
|
||||
- raised_by: the person's name, or null
|
||||
- answered: false (always)
|
||||
|
||||
5. KEY_QUOTES — 3-6 max. Pivotal/insightful/strong-opinion statements worth surfacing verbatim. For each:
|
||||
- speaker: the person's name (or null)
|
||||
- offset: integer seconds where the quote occurs
|
||||
- quote: the verbatim quote (4-30 words)
|
||||
- why_notable: one short clause
|
||||
|
||||
Be conservative — better an empty array than a fabrication. Respond with ONLY valid JSON in this exact shape, no other text:
|
||||
Respond with ONLY valid JSON in this exact shape, no other text:
|
||||
{
|
||||
"tldr": {"summary": "...", "primary_speakers": []},
|
||||
"decisions": [{"statement": "...", "agreed_by": [], "supporting_offset": 0}],
|
||||
"action_items": [{"description": "...", "owner": null, "due_hint": null, "supporting_offset": 0}],
|
||||
"open_questions": [{"question": "...", "raised_by": null, "answered": false}],
|
||||
"key_quotes": [{"speaker": null, "offset": 0, "quote": "...", "why_notable": "..."}]
|
||||
\(shape.joined(separator: ",\n "))
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -288,35 +294,30 @@ final class RecapAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
static func parseExtras(_ content: String) -> MeetingExtras? {
|
||||
static func parseExtras(_ content: String, template: RecapTemplate) -> RecapExtras? {
|
||||
guard let o = jsonObject(content) else { return nil }
|
||||
let tldrObj = o["tldr"] as? [String: Any]
|
||||
let tldr = MeetingExtras.TLDR(
|
||||
summary: (tldrObj?["summary"] as? String) ?? "",
|
||||
primarySpeakers: stringArray(tldrObj?["primary_speakers"]))
|
||||
let decisions = (o["decisions"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.Decision? in
|
||||
guard let st = nonEmpty(d["statement"]) else { return nil }
|
||||
return .init(statement: st, agreedBy: stringArray(d["agreed_by"]), supportingOffset: intVal(d["supporting_offset"]))
|
||||
let tldr = template.includeTLDR ? ((o["tldr"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") : ""
|
||||
let primary = stringArray(o["primary_speakers"])
|
||||
|
||||
var rendered: [RenderedSection] = []
|
||||
for (i, sec) in template.sections.enumerated() {
|
||||
let v = o["sec_\(i)"]
|
||||
switch sec.kind {
|
||||
case .bullets:
|
||||
rendered.append(RenderedSection(title: sec.title, kind: .bullets, bullets: stringArray(v)))
|
||||
case .paragraph:
|
||||
let p = (v as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
rendered.append(RenderedSection(title: sec.title, kind: .paragraph, paragraph: p))
|
||||
case .items:
|
||||
let items = (v as? [[String: Any]] ?? []).compactMap { d -> RecapItem? in
|
||||
guard let t = nonEmpty(d["text"]) else { return nil }
|
||||
return RecapItem(text: t, who: nonEmpty(d["who"]), when: intVal(d["when"]), note: nonEmpty(d["note"]))
|
||||
}
|
||||
rendered.append(RenderedSection(title: sec.title, kind: .items, items: items))
|
||||
}
|
||||
}
|
||||
let actions = (o["action_items"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.ActionItem? in
|
||||
guard let desc = nonEmpty(d["description"]) else { return nil }
|
||||
return .init(description: desc, owner: nonEmpty(d["owner"]), dueHint: nonEmpty(d["due_hint"]),
|
||||
supportingOffset: intVal(d["supporting_offset"]))
|
||||
}
|
||||
let questions = (o["open_questions"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.OpenQuestion? in
|
||||
guard let q = nonEmpty(d["question"]) else { return nil }
|
||||
return .init(question: q, raisedBy: nonEmpty(d["raised_by"]))
|
||||
}
|
||||
let quotes = (o["key_quotes"] as? [[String: Any]] ?? []).compactMap { d -> MeetingExtras.KeyQuote? in
|
||||
guard let q = nonEmpty(d["quote"]) else { return nil }
|
||||
return .init(speaker: nonEmpty(d["speaker"]), offset: intVal(d["offset"]), quote: q,
|
||||
whyNotable: nonEmpty(d["why_notable"]) ?? "")
|
||||
}
|
||||
// Require at least a TLDR to consider extras present.
|
||||
guard !tldr.summary.isEmpty || !decisions.isEmpty || !actions.isEmpty || !questions.isEmpty || !quotes.isEmpty
|
||||
else { return nil }
|
||||
return MeetingExtras(tldr: tldr, decisions: decisions, actionItems: actions,
|
||||
openQuestions: questions, keyQuotes: quotes)
|
||||
guard !tldr.isEmpty || rendered.contains(where: { !$0.isEmpty }) else { return nil }
|
||||
return RecapExtras(tldr: tldr, primarySpeakers: primary, sections: rendered)
|
||||
}
|
||||
|
||||
private static func intVal(_ v: Any?) -> Int? {
|
||||
|
||||
@@ -18,6 +18,8 @@ final class RecapEditModel: ObservableObject {
|
||||
private var originalSpeakers: [String]
|
||||
private var renameOps: [(from: String, to: String)] = []
|
||||
|
||||
let templates: [RecapTemplate]
|
||||
@Published var selectedTemplateId: String
|
||||
@Published private(set) var segments: [SpeakersFile.Segment]
|
||||
@Published private(set) var speakers: [String]
|
||||
@Published private(set) var dirty = false
|
||||
@@ -25,7 +27,8 @@ final class RecapEditModel: ObservableObject {
|
||||
@Published private(set) var hasRecap: Bool
|
||||
@Published private(set) var status: String?
|
||||
|
||||
init?(folder: URL, voiceprints: VoiceprintStore, baseURL: String, skipTLS: Bool) {
|
||||
init?(folder: URL, voiceprints: VoiceprintStore, baseURL: String, skipTLS: Bool,
|
||||
templates: [RecapTemplate], defaultTemplateId: String) {
|
||||
let speakersURL = folder.appendingPathComponent("speakers.json")
|
||||
guard let data = try? Data(contentsOf: speakersURL),
|
||||
let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
|
||||
@@ -34,6 +37,9 @@ final class RecapEditModel: ObservableObject {
|
||||
self.voiceprints = voiceprints
|
||||
self.baseURL = baseURL
|
||||
self.skipTLS = skipTLS
|
||||
self.templates = templates.isEmpty ? RecapTemplate.builtIns : templates
|
||||
self.selectedTemplateId = (templates.contains { $0.id == defaultTemplateId } ? defaultTemplateId : templates.first?.id)
|
||||
?? RecapTemplate.builtIns.first!.id
|
||||
self.base = file
|
||||
self.segments = file.segments
|
||||
self.speakers = SpeakerEditing.orderedSpeakers(file.segments)
|
||||
@@ -102,13 +108,14 @@ final class RecapEditModel: ObservableObject {
|
||||
defer { regenerating = false }
|
||||
|
||||
let file = commitCorrections()
|
||||
let template = templates.first { $0.id == selectedTemplateId } ?? templates.first ?? .internalMeeting
|
||||
let llm = GatewayLLMClient(baseURL: baseURL, skipTLS: skipTLS)
|
||||
guard let model = await llm.chatModelId() else {
|
||||
status = "No language model on the gateway — saved corrections only."
|
||||
rebaseline(); return
|
||||
}
|
||||
let analyzer = RecapAnalyzer(llm: llm, model: model)
|
||||
guard let result = try? await analyzer.recap(file: file) else {
|
||||
guard let result = try? await analyzer.recap(file: file, template: template) else {
|
||||
status = "Recap regeneration failed — corrections were saved."
|
||||
rebaseline(); return
|
||||
}
|
||||
|
||||
@@ -9,32 +9,46 @@ struct TopicSection: Equatable, Codable {
|
||||
var endIndex: Int
|
||||
}
|
||||
|
||||
/// Structured "meeting extras" extracted from the named transcript. Mirrors
|
||||
/// recap-relay's schema; speakers are real names (we already have them from
|
||||
/// label-merge), not anonymous cluster ids.
|
||||
struct MeetingExtras: Equatable, Codable {
|
||||
struct TLDR: Equatable, Codable { var summary: String; var primarySpeakers: [String] }
|
||||
struct Decision: Equatable, Codable { var statement: String; var agreedBy: [String]; var supportingOffset: Int? }
|
||||
struct ActionItem: Equatable, Codable { var description: String; var owner: String?; var dueHint: String?; var supportingOffset: Int? }
|
||||
struct OpenQuestion: Equatable, Codable { var question: String; var raisedBy: String? }
|
||||
struct KeyQuote: Equatable, Codable { var speaker: String?; var offset: Int?; var quote: String; var whyNotable: String }
|
||||
/// Generic, template-driven takeaways extracted from the named transcript: a TLDR
|
||||
/// plus an ordered list of sections whose categories come from the active
|
||||
/// `RecapTemplate` (Settings), not from code. Speakers are real names (label-merge).
|
||||
struct RecapExtras: Equatable, Codable {
|
||||
var tldr: String
|
||||
var primarySpeakers: [String]
|
||||
var sections: [RenderedSection]
|
||||
|
||||
var tldr: TLDR
|
||||
var decisions: [Decision]
|
||||
var actionItems: [ActionItem]
|
||||
var openQuestions: [OpenQuestion]
|
||||
var keyQuotes: [KeyQuote]
|
||||
var isEmpty: Bool { tldr.isEmpty && sections.allSatisfy { $0.isEmpty } }
|
||||
}
|
||||
|
||||
var isEmptyBeyondTLDR: Bool {
|
||||
decisions.isEmpty && actionItems.isEmpty && openQuestions.isEmpty && keyQuotes.isEmpty
|
||||
/// One rendered takeaways section. Only the field matching `kind` is populated.
|
||||
struct RenderedSection: Equatable, Codable {
|
||||
var title: String
|
||||
var kind: SectionKind
|
||||
var bullets: [String]
|
||||
var items: [RecapItem]
|
||||
var paragraph: String
|
||||
|
||||
var isEmpty: Bool { bullets.isEmpty && items.isEmpty && paragraph.isEmpty }
|
||||
|
||||
init(title: String, kind: SectionKind, bullets: [String] = [], items: [RecapItem] = [], paragraph: String = "") {
|
||||
self.title = title; self.kind = kind; self.bullets = bullets; self.items = items; self.paragraph = paragraph
|
||||
}
|
||||
}
|
||||
|
||||
/// A single attributed point: the statement plus optional speaker / timestamp / note.
|
||||
/// Subsumes decisions, action items, questions, quotes, etc.
|
||||
struct RecapItem: Equatable, Codable {
|
||||
var text: String
|
||||
var who: String?
|
||||
var when: Int? // seconds
|
||||
var note: String?
|
||||
}
|
||||
|
||||
/// The assembled recap for one session: the topic sections + (optional) extras,
|
||||
/// over the session's transcript. Rendered to `transcript.md` / `recap.html`.
|
||||
struct RecapResult: Equatable, Codable {
|
||||
var sections: [TopicSection]
|
||||
var extras: MeetingExtras?
|
||||
var extras: RecapExtras?
|
||||
}
|
||||
|
||||
/// Persisted `recap.json` — the recap result plus its title, so the speaker editor
|
||||
|
||||
@@ -24,51 +24,27 @@ enum RecapRenderer {
|
||||
out += "*\n\n"
|
||||
|
||||
if let x = result.extras {
|
||||
if !x.tldr.summary.isEmpty {
|
||||
out += "## Summary\n\n\(x.tldr.summary)\n"
|
||||
if !x.tldr.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.tldr.primarySpeakers.joined(separator: ", "))*\n" }
|
||||
if !x.tldr.isEmpty {
|
||||
out += "## Summary\n\n\(x.tldr)\n"
|
||||
if !x.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.primarySpeakers.joined(separator: ", "))*\n" }
|
||||
out += "\n"
|
||||
}
|
||||
if !x.decisions.isEmpty {
|
||||
out += "## Decisions\n\n"
|
||||
for d in x.decisions {
|
||||
var line = "- \(d.statement)"
|
||||
if !d.agreedBy.isEmpty { line += " — agreed by \(d.agreedBy.joined(separator: ", "))" }
|
||||
if let o = d.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(o))))*" }
|
||||
out += line + "\n"
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
if !x.actionItems.isEmpty {
|
||||
out += "## Action Items\n\n"
|
||||
for a in x.actionItems {
|
||||
var line = "- [ ] \(a.description)"
|
||||
if let o = a.owner { line += " — **\(o)**" }
|
||||
if let due = a.dueHint { line += " (\(due))" }
|
||||
if let off = a.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(off))))*" }
|
||||
out += line + "\n"
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
if !x.openQuestions.isEmpty {
|
||||
out += "## Open Questions\n\n"
|
||||
for q in x.openQuestions {
|
||||
var line = "- \(q.question)"
|
||||
if let r = q.raisedBy { line += " — *\(r)*" }
|
||||
out += line + "\n"
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
if !x.keyQuotes.isEmpty {
|
||||
out += "## Key Quotes\n\n"
|
||||
for k in x.keyQuotes {
|
||||
out += "> \"\(k.quote)\""
|
||||
var attr: [String] = []
|
||||
if let s = k.speaker { attr.append(s) }
|
||||
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
|
||||
if !attr.isEmpty { out += " — \(attr.joined(separator: ", "))" }
|
||||
for section in x.sections where !section.isEmpty {
|
||||
out += "## \(section.title)\n\n"
|
||||
switch section.kind {
|
||||
case .paragraph:
|
||||
out += "\(section.paragraph)\n\n"
|
||||
case .bullets:
|
||||
for b in section.bullets { out += "- \(b)\n" }
|
||||
out += "\n"
|
||||
if !k.whyNotable.isEmpty { out += ">\n> \(k.whyNotable)\n" }
|
||||
case .items:
|
||||
for item in section.items {
|
||||
var line = "- \(item.text)"
|
||||
if let who = item.who { line += " — **\(who)**" }
|
||||
if let note = item.note { line += " (\(note))" }
|
||||
if let when = item.when { line += " *(\(RecapAnalyzer.mmss(Double(when))))*" }
|
||||
out += line + "\n"
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
}
|
||||
@@ -125,46 +101,26 @@ enum RecapRenderer {
|
||||
body += "</header>"
|
||||
|
||||
if let x = result.extras {
|
||||
if !x.tldr.summary.isEmpty {
|
||||
body += card("Summary", "<p>\(esc(x.tldr.summary))</p>"
|
||||
+ (x.tldr.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
|
||||
if !x.tldr.isEmpty {
|
||||
body += card("Summary", "<p>\(esc(x.tldr))</p>"
|
||||
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
|
||||
}
|
||||
if !x.decisions.isEmpty {
|
||||
let items = x.decisions.map { d -> String in
|
||||
var s = "<li>\(esc(d.statement))"
|
||||
if !d.agreedBy.isEmpty { s += " <span class=\"muted\">— agreed by \(d.agreedBy.map(esc).joined(separator: ", "))</span>" }
|
||||
if let o = d.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(o)))</span>" }
|
||||
return s + "</li>"
|
||||
}.joined()
|
||||
body += card("Decisions", "<ul>\(items)</ul>")
|
||||
}
|
||||
if !x.actionItems.isEmpty {
|
||||
let items = x.actionItems.map { a -> String in
|
||||
var s = "<li>☐ \(esc(a.description))"
|
||||
if let o = a.owner { s += " <strong>\(esc(o))</strong>" }
|
||||
if let due = a.dueHint { s += " <span class=\"muted\">(\(esc(due)))</span>" }
|
||||
if let off = a.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(off)))</span>" }
|
||||
return s + "</li>"
|
||||
}.joined()
|
||||
body += card("Action Items", "<ul class=\"actions\">\(items)</ul>")
|
||||
}
|
||||
if !x.openQuestions.isEmpty {
|
||||
let items = x.openQuestions.map { q -> String in
|
||||
"<li>\(esc(q.question))" + (q.raisedBy.map { " <span class=\"muted\">— \(esc($0))</span>" } ?? "") + "</li>"
|
||||
}.joined()
|
||||
body += card("Open Questions", "<ul>\(items)</ul>")
|
||||
}
|
||||
if !x.keyQuotes.isEmpty {
|
||||
let items = x.keyQuotes.map { k -> String in
|
||||
var attr: [String] = []
|
||||
if let s = k.speaker { attr.append(esc(s)) }
|
||||
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
|
||||
var s = "<blockquote>“\(esc(k.quote))”"
|
||||
if !attr.isEmpty { s += "<cite>— \(attr.joined(separator: ", "))</cite>" }
|
||||
if !k.whyNotable.isEmpty { s += "<div class=\"muted\">\(esc(k.whyNotable))</div>" }
|
||||
return s + "</blockquote>"
|
||||
}.joined()
|
||||
body += card("Key Quotes", items)
|
||||
for section in x.sections where !section.isEmpty {
|
||||
switch section.kind {
|
||||
case .paragraph:
|
||||
body += card(section.title, "<p>\(esc(section.paragraph))</p>")
|
||||
case .bullets:
|
||||
body += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
|
||||
case .items:
|
||||
let lis = section.items.map { item -> String in
|
||||
var s = "<li>\(esc(item.text))"
|
||||
if let who = item.who { s += " <strong>\(esc(who))</strong>" }
|
||||
if let note = item.note { s += " <span class=\"muted\">(\(esc(note)))</span>" }
|
||||
if let when = item.when { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
|
||||
return s + "</li>"
|
||||
}.joined()
|
||||
body += card(section.title, "<ul>\(lis)</ul>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
/// How a takeaways section is shaped (drives the LLM output shape + rendering).
|
||||
enum SectionKind: String, Codable, CaseIterable, Identifiable {
|
||||
case items // point + optional who / timestamp / note (decisions, actions, quotes, questions…)
|
||||
case bullets // plain short points
|
||||
case paragraph // a prose block
|
||||
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .items: return "Attributed items"
|
||||
case .bullets: return "Bulleted list"
|
||||
case .paragraph: return "Paragraph"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable takeaways category in a template.
|
||||
struct TemplateSection: Codable, Identifiable, Equatable {
|
||||
var id: String
|
||||
var title: String
|
||||
var kind: SectionKind
|
||||
var instruction: String // editable prompt text: what the LLM should extract
|
||||
|
||||
init(id: String = UUID().uuidString, title: String, kind: SectionKind, instruction: String) {
|
||||
self.id = id; self.title = title; self.kind = kind; self.instruction = instruction
|
||||
}
|
||||
}
|
||||
|
||||
/// How finely to break the transcript into topic sections.
|
||||
enum TopicGranularity: String, Codable, CaseIterable, Identifiable {
|
||||
case coarse, auto, fine
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self { case .coarse: return "Fewer, broader topics"; case .auto: return "Automatic"; case .fine: return "More, finer topics" }
|
||||
}
|
||||
/// Multiplier applied to the auto target-section count.
|
||||
var multiplier: Double {
|
||||
switch self { case .coarse: return 0.6; case .auto: return 1.0; case .fine: return 1.7 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A recap template: the always-on TLDR + an ordered list of takeaways sections.
|
||||
/// Fully user-editable in Settings (titles, types, instructions); the analyzer builds
|
||||
/// the LLM prompt from it and the renderer renders from it, so categories are data,
|
||||
/// not code.
|
||||
struct RecapTemplate: Codable, Identifiable, Equatable {
|
||||
var id: String
|
||||
var name: String
|
||||
var includeTLDR: Bool
|
||||
var topicGranularity: TopicGranularity
|
||||
var sections: [TemplateSection]
|
||||
|
||||
init(id: String = UUID().uuidString, name: String, includeTLDR: Bool = true,
|
||||
topicGranularity: TopicGranularity = .auto, sections: [TemplateSection]) {
|
||||
self.id = id; self.name = name; self.includeTLDR = includeTLDR
|
||||
self.topicGranularity = topicGranularity; self.sections = sections
|
||||
}
|
||||
|
||||
// MARK: - Built-in defaults (seeded once; all editable thereafter)
|
||||
|
||||
static var builtIns: [RecapTemplate] { [internalMeeting, oneOnOne, companyCall] }
|
||||
|
||||
static var internalMeeting: RecapTemplate {
|
||||
RecapTemplate(id: "builtin.internal", name: "Internal Meeting", sections: [
|
||||
.init(id: "internal.decisions", title: "Decisions", kind: .items,
|
||||
instruction: "Things explicitly decided or agreed. Only clear commitments (\"we'll do X\", \"let's go with Y\"), not casual mentions. text = the decision; who = who agreed; when = the timestamp."),
|
||||
.init(id: "internal.actions", title: "Action Items", kind: .items,
|
||||
instruction: "Explicit ownership (\"I'll send the doc\", \"Matt will follow up\"), not vague \"someone should\". text = the action in imperative form; who = the owner; note = a due date if mentioned; when = the timestamp."),
|
||||
.init(id: "internal.questions", title: "Open Questions", kind: .items,
|
||||
instruction: "Questions raised that were NOT clearly answered. Skip rhetorical or answered ones. text = the question, self-contained; who = who raised it."),
|
||||
.init(id: "internal.quotes", title: "Key Quotes", kind: .items,
|
||||
instruction: "3-6 pivotal or insightful statements worth surfacing verbatim. text = the quote (4-30 words); who = the speaker; when = the timestamp; note = why it's notable."),
|
||||
])
|
||||
}
|
||||
|
||||
static var oneOnOne: RecapTemplate {
|
||||
RecapTemplate(id: "builtin.oneonone", name: "1:1", sections: [
|
||||
.init(id: "1on1.takeaways", title: "Key Takeaways", kind: .bullets,
|
||||
instruction: "The main points and conclusions from the conversation, as concise bullets."),
|
||||
.init(id: "1on1.actions", title: "Action Items", kind: .items,
|
||||
instruction: "Anything either person committed to do. text = the action; who = the owner; note = a due date if mentioned."),
|
||||
.init(id: "1on1.followups", title: "Follow-ups", kind: .bullets,
|
||||
instruction: "Things to revisit or circle back on next time."),
|
||||
])
|
||||
}
|
||||
|
||||
static var companyCall: RecapTemplate {
|
||||
RecapTemplate(id: "builtin.company", name: "Company / Sales Call", sections: [
|
||||
.init(id: "company.takeaways", title: "Key Takeaways", kind: .bullets,
|
||||
instruction: "The most important points from the call, as concise bullets."),
|
||||
.init(id: "company.needs", title: "Their Asks & Needs", kind: .bullets,
|
||||
instruction: "What the other party wants, needs, or is trying to solve."),
|
||||
.init(id: "company.objections", title: "Objections & Concerns", kind: .items,
|
||||
instruction: "Concerns, hesitations, or objections raised. text = the concern; who = who raised it."),
|
||||
.init(id: "company.next", title: "Next Steps", kind: .items,
|
||||
instruction: "Agreed next steps. text = the step; who = the owner; note = timing if mentioned; when = the timestamp."),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -61,16 +61,15 @@ enum SpeakerEditing {
|
||||
}
|
||||
var extras = result.extras
|
||||
if let x = result.extras {
|
||||
extras = MeetingExtras(
|
||||
tldr: .init(summary: replaceWords(x.tldr.summary, map),
|
||||
primarySpeakers: exactList(x.tldr.primarySpeakers)),
|
||||
decisions: x.decisions.map { .init(statement: replaceWords($0.statement, map),
|
||||
agreedBy: exactList($0.agreedBy), supportingOffset: $0.supportingOffset) },
|
||||
actionItems: x.actionItems.map { .init(description: replaceWords($0.description, map),
|
||||
owner: exact($0.owner), dueHint: $0.dueHint, supportingOffset: $0.supportingOffset) },
|
||||
openQuestions: x.openQuestions.map { .init(question: replaceWords($0.question, map), raisedBy: exact($0.raisedBy)) },
|
||||
keyQuotes: x.keyQuotes.map { .init(speaker: exact($0.speaker), offset: $0.offset,
|
||||
quote: replaceWords($0.quote, map), whyNotable: replaceWords($0.whyNotable, map)) })
|
||||
let rendered = x.sections.map { sec in
|
||||
RenderedSection(title: sec.title, kind: sec.kind,
|
||||
bullets: sec.bullets.map { replaceWords($0, map) },
|
||||
items: sec.items.map { RecapItem(text: replaceWords($0.text, map), who: exact($0.who),
|
||||
when: $0.when, note: $0.note.map { replaceWords($0, map) }) },
|
||||
paragraph: replaceWords(sec.paragraph, map))
|
||||
}
|
||||
extras = RecapExtras(tldr: replaceWords(x.tldr, map),
|
||||
primarySpeakers: exactList(x.primarySpeakers), sections: rendered)
|
||||
}
|
||||
return RecapResult(sections: sections, extras: extras)
|
||||
}
|
||||
|
||||
@@ -400,10 +400,11 @@ final class SessionController: ObservableObject {
|
||||
/// the gateway LLM. Best-effort: a missing LLM or any failure leaves the
|
||||
/// transcript intact and just skips the recap.
|
||||
private func buildRecap(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
|
||||
let template = settings.defaultTemplate
|
||||
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
|
||||
guard let model = await llm.chatModelId() else { return } // no LLM on the gateway → skip
|
||||
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)
|
||||
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"))
|
||||
@@ -434,7 +435,8 @@ final class SessionController: ObservableObject {
|
||||
func editLastSession() {
|
||||
guard let folder = lastSession?.folder,
|
||||
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 }
|
||||
EditorWindow.shared.show(model: model)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,21 @@ final class AppSettings: ObservableObject {
|
||||
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 `~`).
|
||||
var outputFolderURL: URL {
|
||||
URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath,
|
||||
@@ -81,6 +96,16 @@ final class AppSettings: ObservableObject {
|
||||
self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me"
|
||||
self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false
|
||||
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 {
|
||||
@@ -92,5 +117,7 @@ final class AppSettings: ObservableObject {
|
||||
static let selfName = "selfName"
|
||||
static let autoSend = "autoSendOnStop"
|
||||
static let recapEnabled = "recapEnabled"
|
||||
static let recapTemplates = "recapTemplates"
|
||||
static let defaultTemplate = "defaultTemplateId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,29 @@ final class EditorWindow {
|
||||
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)
|
||||
Toggle("Auto-send recordings to backend", isOn: $settings.autoSendOnStop)
|
||||
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)
|
||||
.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() }
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.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() } }
|
||||
.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)
|
||||
if model.regenerating { ProgressView().controlSize(.small) }
|
||||
if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) }
|
||||
|
||||
Reference in New Issue
Block a user