Files
ten31-transcripts/Ten31Transcripts/Settings/AppSettings.swift
T
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

124 lines
4.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Combine
/// User-facing settings, persisted to `UserDefaults`.
///
/// Phase 0 scope: backend host + TLS-skip, output folder, and adapter toggles.
/// The adapter toggles persist but do nothing yet (adapters arrive in Phase 34).
@MainActor
final class AppSettings: ObservableObject {
/// Adapters the app will eventually run, in display order.
static let adapterKeys: [(key: String, label: String)] = [
("zoom", "Zoom"),
("teams", "Microsoft Teams"),
("signal", "Signal"),
("meet", "Google Meet"),
]
@Published var backendBaseURL: String {
didSet { defaults.set(backendBaseURL, forKey: Keys.backendBaseURL) }
}
@Published var skipTLSVerification: Bool {
didSet { defaults.set(skipTLSVerification, forKey: Keys.skipTLS) }
}
@Published var outputFolderPath: String {
didSet { defaults.set(outputFolderPath, forKey: Keys.outputFolder) }
}
@Published var adapterEnabled: [String: Bool] {
didSet { defaults.set(adapterEnabled, forKey: Keys.adapterEnabled) }
}
@Published var autoRecordOnDetection: Bool {
didSet { defaults.set(autoRecordOnDetection, forKey: Keys.autoRecord) }
}
/// The user's name, pre-seeded into the timeline for mic-VAD "self" spans.
@Published var selfName: String {
didSet { defaults.set(selfName, forKey: Keys.selfName) }
}
/// Auto-send a finished recording to the backend for transcription. Default
/// off while developing; flip on for hands-free transcripts.
@Published var autoSendOnStop: Bool {
didSet { defaults.set(autoSendOnStop, forKey: Keys.autoSend) }
}
/// After transcription, build the readable recap (topic sections + meeting
/// extras) via the gateway LLM and write transcript.md / recap.html. Best-effort.
@Published var recapEnabled: Bool {
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,
isDirectory: true)
}
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.backendBaseURL = defaults.string(forKey: Keys.backendBaseURL)
?? "https://your-spark-backend.local:62419"
self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? true
self.outputFolderPath = defaults.string(forKey: Keys.outputFolder)
?? "~/Ten31Transcripts"
let stored = defaults.dictionary(forKey: Keys.adapterEnabled) as? [String: Bool]
self.adapterEnabled = stored ?? Dictionary(
uniqueKeysWithValues: Self.adapterKeys.map { ($0.key, true) }
)
self.autoRecordOnDetection = defaults.object(forKey: Keys.autoRecord) as? Bool ?? true
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 {
static let backendBaseURL = "backendBaseURL"
static let skipTLS = "skipTLSVerification"
static let outputFolder = "outputFolderPath"
static let adapterEnabled = "adapterEnabled"
static let autoRecord = "autoRecordOnDetection"
static let selfName = "selfName"
static let autoSend = "autoSendOnStop"
static let recapEnabled = "recapEnabled"
static let recapTemplates = "recapTemplates"
static let defaultTemplate = "defaultTemplateId"
}
}