Files
ten31-transcripts/Ten31Transcripts/Recap/RecapRenderer.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

210 lines
11 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
/// Renders a session (`speakers.json` + recap) into human-readable artifacts:
/// `transcript.md` (portable/editable) and `recap.html` (self-contained, dark
/// theme, printable, shareable). Port of recap-relay's meetingToMarkdown/Html,
/// driven by our already-named transcript.
enum RecapRenderer {
static func write(file: SpeakersFile, result: RecapResult, title: String, to folder: URL) throws {
let entries = RecapAnalyzer.entries(from: file)
try markdown(file: file, result: result, title: title, entries: entries)
.data(using: .utf8)?.write(to: folder.appendingPathComponent("transcript.md"))
try html(file: file, result: result, title: title, entries: entries)
.data(using: .utf8)?.write(to: folder.appendingPathComponent("recap.html"))
}
// MARK: - Markdown
static func markdown(file: SpeakersFile, result: RecapResult, title: String,
entries: [RecapAnalyzer.Entry]) -> String {
var out = "# \(title)\n\n"
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
out += "*\(file.app) · \(RecapAnalyzer.mmss(file.durationSec))"
if !speakers.isEmpty { out += " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s"): \(speakers.joined(separator: ", "))" }
out += "*\n\n"
if let x = result.extras {
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"
}
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"
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"
}
}
}
if !result.sections.isEmpty {
out += "## Topics\n\n"
for (i, sec) in result.sections.enumerated() {
let range = timeRange(sec, entries: entries)
out += "### \(i + 1). \(sec.title)\(range)\n\n"
if !sec.summary.isEmpty { out += "\(sec.summary)\n\n" }
out += "<details>\n<summary>Transcript</summary>\n\n"
out += transcriptLines(sec, entries: entries)
out += "\n</details>\n\n"
}
}
out += "## Full Transcript\n\n"
for e in entries { out += "**[\(RecapAnalyzer.mmss(e.offset))] \(e.speaker):** \(e.text)\n\n" }
return out
}
private static func timeRange(_ sec: TopicSection, entries: [RecapAnalyzer.Entry]) -> String {
guard entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex) else { return "" }
return " *(\(RecapAnalyzer.mmss(entries[sec.startIndex].offset))\(RecapAnalyzer.mmss(entries[sec.endIndex].end)))*"
}
private static func transcriptLines(_ sec: TopicSection, entries: [RecapAnalyzer.Entry]) -> String {
guard sec.startIndex <= sec.endIndex, entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex)
else { return "" }
return entries[sec.startIndex...sec.endIndex]
.map { "**[\(RecapAnalyzer.mmss($0.offset))] \($0.speaker):** \($0.text)" }
.joined(separator: "\n\n")
}
// MARK: - HTML
static func html(file: SpeakersFile, result: RecapResult, title: String,
entries: [RecapAnalyzer.Entry]) -> String {
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
let colorFor = speakerColors(speakers)
func chip(_ name: String) -> String {
let c = colorFor[name] ?? "#8a8f98"
return "<span class=\"chip\" style=\"background:\(c)\">\(esc(name))</span>"
}
var body = ""
let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))"
+ (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")")
body += "<header><h1>\(esc(title))</h1><div class=\"sub\">\(sub)</div>"
if !speakers.isEmpty {
body += "<div class=\"legend\">" + speakers.map { chip($0) }.joined() + "</div>"
}
body += "</header>"
if let x = result.extras {
if !x.tldr.isEmpty {
body += card("Summary", "<p>\(esc(x.tldr))</p>"
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
}
for section in x.sections where !section.isEmpty {
switch section.kind {
case .paragraph:
body += card(section.title, "<p>\(esc(section.paragraph))</p>")
case .bullets:
body += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
case .items:
let lis = section.items.map { item -> String in
var s = "<li>\(esc(item.text))"
if let who = item.who { s += " <strong>\(esc(who))</strong>" }
if let note = item.note { s += " <span class=\"muted\">(\(esc(note)))</span>" }
if let when = item.when { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
return s + "</li>"
}.joined()
body += card(section.title, "<ul>\(lis)</ul>")
}
}
}
if !result.sections.isEmpty {
var topics = ""
for (i, sec) in result.sections.enumerated() {
let range = entries.indices.contains(sec.startIndex) && entries.indices.contains(sec.endIndex)
? "<span class=\"ts\">\(RecapAnalyzer.mmss(entries[sec.startIndex].offset))\(RecapAnalyzer.mmss(entries[sec.endIndex].end))</span>" : ""
topics += "<details class=\"topic\"><summary><span class=\"tnum\">\(i + 1)</span> \(esc(sec.title)) \(range)</summary>"
if !sec.summary.isEmpty { topics += "<p>\(esc(sec.summary))</p>" }
topics += "<div class=\"turns\">" + turnsHtml(sec, entries: entries, chip: chip) + "</div></details>"
}
body += card("Topics", topics)
}
let full = entries.map { "<div class=\"turn\"><span class=\"ts\">\(RecapAnalyzer.mmss($0.offset))</span> \(chip($0.speaker)) <span class=\"txt\">\(esc($0.text))</span></div>" }.joined()
body += "<details class=\"topic\" open><summary>Full Transcript</summary><div class=\"turns\">\(full)</div></details>"
return htmlShell(title: esc(title), body: body)
}
private static func turnsHtml(_ sec: TopicSection, entries: [RecapAnalyzer.Entry],
chip: (String) -> String) -> String {
guard sec.startIndex <= sec.endIndex, entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex)
else { return "" }
return entries[sec.startIndex...sec.endIndex].map {
"<div class=\"turn\"><span class=\"ts\">\(RecapAnalyzer.mmss($0.offset))</span> \(chip($0.speaker)) <span class=\"txt\">\(esc($0.text))</span></div>"
}.joined()
}
private static func card(_ title: String, _ inner: String) -> String {
"<section class=\"card\"><h2>\(esc(title))</h2>\(inner)</section>"
}
private static let palette = ["#5b8def", "#e0719c", "#43b581", "#e8a33d", "#9b6dde",
"#3fb6c9", "#d96f6f", "#7aa55c"]
private static func speakerColors(_ names: [String]) -> [String: String] {
var map: [String: String] = [:]
for (i, n) in names.enumerated() { map[n] = palette[i % palette.count] }
return map
}
private static func esc(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
private static func htmlShell(title: String, body: String) -> String {
"""
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>\(title)</title>
<style>
:root{--bg:#15171c;--card:#1d2026;--fg:#e6e8ec;--muted:#9aa0aa;--line:#2a2e36;--accent:#5b8def;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
main{max-width:820px;margin:0 auto;padding:32px 20px 80px;}
header h1{margin:0 0 4px;font-size:24px}
.sub{color:var(--muted);font-size:13px}
.legend{margin-top:12px;display:flex;flex-wrap:wrap;gap:6px}
.chip{display:inline-block;padding:1px 8px;border-radius:10px;color:#fff;font-size:12px;font-weight:600}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin-top:18px}
.card h2{margin:0 0 10px;font-size:16px;color:var(--accent)}
.muted{color:var(--muted)}
ul{margin:0;padding-left:18px} li{margin:4px 0}
ul.actions{list-style:none;padding-left:0}
.ts{color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px;margin-right:4px}
blockquote{margin:0 0 12px;padding:8px 12px;border-left:3px solid var(--accent);background:#0e0f13;border-radius:0 8px 8px 0}
blockquote cite{display:block;color:var(--muted);font-size:12px;margin-top:4px;font-style:normal}
details.topic{border-top:1px solid var(--line);padding:10px 0}
details.topic > summary{cursor:pointer;font-weight:600;list-style:none}
details.topic > summary::-webkit-details-marker{display:none}
.tnum{display:inline-block;min-width:20px;color:var(--accent);font-weight:700}
.turns{margin-top:10px}
.turn{margin:6px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
.turn .txt{flex:1;min-width:60%}
@media print{body{background:#fff;color:#000}.card,blockquote{background:#fff;border-color:#ccc}details.topic{}.chip{border:1px solid #999}}
</style></head>
<body><main>\(body)
<footer class="sub" style="margin-top:40px">Ten31 Transcripts · generated on-device</footer>
</main></body></html>
"""
}
}