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).
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
|
||||
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>
|
||||
"""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user