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:
Grant Gilliam
2026-06-06 14:36:18 -05:00
parent 53d7fcdac0
commit 85bfdf2b56
9 changed files with 941 additions and 1 deletions
+129
View File
@@ -0,0 +1,129 @@
import XCTest
@testable import Ten31Transcripts
final class RecapTests: XCTestCase {
private func entry(_ off: Double, _ end: Double, _ who: String, _ text: String) -> RecapAnalyzer.Entry {
.init(offset: off, end: end, speaker: who, text: text)
}
// MARK: - Parsing
func testParseSectionsHandlesStringIndices() {
let json = #"{"sections":[{"title":"Intro","summary":"hi","startIndex":"0","endIndex":3},{"title":"Topic","summary":"x","startIndex":4,"endIndex":9}]}"#
let secs = RecapAnalyzer.parseSections(json)
XCTAssertEqual(secs.count, 2)
XCTAssertEqual(secs[0].title, "Intro")
XCTAssertEqual(secs[0].startIndex, 0)
XCTAssertEqual(secs[1].endIndex, 9)
}
func testParseSectionsStripsCodeFence() {
let json = "```json\n{\"sections\":[{\"title\":\"A\",\"summary\":\"\",\"startIndex\":0,\"endIndex\":1}]}\n```"
XCTAssertEqual(RecapAnalyzer.parseSections(json).count, 1)
}
func testParseExtras() {
let json = #"""
{"tldr":{"summary":"They discussed the roadmap.","primary_speakers":["Grant","Caitlyn"]},
"decisions":[{"statement":"Ship dual-channel","agreed_by":["Grant"],"supporting_offset":72}],
"action_items":[{"description":"Send the doc","owner":"Caitlyn","due_hint":"by Friday","supporting_offset":120}],
"open_questions":[{"question":"What about Teams?","raised_by":"Grant","answered":false}],
"key_quotes":[{"speaker":"Caitlyn","offset":73,"quote":"Go Bitcoin","why_notable":"sets the tone"}]}
"""#
let x = RecapAnalyzer.parseExtras(json)
XCTAssertNotNil(x)
XCTAssertEqual(x?.tldr.primarySpeakers, ["Grant", "Caitlyn"])
XCTAssertEqual(x?.decisions.first?.supportingOffset, 72)
XCTAssertEqual(x?.actionItems.first?.owner, "Caitlyn")
XCTAssertEqual(x?.actionItems.first?.dueHint, "by Friday")
XCTAssertEqual(x?.openQuestions.first?.question, "What about Teams?")
XCTAssertEqual(x?.keyQuotes.first?.quote, "Go Bitcoin")
}
func testParseExtrasDropsNullStrings() {
// owner/raised_by "null" or empty must become nil, not a literal "null".
let json = #"{"tldr":{"summary":"s","primary_speakers":[]},"action_items":[{"description":"do it","owner":"null","due_hint":""}],"decisions":[],"open_questions":[],"key_quotes":[]}"#
let x = RecapAnalyzer.parseExtras(json)
XCTAssertNil(x?.actionItems.first?.owner)
XCTAssertNil(x?.actionItems.first?.dueHint)
}
// MARK: - Stitch / windows
func testStitchDropsContainedAndTrimsOverlap() {
let secs = [
TopicSection(title: "A", summary: "", startIndex: 0, endIndex: 5),
TopicSection(title: "B-contained", summary: "", startIndex: 2, endIndex: 4),
TopicSection(title: "C-overlap", summary: "", startIndex: 4, endIndex: 9),
]
let out = RecapAnalyzer.stitch(secs)
XCTAssertEqual(out.map { $0.title }, ["A", "C-overlap"])
XCTAssertEqual(out[0].startIndex, 0); XCTAssertEqual(out[0].endIndex, 5)
XCTAssertEqual(out[1].startIndex, 6); XCTAssertEqual(out[1].endIndex, 9) // trimmed front
}
func testPlanWindowsSingleForShortCall() {
let entries = (0..<10).map { entry(Double($0 * 10), Double($0 * 10 + 5), "A", "x") } // ~100s
let w = RecapAnalyzer.planWindows(entries)
XCTAssertEqual(w.count, 1)
XCTAssertEqual(w[0].startIdx, 0); XCTAssertEqual(w[0].endIdx, 9)
}
func testPlanWindowsMultipleForLongCall() {
// 40 entries at 60s spacing ~39 min, over the 25-min cutoff.
let entries = (0..<40).map { entry(Double($0 * 60), Double($0 * 60 + 30), "A", "x") }
let w = RecapAnalyzer.planWindows(entries)
XCTAssertGreaterThan(w.count, 1)
XCTAssertEqual(w.first?.startIdx, 0)
XCTAssertEqual(w.last?.endIdx, 39) // last window reaches the end
for i in 1..<w.count { XCTAssertGreaterThan(w[i].bodyStartIdx, w[i - 1].bodyStartIdx) }
}
// MARK: - Entries + formatting
func testEntriesFiltersEmptyAndSorts() {
let file = SpeakersFile(sessionId: "s", app: "meet", durationSec: 30,
speakers: [],
segments: [
.init(start: 10, end: 12, speaker: "B", text: "second"),
.init(start: 0, end: 2, speaker: "A", text: "first"),
.init(start: 5, end: 6, speaker: "A", text: " "), // empty dropped
], models: [:])
let e = RecapAnalyzer.entries(from: file)
XCTAssertEqual(e.map { $0.text }, ["first", "second"])
}
func testMmss() {
XCTAssertEqual(RecapAnalyzer.mmss(72), "1:12")
XCTAssertEqual(RecapAnalyzer.mmss(3725), "1:02:05")
}
// MARK: - Render smoke test
func testMarkdownRenders() {
let file = SpeakersFile(sessionId: "2026-06-06T11-43-12_meet", app: "meet", durationSec: 80,
speakers: [.init(name: "Grant", source: "mic_channel", overlapConfidence: nil, matchSimilarity: nil)],
segments: [
.init(start: 0, end: 4, speaker: "Grant", text: "Now we got a call going on."),
.init(start: 72, end: 74, speaker: "Caitlyn", text: "Go Bitcoin."),
], models: [:])
let extras = MeetingExtras(
tldr: .init(summary: "A quick test call.", primarySpeakers: ["Grant"]),
decisions: [], actionItems: [.init(description: "Ship it", owner: "Grant", dueHint: nil, supportingOffset: 3)],
openQuestions: [], keyQuotes: [.init(speaker: "Caitlyn", offset: 72, quote: "Go Bitcoin", whyNotable: "tone")])
let result = RecapResult(sections: [TopicSection(title: "Call start", summary: "Grant opens.", startIndex: 0, endIndex: 1)], extras: extras)
let entries = RecapAnalyzer.entries(from: file)
let md = RecapRenderer.markdown(file: file, result: result, title: "Meet call", entries: entries)
XCTAssertTrue(md.contains("## Summary"))
XCTAssertTrue(md.contains("## Action Items"))
XCTAssertTrue(md.contains("## Topics"))
XCTAssertTrue(md.contains("## Full Transcript"))
XCTAssertTrue(md.contains("Go Bitcoin"))
XCTAssertTrue(md.contains("Grant"))
// HTML smoke
let html = RecapRenderer.html(file: file, result: result, title: "Meet call", entries: entries)
XCTAssertTrue(html.contains("<!DOCTYPE html>"))
XCTAssertTrue(html.contains("Go Bitcoin"))
}
}