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

91 lines
4.4 KiB
Swift

import Foundation
/// Pure transforms for speaker corrections: rename, merge (rename onto an existing
/// name), and per-segment reassignment, plus remapping speaker names through a
/// recap's text/structured fields. No UI/IO fully unit-testable.
enum SpeakerEditing {
typealias Segment = SpeakersFile.Segment
/// Distinct speakers in first-appearance order (the legend).
static func orderedSpeakers(_ segments: [Segment]) -> [String] {
var seen = Set<String>(), order: [String] = []
for s in segments where !s.speaker.isEmpty && !seen.contains(s.speaker) {
seen.insert(s.speaker); order.append(s.speaker)
}
return order
}
/// Replace every `from` with `to` across segments. Rename when `to` is new; a
/// merge when `to` already exists same primitive either way.
static func replaceSpeaker(_ from: String, with to: String, in segments: [Segment]) -> [Segment] {
guard from != to, !to.isEmpty else { return segments }
return segments.map {
$0.speaker == from ? Segment(start: $0.start, end: $0.end, speaker: to, text: $0.text) : $0
}
}
/// Reassign a single segment to another speaker.
static func reassign(_ index: Int, to speaker: String, in segments: [Segment]) -> [Segment] {
guard segments.indices.contains(index), !speaker.isEmpty else { return segments }
var out = segments
let s = out[index]
out[index] = Segment(start: s.start, end: s.end, speaker: speaker, text: s.text)
return out
}
/// Compose an ordered list of (from to) rename/merge ops into the net
/// originalfinal map (per-segment reassignments are NOT renames, so they don't
/// appear here). Only entries that actually changed are returned.
static func netNameMap(originals: [String], ops: [(from: String, to: String)]) -> [String: String] {
var cur = Dictionary(uniqueKeysWithValues: originals.map { ($0, $0) })
for op in ops {
for (k, v) in cur where v == op.from { cur[k] = op.to }
}
return cur.filter { $0.key != $0.value }
}
// MARK: - Recap remapping
/// Apply a name map to a recap's structured fields (exact) and free text
/// (whole-word), so a rename/merge is reflected in summaries, the TLDR, and the
/// extras attributions without re-running the LLM.
static func remap(_ result: RecapResult, names map: [String: String]) -> RecapResult {
guard !map.isEmpty else { return result }
func exact(_ s: String?) -> String? { s.flatMap { map[$0] ?? $0 } }
func exactList(_ a: [String]) -> [String] { a.map { map[$0] ?? $0 } }
let sections = result.sections.map {
TopicSection(title: replaceWords($0.title, map),
summary: replaceWords($0.summary, map),
startIndex: $0.startIndex, endIndex: $0.endIndex)
}
var extras = result.extras
if let x = result.extras {
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)
}
/// Whole-word replace each `from``to` in free text (case-sensitive). Used so a
/// renamed speaker's name updates inside summaries without clobbering substrings.
static func replaceWords(_ text: String, _ map: [String: String]) -> String {
var out = text
for (from, to) in map where from != to && !from.isEmpty {
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: from) + "\\b"
guard let re = try? NSRegularExpression(pattern: pattern) else { continue }
let range = NSRange(out.startIndex..., in: out)
out = re.stringByReplacingMatches(in: out, range: range,
withTemplate: NSRegularExpression.escapedTemplate(for: to))
}
return out
}
}