diff --git a/Ten31Transcripts/Recap/RecapAnalyzer.swift b/Ten31Transcripts/Recap/RecapAnalyzer.swift index 3bbe426..ef0c142 100644 --- a/Ten31Transcripts/Recap/RecapAnalyzer.swift +++ b/Ten31Transcripts/Recap/RecapAnalyzer.swift @@ -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_` 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 [ ] 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 [ ] 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? { diff --git a/Ten31Transcripts/Recap/RecapEditModel.swift b/Ten31Transcripts/Recap/RecapEditModel.swift index ebb33ad..208fda3 100644 --- a/Ten31Transcripts/Recap/RecapEditModel.swift +++ b/Ten31Transcripts/Recap/RecapEditModel.swift @@ -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 } diff --git a/Ten31Transcripts/Recap/RecapModels.swift b/Ten31Transcripts/Recap/RecapModels.swift index f990963..2ba4bf2 100644 --- a/Ten31Transcripts/Recap/RecapModels.swift +++ b/Ten31Transcripts/Recap/RecapModels.swift @@ -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 diff --git a/Ten31Transcripts/Recap/RecapRenderer.swift b/Ten31Transcripts/Recap/RecapRenderer.swift index 8a3337d..855f68a 100644 --- a/Ten31Transcripts/Recap/RecapRenderer.swift +++ b/Ten31Transcripts/Recap/RecapRenderer.swift @@ -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 += "" if let x = result.extras { - if !x.tldr.summary.isEmpty { - body += card("Summary", "

\(esc(x.tldr.summary))

" - + (x.tldr.primarySpeakers.isEmpty ? "" : "

Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))

")) + if !x.tldr.isEmpty { + body += card("Summary", "

\(esc(x.tldr))

" + + (x.primarySpeakers.isEmpty ? "" : "

Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))

")) } - if !x.decisions.isEmpty { - let items = x.decisions.map { d -> String in - var s = "
  • \(esc(d.statement))" - if !d.agreedBy.isEmpty { s += " — agreed by \(d.agreedBy.map(esc).joined(separator: ", "))" } - if let o = d.supportingOffset { s += " \(RecapAnalyzer.mmss(Double(o)))" } - return s + "
  • " - }.joined() - body += card("Decisions", "
      \(items)
    ") - } - if !x.actionItems.isEmpty { - let items = x.actionItems.map { a -> String in - var s = "
  • ☐ \(esc(a.description))" - if let o = a.owner { s += " \(esc(o))" } - if let due = a.dueHint { s += " (\(esc(due)))" } - if let off = a.supportingOffset { s += " \(RecapAnalyzer.mmss(Double(off)))" } - return s + "
  • " - }.joined() - body += card("Action Items", "
      \(items)
    ") - } - if !x.openQuestions.isEmpty { - let items = x.openQuestions.map { q -> String in - "
  • \(esc(q.question))" + (q.raisedBy.map { " — \(esc($0))" } ?? "") + "
  • " - }.joined() - body += card("Open Questions", "
      \(items)
    ") - } - 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 = "
    “\(esc(k.quote))”" - if !attr.isEmpty { s += "— \(attr.joined(separator: ", "))" } - if !k.whyNotable.isEmpty { s += "
    \(esc(k.whyNotable))
    " } - return s + "
    " - }.joined() - body += card("Key Quotes", items) + for section in x.sections where !section.isEmpty { + switch section.kind { + case .paragraph: + body += card(section.title, "

    \(esc(section.paragraph))

    ") + case .bullets: + body += card(section.title, "
      " + section.bullets.map { "
    • \(esc($0))
    • " }.joined() + "
    ") + case .items: + let lis = section.items.map { item -> String in + var s = "
  • \(esc(item.text))" + if let who = item.who { s += " \(esc(who))" } + if let note = item.note { s += " (\(esc(note)))" } + if let when = item.when { s += " \(RecapAnalyzer.mmss(Double(when)))" } + return s + "
  • " + }.joined() + body += card(section.title, "
      \(lis)
    ") + } } } diff --git a/Ten31Transcripts/Recap/RecapTemplate.swift b/Ten31Transcripts/Recap/RecapTemplate.swift new file mode 100644 index 0000000..0a33f21 --- /dev/null +++ b/Ten31Transcripts/Recap/RecapTemplate.swift @@ -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."), + ]) + } +} diff --git a/Ten31Transcripts/Recap/SpeakerEditing.swift b/Ten31Transcripts/Recap/SpeakerEditing.swift index 1ad40da..c0b5a17 100644 --- a/Ten31Transcripts/Recap/SpeakerEditing.swift +++ b/Ten31Transcripts/Recap/SpeakerEditing.swift @@ -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) } diff --git a/Ten31Transcripts/Session/SessionController.swift b/Ten31Transcripts/Session/SessionController.swift index c2a3acd..0823158 100644 --- a/Ten31Transcripts/Session/SessionController.swift +++ b/Ten31Transcripts/Session/SessionController.swift @@ -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) } diff --git a/Ten31Transcripts/Settings/AppSettings.swift b/Ten31Transcripts/Settings/AppSettings.swift index 74376f9..8ab504e 100644 --- a/Ten31Transcripts/Settings/AppSettings.swift +++ b/Ten31Transcripts/Settings/AppSettings.swift @@ -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(_ 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" } } diff --git a/Ten31Transcripts/UI/EditorWindow.swift b/Ten31Transcripts/UI/EditorWindow.swift index 9d35111..922c7d9 100644 --- a/Ten31Transcripts/UI/EditorWindow.swift +++ b/Ten31Transcripts/UI/EditorWindow.swift @@ -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) + } +} diff --git a/Ten31Transcripts/UI/SettingsView.swift b/Ten31Transcripts/UI/SettingsView.swift index 2396ded..9ae4714 100644 --- a/Ten31Transcripts/UI/SettingsView.swift +++ b/Ten31Transcripts/UI/SettingsView.swift @@ -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) } diff --git a/Ten31Transcripts/UI/TemplatesView.swift b/Ten31Transcripts/UI/TemplatesView.swift new file mode 100644 index 0000000..8e70afd --- /dev/null +++ b/Ten31Transcripts/UI/TemplatesView.swift @@ -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) -> 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? { + 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) { + t.wrappedValue.sections.append(.init(title: "New Section", kind: .bullets, instruction: "")) + } + private func removeSection(_ t: Binding, _ idx: Int) { + guard t.wrappedValue.sections.indices.contains(idx) else { return } + t.wrappedValue.sections.remove(at: idx) + } + private func move(_ t: Binding, _ 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 } } diff --git a/Ten31Transcripts/UI/TranscriptEditorView.swift b/Ten31Transcripts/UI/TranscriptEditorView.swift index d908d7e..3e430f7 100644 --- a/Ten31Transcripts/UI/TranscriptEditorView.swift +++ b/Ten31Transcripts/UI/TranscriptEditorView.swift @@ -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) } diff --git a/Ten31TranscriptsTests/RecapTests.swift b/Ten31TranscriptsTests/RecapTests.swift index e90fe9d..86feac6 100644 --- a/Ten31TranscriptsTests/RecapTests.swift +++ b/Ten31TranscriptsTests/RecapTests.swift @@ -23,30 +23,61 @@ final class RecapTests: XCTestCase { XCTAssertEqual(RecapAnalyzer.parseSections(json).count, 1) } - func testParseExtras() { - let json = #""" - {"tldr":{"summary":"They discussed the roadmap.","primary_speakers":["Grant","Caitlyn"]}, - "decisions":[{"statement":"Ship dual-channel","agreed_by":["Grant"],"supporting_offset":72}], - "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"}]} - """# - let x = RecapAnalyzer.parseExtras(json) + private var sampleTemplate: RecapTemplate { + RecapTemplate(id: "t", name: "T", includeTLDR: true, sections: [ + .init(id: "a", title: "Decisions", kind: .items, instruction: ""), + .init(id: "b", title: "Takeaways", kind: .bullets, instruction: ""), + ]) + } + + func testParseExtrasGeneric() { + 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) - XCTAssertEqual(x?.tldr.primarySpeakers, ["Grant", "Caitlyn"]) - XCTAssertEqual(x?.decisions.first?.supportingOffset, 72) - XCTAssertEqual(x?.actionItems.first?.owner, "Caitlyn") - XCTAssertEqual(x?.actionItems.first?.dueHint, "by Friday") - XCTAssertEqual(x?.openQuestions.first?.question, "What about Teams?") - XCTAssertEqual(x?.keyQuotes.first?.quote, "Go Bitcoin") + XCTAssertEqual(x?.tldr, "They discussed the roadmap.") + XCTAssertEqual(x?.primarySpeakers, ["Grant", "Caitlyn"]) + XCTAssertEqual(x?.sections.count, 2) + XCTAssertEqual(x?.sections[0].kind, .items) + XCTAssertEqual(x?.sections[0].items.first?.text, "Ship dual-channel") + 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() { - // owner/raised_by "null" or empty must become nil, not a literal "null". - let json = #"{"tldr":{"summary":"s","primary_speakers":[]},"action_items":[{"description":"do it","owner":"null","due_hint":""}],"decisions":[],"open_questions":[],"key_quotes":[]}"# - let x = RecapAnalyzer.parseExtras(json) - XCTAssertNil(x?.actionItems.first?.owner) - XCTAssertNil(x?.actionItems.first?.dueHint) + let template = RecapTemplate(id: "t", name: "T", sections: [.init(id: "a", title: "Actions", kind: .items, instruction: "")]) + let json = #"{"tldr":"s","primary_speakers":[],"sec_0":[{"text":"do it","who":"null","note":""}]}"# + let x = RecapAnalyzer.parseExtras(json, template: template) + XCTAssertNil(x?.sections.first?.items.first?.who) + 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 @@ -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: 72, end: 74, speaker: "Caitlyn", text: "Go Bitcoin."), ], models: [:]) - let extras = MeetingExtras( - tldr: .init(summary: "A quick test call.", primarySpeakers: ["Grant"]), - decisions: [], actionItems: [.init(description: "Ship it", owner: "Grant", dueHint: nil, supportingOffset: 3)], - openQuestions: [], keyQuotes: [.init(speaker: "Caitlyn", offset: 72, quote: "Go Bitcoin", whyNotable: "tone")]) + let extras = RecapExtras(tldr: "A quick test call.", primarySpeakers: ["Grant"], sections: [ + RenderedSection(title: "Action Items", kind: .items, items: [RecapItem(text: "Ship it", who: "Grant", when: 3, note: nil)]), + RenderedSection(title: "Key Quotes", kind: .items, items: [RecapItem(text: "Go Bitcoin", who: "Caitlyn", when: 72, note: "tone")]), + ]) let result = RecapResult(sections: [TopicSection(title: "Call start", summary: "Grant opens.", startIndex: 0, endIndex: 1)], extras: extras) let entries = RecapAnalyzer.entries(from: file) let md = RecapRenderer.markdown(file: file, result: result, title: "Meet call", entries: entries) diff --git a/Ten31TranscriptsTests/SpeakerEditingTests.swift b/Ten31TranscriptsTests/SpeakerEditingTests.swift index 527bd8f..98b5e79 100644 --- a/Ten31TranscriptsTests/SpeakerEditingTests.swift +++ b/Ten31TranscriptsTests/SpeakerEditingTests.swift @@ -34,20 +34,20 @@ final class SpeakerEditingTests: XCTestCase { func testRemapStructuredAndWordBoundaryText() { let result = RecapResult( sections: [TopicSection(title: "Grant intro", summary: "Grant and Unknown_0 talk; Grantham stays.", startIndex: 0, endIndex: 1)], - extras: MeetingExtras( - tldr: .init(summary: "Grant led.", primarySpeakers: ["Grant"]), - decisions: [.init(statement: "ship", agreedBy: ["Grant", "Unknown_0"], supportingOffset: 1)], - actionItems: [.init(description: "Unknown_0 sends doc", owner: "Unknown_0", dueHint: nil, supportingOffset: nil)], - openQuestions: [], - keyQuotes: [.init(speaker: "Unknown_0", offset: 2, quote: "go", whyNotable: "")])) + extras: RecapExtras(tldr: "Grant led.", primarySpeakers: ["Grant"], sections: [ + RenderedSection(title: "Decisions", kind: .items, + items: [RecapItem(text: "Unknown_0 sends doc", who: "Unknown_0", when: 1, note: nil)]), + RenderedSection(title: "Takeaways", kind: .bullets, bullets: ["Unknown_0 and Grant agree"]), + ])) let map = ["Unknown_0": "Caitlyn", "Grant": "Grant Gilliam"] let out = SpeakerEditing.remap(result, names: map) 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.extras?.tldr.primarySpeakers, ["Grant Gilliam"]) - XCTAssertEqual(out.extras?.decisions.first?.agreedBy, ["Grant Gilliam", "Caitlyn"]) - XCTAssertEqual(out.extras?.actionItems.first?.owner, "Caitlyn") - XCTAssertEqual(out.extras?.keyQuotes.first?.speaker, "Caitlyn") + XCTAssertEqual(out.extras?.tldr, "Grant Gilliam led.") + XCTAssertEqual(out.extras?.primarySpeakers, ["Grant Gilliam"]) + XCTAssertEqual(out.extras?.sections[0].items.first?.who, "Caitlyn") + XCTAssertEqual(out.extras?.sections[0].items.first?.text, "Caitlyn sends doc") + XCTAssertEqual(out.extras?.sections[1].bullets, ["Caitlyn and Grant Gilliam agree"]) } @MainActor @@ -69,7 +69,9 @@ final class SpeakerEditingTests: XCTestCase { let store = VoiceprintStore(fileURL: dir.appendingPathComponent("voiceprints.json")) 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") XCTAssertTrue(model.speakers.contains("Caitlyn")) XCTAssertFalse(model.speakers.contains("Unknown_0"))