c539b78a58
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).
161 lines
6.8 KiB
Swift
161 lines
6.8 KiB
Swift
import SwiftUI
|
|
|
|
/// Speaker-correction editor: rename a speaker, merge two speakers, or reassign an
|
|
/// individual transcript line to a different speaker. Saving rewrites speakers.json,
|
|
/// re-renders the recap, and updates the voiceprint memory.
|
|
struct TranscriptEditorView: View {
|
|
@ObservedObject var model: RecapEditModel
|
|
|
|
@State private var renameTarget: String?
|
|
@State private var renameText = ""
|
|
@State private var reassignNewIndex: Int?
|
|
@State private var newSpeakerText = ""
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
Divider()
|
|
transcript
|
|
Divider()
|
|
footer
|
|
}
|
|
.frame(minWidth: 560, minHeight: 480)
|
|
.sheet(item: Binding(get: { renameTarget.map { IdentifiableString($0) } },
|
|
set: { renameTarget = $0?.value })) { item in
|
|
renameSheet(for: item.value)
|
|
}
|
|
.sheet(item: Binding(get: { reassignNewIndex.map { IdentifiableInt($0) } },
|
|
set: { reassignNewIndex = $0?.value })) { item in
|
|
newSpeakerSheet(for: item.value)
|
|
}
|
|
}
|
|
|
|
// MARK: - Header / legend
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(model.title).font(.headline)
|
|
Text("Fix speaker names, merge duplicates, or reassign a line. Saving updates the recap and remembers any names you set.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(model.speakers, id: \.self) { name in
|
|
Menu {
|
|
Button("Rename…") { renameText = name; renameTarget = name }
|
|
if model.speakers.count > 1 {
|
|
Menu("Merge into") {
|
|
ForEach(model.speakers.filter { $0 != name }, id: \.self) { other in
|
|
Button(other) { model.merge(name, into: other) }
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(name).font(.caption).padding(.horizontal, 8).padding(.vertical, 3)
|
|
.background(Color.accentColor.opacity(0.18)).clipShape(Capsule())
|
|
}
|
|
.menuStyle(.borderlessButton).fixedSize()
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
// MARK: - Transcript
|
|
|
|
private var transcript: some View {
|
|
List {
|
|
ForEach(Array(model.segments.enumerated()), id: \.offset) { idx, seg in
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Text(RecapAnalyzer.mmss(seg.start))
|
|
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
.frame(width: 52, alignment: .trailing)
|
|
Menu {
|
|
ForEach(model.speakers, id: \.self) { name in
|
|
Button(name) { model.reassign(idx, to: name) }
|
|
}
|
|
Divider()
|
|
Button("New name…") { newSpeakerText = ""; reassignNewIndex = idx }
|
|
} label: {
|
|
Text(seg.speaker).font(.caption.weight(.semibold))
|
|
.frame(width: 96, alignment: .leading)
|
|
}
|
|
.menuStyle(.borderlessButton).fixedSize()
|
|
Text(seg.text ?? "").font(.callout).textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
|
|
// MARK: - Footer
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: 10) {
|
|
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 with the chosen template.")
|
|
.disabled(model.regenerating)
|
|
if model.regenerating { ProgressView().controlSize(.small) }
|
|
if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) }
|
|
Spacer()
|
|
if let recap = recapURL {
|
|
Button("Open recap") { NSWorkspace.shared.open(recap) }
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
private var recapURL: URL? {
|
|
let u = model.folder.appendingPathComponent("recap.html")
|
|
return FileManager.default.fileExists(atPath: u.path) ? u : nil
|
|
}
|
|
|
|
// MARK: - Sheets
|
|
|
|
private func renameSheet(for name: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Rename “\(name)”").font(.headline)
|
|
TextField("New name", text: $renameText).textFieldStyle(.roundedBorder).frame(width: 260)
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel") { renameTarget = nil }
|
|
Button("Rename") {
|
|
model.rename(name, to: renameText)
|
|
renameTarget = nil
|
|
}.keyboardShortcut(.defaultAction).disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
|
|
private func newSpeakerSheet(for index: Int) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Assign this line to a new speaker").font(.headline)
|
|
TextField("Speaker name", text: $newSpeakerText).textFieldStyle(.roundedBorder).frame(width: 260)
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel") { reassignNewIndex = nil }
|
|
Button("Assign") {
|
|
let n = newSpeakerText.trimmingCharacters(in: .whitespaces)
|
|
if !n.isEmpty { model.reassign(index, to: n) }
|
|
reassignNewIndex = nil
|
|
}.keyboardShortcut(.defaultAction).disabled(newSpeakerText.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private struct IdentifiableString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } }
|
|
private struct IdentifiableInt: Identifiable { let value: Int; var id: Int { value }; init(_ v: Int) { value = v } }
|