Files
ten31-transcripts/Ten31Transcripts/Recap/RecapRenderer.swift
T
Grant Gilliam 85bfdf2b56 Recap: readable transcript + topic sections + meeting extras (gateway LLM)
New 'Recap' phase — turns speakers.json into a human-readable recap, leveraging
recap-relay's proven logic/prompts but calling the Spark gateway's OpenAI-compatible
/v1/chat/completions directly (same host/TLS as label-merge; Qwen3-35B). We start
from already-named speakers (label-merge), so recap-relay's speaker clustering +
name-inference are skipped entirely.

- GatewayLLMClient: /v1/chat/completions (JSON mode), model discovery via
  /api/endpoints, TLS-skip reuse, 503 retry, sequential.
- RecapAnalyzer: speakers.json → numbered [N] (MM:SS) Name: text transcript →
  time-windowed analyze (single window for short calls, 18min/2min overlap for long)
  → stitch/dedup topic sections → meeting extras (TLDR/decisions/action_items/
  open_questions/key_quotes). Defensive JSON parsing of LLM output.
- RecapRenderer: writes transcript.md + a self-contained dark-theme recap.html
  (topic sections w/ collapsible transcripts, extras panels, speaker color chips,
  full timestamped speaker-attributed transcript, print styles).
- SessionController.buildRecap: best-effort after speakers.json (gated by
  settings.recapEnabled); surfaces recapURL → menu 'Open recap'. Skips silently if
  the gateway has no LLM. Settings toggle added.

Validated END-TO-END on the real Meet session against the live gateway: dual-channel
transcription → 3 topic sections + accurate TLDR + key quotes; 'Go Bitcoin'
correctly attributed to the remote speaker. 46/46 XCTest (10 new).
2026-06-06 14:36:18 -05:00

254 lines
13 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.summary.isEmpty {
out += "## Summary\n\n\(x.tldr.summary)\n"
if !x.tldr.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.tldr.primarySpeakers.joined(separator: ", "))*\n" }
out += "\n"
}
if !x.decisions.isEmpty {
out += "## Decisions\n\n"
for d in x.decisions {
var line = "- \(d.statement)"
if !d.agreedBy.isEmpty { line += " — agreed by \(d.agreedBy.joined(separator: ", "))" }
if let o = d.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(o))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.actionItems.isEmpty {
out += "## Action Items\n\n"
for a in x.actionItems {
var line = "- [ ] \(a.description)"
if let o = a.owner { line += " — **\(o)**" }
if let due = a.dueHint { line += " (\(due))" }
if let off = a.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(off))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.openQuestions.isEmpty {
out += "## Open Questions\n\n"
for q in x.openQuestions {
var line = "- \(q.question)"
if let r = q.raisedBy { line += " — *\(r)*" }
out += line + "\n"
}
out += "\n"
}
if !x.keyQuotes.isEmpty {
out += "## Key Quotes\n\n"
for k in x.keyQuotes {
out += "> \"\(k.quote)\""
var attr: [String] = []
if let s = k.speaker { attr.append(s) }
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
if !attr.isEmpty { out += "\(attr.joined(separator: ", "))" }
out += "\n"
if !k.whyNotable.isEmpty { out += ">\n> \(k.whyNotable)\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.summary.isEmpty {
body += card("Summary", "<p>\(esc(x.tldr.summary))</p>"
+ (x.tldr.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
}
if !x.decisions.isEmpty {
let items = x.decisions.map { d -> String in
var s = "<li>\(esc(d.statement))"
if !d.agreedBy.isEmpty { s += " <span class=\"muted\">— agreed by \(d.agreedBy.map(esc).joined(separator: ", "))</span>" }
if let o = d.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(o)))</span>" }
return s + "</li>"
}.joined()
body += card("Decisions", "<ul>\(items)</ul>")
}
if !x.actionItems.isEmpty {
let items = x.actionItems.map { a -> String in
var s = "<li>☐ \(esc(a.description))"
if let o = a.owner { s += " <strong>\(esc(o))</strong>" }
if let due = a.dueHint { s += " <span class=\"muted\">(\(esc(due)))</span>" }
if let off = a.supportingOffset { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(off)))</span>" }
return s + "</li>"
}.joined()
body += card("Action Items", "<ul class=\"actions\">\(items)</ul>")
}
if !x.openQuestions.isEmpty {
let items = x.openQuestions.map { q -> String in
"<li>\(esc(q.question))" + (q.raisedBy.map { " <span class=\"muted\">— \(esc($0))</span>" } ?? "") + "</li>"
}.joined()
body += card("Open Questions", "<ul>\(items)</ul>")
}
if !x.keyQuotes.isEmpty {
let items = x.keyQuotes.map { k -> String in
var attr: [String] = []
if let s = k.speaker { attr.append(esc(s)) }
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
var s = "<blockquote>“\(esc(k.quote))"
if !attr.isEmpty { s += "<cite>— \(attr.joined(separator: ", "))</cite>" }
if !k.whyNotable.isEmpty { s += "<div class=\"muted\">\(esc(k.whyNotable))</div>" }
return s + "</blockquote>"
}.joined()
body += card("Key Quotes", items)
}
}
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>
"""
}
}