Files
Grant Gilliam c539b78a58 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).
2026-06-06 19:26:03 -05:00

70 lines
2.6 KiB
Swift

import Foundation
/// One topic section: a contiguous run of transcript entries `[startIndex...endIndex]`
/// (inclusive, indices into the canonical entries array) with an LLM title + summary.
struct TopicSection: Equatable, Codable {
var title: String
var summary: String
var startIndex: Int
var endIndex: Int
}
/// 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 isEmpty: Bool { tldr.isEmpty && sections.allSatisfy { $0.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: RecapExtras?
}
/// Persisted `recap.json` the recap result plus its title, so the speaker editor
/// can re-render `recap.html` / `transcript.md` after corrections without re-calling
/// the LLM (a "Regenerate" action re-runs analysis when the user wants fresh summaries).
struct RecapFile: Equatable, Codable {
var title: String
var result: RecapResult
func write(to url: URL) throws {
let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .sortedKeys]
try enc.encode(self).write(to: url)
}
static func read(from url: URL) -> RecapFile? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(RecapFile.self, from: data)
}
}