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:
@@ -54,6 +54,8 @@ final class SessionController: ObservableObject {
|
||||
@Published private(set) var detectionStatus: CallDetector.Status = .disabled
|
||||
/// Backend transcription status for the last session.
|
||||
@Published private(set) var transcriptStatus: TranscriptStatus = .idle
|
||||
/// Set when a readable recap (`recap.html`) has been written for the last session.
|
||||
@Published private(set) var recapURL: URL?
|
||||
|
||||
private let settings: AppSettings
|
||||
private var voiceprints: VoiceprintStore
|
||||
@@ -195,6 +197,7 @@ final class SessionController: ObservableObject {
|
||||
mixedURL: folder.appendingPathComponent("mixed_mono_16k.wav"))
|
||||
self.recorder = recorder
|
||||
warning = nil
|
||||
recapURL = nil
|
||||
state = .starting
|
||||
|
||||
lifecycleGeneration += 1
|
||||
@@ -361,6 +364,7 @@ final class SessionController: ObservableObject {
|
||||
guard let inputs = lastProcess else { return }
|
||||
if case .processing = transcriptStatus { return }
|
||||
transcriptStatus = .processing(0, 1)
|
||||
recapURL = nil
|
||||
|
||||
let settings = self.settings
|
||||
let voiceprints = self.voiceprints
|
||||
@@ -379,6 +383,11 @@ final class SessionController: ObservableObject {
|
||||
await MainActor.run { self.transcriptStatus = .processing(done, total) }
|
||||
})
|
||||
self.transcriptStatus = .done(speakers: speakers.speakers.count, segments: speakers.segments.count)
|
||||
// Best-effort readable recap (topic sections + extras) via the gateway LLM.
|
||||
if settings.recapEnabled, !speakers.segments.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
await self.buildRecap(speakers: speakers, inputs: inputs, settings: settings)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
self.transcriptStatus = .idle
|
||||
} catch {
|
||||
@@ -387,6 +396,31 @@ final class SessionController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `transcript.md` + `recap.html` from the finished `speakers.json` using
|
||||
/// the gateway LLM. Best-effort: a missing LLM or any failure leaves the
|
||||
/// transcript intact and just skips the recap.
|
||||
private func buildRecap(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
|
||||
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
|
||||
guard let model = await llm.chatModelId() else { return } // no LLM on the gateway → skip
|
||||
let analyzer = RecapAnalyzer(llm: llm, model: model)
|
||||
guard let result = try? await analyzer.recap(file: speakers) else { return }
|
||||
let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId)
|
||||
try? RecapRenderer.write(file: speakers, result: result, title: title, to: inputs.folder)
|
||||
let url = inputs.folder.appendingPathComponent("recap.html")
|
||||
if FileManager.default.fileExists(atPath: url.path) { self.recapURL = url }
|
||||
}
|
||||
|
||||
/// Friendly recap title, e.g. "Google Meet call — 2026-06-06 11:43".
|
||||
private static func recapTitle(app: String, sessionId: String) -> String {
|
||||
let appName = CallDetector.DetectedApp(rawValue: app)?.display ?? app.capitalized
|
||||
let stamp = sessionId.split(separator: "_").first.map(String.init) ?? sessionId
|
||||
let parts = stamp.split(separator: "T")
|
||||
let date = parts.first.map(String.init) ?? ""
|
||||
let timeBits = parts.count > 1 ? parts[1].split(separator: "-") : []
|
||||
let time = timeBits.count >= 2 ? "\(timeBits[0]):\(timeBits[1])" : ""
|
||||
return "\(appName) call — \(date) \(time)".trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private func fail(_ message: String) {
|
||||
recorder = nil
|
||||
visualCapture = nil // recorder.start() failed before visual started; nothing running
|
||||
|
||||
Reference in New Issue
Block a user