85bfdf2b56
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).
130 lines
6.4 KiB
Swift
130 lines
6.4 KiB
Swift
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"))
|
|
}
|
|
}
|