Преглед изворни кода

Add Google Meet transcript fetching to Ai companion.

Wire Ai companion transcript viewing to live Meet artifacts, improve API error visibility, and fix conference record lookup so transcript retrieval works reliably when Meet transcription is available.

Made-with: Cursor
huzaifahayat12 пре 1 месец
родитељ
комит
f563de8e25

+ 3 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -47,7 +47,9 @@ final class GoogleOAuthService: NSObject {
47 47
         "openid",
48 48
         "email",
49 49
         "profile",
50
-        "https://www.googleapis.com/auth/calendar.events"
50
+        "https://www.googleapis.com/auth/calendar.events",
51
+        // Required for Google Meet conferenceRecords/transcripts APIs.
52
+        "https://www.googleapis.com/auth/meetings.space.readonly"
51 53
     ]
52 54
 
53 55
     private let tokenStore = KeychainTokenStore()

+ 172 - 7
meetings_app/Google/GoogleMeetClient.swift

@@ -2,7 +2,8 @@ import Foundation
2 2
 
3 3
 enum GoogleMeetClientError: Error {
4 4
     case invalidResponse
5
-    case httpStatus(Int)
5
+    case httpStatus(Int, String)
6
+    case decodeFailed(String)
6 7
 }
7 8
 
8 9
 /// Thin Meet REST API wrapper.
@@ -14,12 +15,36 @@ final class GoogleMeetClient {
14 15
         self.session = session
15 16
     }
16 17
 
17
-    /// Lists conference records for a given meeting space resource name.
18
-    /// This is intentionally minimal scaffolding for phase 2 enrichment.
18
+    func getSpace(accessToken: String, spaceNameOrMeetingCode: String) async throws -> Space {
19
+        let encoded = spaceNameOrMeetingCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spaceNameOrMeetingCode
20
+        let url = URL(string: "https://meet.googleapis.com/v2/\(encoded)")!
21
+        var request = URLRequest(url: url)
22
+        request.httpMethod = "GET"
23
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
24
+
25
+        let (data, response) = try await session.data(for: request)
26
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
27
+        guard (200..<300).contains(http.statusCode) else {
28
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
29
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
30
+        }
31
+
32
+        let decoder = JSONDecoder()
33
+        decoder.dateDecodingStrategy = .iso8601
34
+        do {
35
+            return try decoder.decode(Space.self, from: data)
36
+        } catch {
37
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
38
+            throw GoogleMeetClientError.decodeFailed(raw)
39
+        }
40
+    }
41
+
42
+    /// Lists conference records filtered by meeting space resource name.
19 43
     func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
20
-        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
44
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/conferenceRecords")!
21 45
         components.queryItems = [
22
-            URLQueryItem(name: "pageSize", value: String(pageSize))
46
+            URLQueryItem(name: "pageSize", value: String(pageSize)),
47
+            URLQueryItem(name: "filter", value: "space.name = \"\(spaceResourceName)\"")
23 48
         ]
24 49
 
25 50
         var request = URLRequest(url: components.url!)
@@ -28,11 +53,107 @@ final class GoogleMeetClient {
28 53
 
29 54
         let (data, response) = try await session.data(for: request)
30 55
         guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
31
-        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
56
+        guard (200..<300).contains(http.statusCode) else {
57
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
58
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
59
+        }
32 60
 
33
-        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
61
+        let decoder = JSONDecoder()
62
+        decoder.dateDecodingStrategy = .iso8601
63
+        let decoded: ListConferenceRecordsResponse
64
+        do {
65
+            decoded = try decoder.decode(ListConferenceRecordsResponse.self, from: data)
66
+        } catch {
67
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
68
+            throw GoogleMeetClientError.decodeFailed(raw)
69
+        }
34 70
         return decoded.conferenceRecords ?? []
35 71
     }
72
+
73
+    func listTranscripts(accessToken: String, conferenceRecordName: String, pageSize: Int = 20) async throws -> [Transcript] {
74
+        var transcripts: [Transcript] = []
75
+        var nextPageToken: String?
76
+
77
+        repeat {
78
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(conferenceRecordName)/transcripts")!
79
+            components.queryItems = [
80
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
81
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
82
+            ].filter { $0.value?.isEmpty == false }
83
+
84
+            var request = URLRequest(url: components.url!)
85
+            request.httpMethod = "GET"
86
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
87
+
88
+            let (data, response) = try await session.data(for: request)
89
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
90
+            guard (200..<300).contains(http.statusCode) else {
91
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
92
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
93
+            }
94
+
95
+            let decoder = JSONDecoder()
96
+            decoder.dateDecodingStrategy = .iso8601
97
+            let decoded: ListTranscriptsResponse
98
+            do {
99
+                decoded = try decoder.decode(ListTranscriptsResponse.self, from: data)
100
+            } catch {
101
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
102
+                throw GoogleMeetClientError.decodeFailed(raw)
103
+            }
104
+            transcripts.append(contentsOf: decoded.transcripts ?? [])
105
+            nextPageToken = decoded.nextPageToken
106
+        } while nextPageToken != nil
107
+
108
+        return transcripts
109
+    }
110
+
111
+    func listTranscriptEntries(
112
+        accessToken: String,
113
+        transcriptName: String,
114
+        pageSize: Int = 200,
115
+        maxEntries: Int = 10_000
116
+    ) async throws -> [TranscriptEntry] {
117
+        var entries: [TranscriptEntry] = []
118
+        var nextPageToken: String?
119
+
120
+        repeat {
121
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(transcriptName)/entries")!
122
+            components.queryItems = [
123
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
124
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
125
+            ].filter { $0.value?.isEmpty == false }
126
+
127
+            var request = URLRequest(url: components.url!)
128
+            request.httpMethod = "GET"
129
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
130
+
131
+            let (data, response) = try await session.data(for: request)
132
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
133
+            guard (200..<300).contains(http.statusCode) else {
134
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
135
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
136
+            }
137
+
138
+            let decoder = JSONDecoder()
139
+            decoder.dateDecodingStrategy = .iso8601
140
+            let decoded: ListTranscriptEntriesResponse
141
+            do {
142
+                decoded = try decoder.decode(ListTranscriptEntriesResponse.self, from: data)
143
+            } catch {
144
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
145
+                throw GoogleMeetClientError.decodeFailed(raw)
146
+            }
147
+            entries.append(contentsOf: decoded.transcriptEntries ?? [])
148
+            nextPageToken = decoded.nextPageToken
149
+
150
+            if entries.count >= maxEntries {
151
+                break
152
+            }
153
+        } while nextPageToken != nil
154
+
155
+        return entries
156
+    }
36 157
 }
37 158
 
38 159
 // MARK: - Minimal models (v2)
@@ -43,8 +164,52 @@ struct ConferenceRecord: Decodable, Equatable {
43 164
     let endTime: Date?
44 165
 }
45 166
 
167
+struct Space: Decodable, Equatable {
168
+    let name: String?
169
+    let meetingUri: String?
170
+    let meetingCode: String?
171
+}
172
+
173
+struct Transcript: Decodable, Equatable {
174
+    let name: String?
175
+    let state: String?
176
+    let startTime: Date?
177
+    let endTime: Date?
178
+}
179
+
180
+struct TranscriptEntry: Decodable, Equatable {
181
+    let name: String?
182
+    let participant: String?
183
+    let text: String?
184
+    let languageCode: String?
185
+    let startTime: Date?
186
+    let endTime: Date?
187
+}
188
+
46 189
 private struct ListConferenceRecordsResponse: Decodable {
47 190
     let conferenceRecords: [ConferenceRecord]?
48 191
     let nextPageToken: String?
49 192
 }
50 193
 
194
+private struct ListTranscriptsResponse: Decodable {
195
+    let transcripts: [Transcript]?
196
+    let nextPageToken: String?
197
+}
198
+
199
+private struct ListTranscriptEntriesResponse: Decodable {
200
+    let transcriptEntries: [TranscriptEntry]?
201
+    let nextPageToken: String?
202
+}
203
+
204
+extension GoogleMeetClientError: LocalizedError {
205
+    var errorDescription: String? {
206
+        switch self {
207
+        case .invalidResponse:
208
+            return "Google Meet returned an invalid response."
209
+        case let .httpStatus(status, body):
210
+            return "Google Meet API error (\(status)): \(body)"
211
+        case let .decodeFailed(raw):
212
+            return "Failed to parse Google Meet response: \(raw)"
213
+        }
214
+    }
215
+}

+ 225 - 2
meetings_app/ViewController.swift

@@ -270,6 +270,12 @@ final class ViewController: NSViewController {
270 270
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
271 271
     private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
272 272
     private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
273
+    private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
274
+    private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
275
+    private var aiCompanionTranscriptTextByMeetingId = [String: String]()
276
+    private var aiCompanionTranscriptCurrentRequestId: UUID?
277
+    private var aiCompanionTranscriptWindow: NSWindow?
278
+    private weak var aiCompanionTranscriptTextView: NSTextView?
273 279
     private var aiCompanionAudioPlayer: AVPlayer?
274 280
     private var aiCompanionCurrentlyPlayingURL: URL?
275 281
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
@@ -314,6 +320,7 @@ final class ViewController: NSViewController {
314 320
     private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
315 321
     private let googleOAuth = GoogleOAuthService.shared
316 322
     private let calendarClient = GoogleCalendarClient()
323
+    private let googleMeetClient = GoogleMeetClient()
317 324
     private let storeKitCoordinator = StoreKitCoordinator()
318 325
     private var storeKitStartupTask: Task<Void, Never>?
319 326
     private var paywallPurchaseTask: Task<Void, Never>?
@@ -1977,6 +1984,13 @@ private extension ViewController {
1977 1984
         aiCompanionAudioURLByView.removeAll()
1978 1985
         aiCompanionAudioStatusLabelByView.removeAll()
1979 1986
         aiCompanionSpeechTextByView.removeAll()
1987
+        aiCompanionTranscriptMeetingIdByView.removeAll()
1988
+        aiCompanionTranscriptStatusLabelByView.removeAll()
1989
+        aiCompanionTranscriptTextByMeetingId.removeAll()
1990
+        aiCompanionTranscriptCurrentRequestId = nil
1991
+        aiCompanionTranscriptWindow?.close()
1992
+        aiCompanionTranscriptWindow = nil
1993
+        aiCompanionTranscriptTextView = nil
1980 1994
 
1981 1995
         let panel = NSView()
1982 1996
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -2012,7 +2026,7 @@ private extension ViewController {
2012 2026
         titleLabel.alignment = .left
2013 2027
         contentStack.addArrangedSubview(titleLabel)
2014 2028
 
2015
-        let subtitle = textLabel("Ended meetings with temporary audio links", font: typography.fieldLabel, color: palette.textSecondary)
2029
+        let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
2016 2030
         subtitle.alignment = .left
2017 2031
         contentStack.addArrangedSubview(subtitle)
2018 2032
         contentStack.setCustomSpacing(14, after: subtitle)
@@ -2026,7 +2040,7 @@ private extension ViewController {
2026 2040
 
2027 2041
         if endedMeetings.isEmpty {
2028 2042
             let emptyLabel = textLabel(
2029
-                "No ended meetings yet. Audio items will appear here after meetings end.",
2043
+                "No ended meetings yet. Transcript items will appear here after meetings end.",
2030 2044
                 font: typography.fieldLabel,
2031 2045
                 color: palette.textMuted
2032 2046
             )
@@ -2116,11 +2130,30 @@ private extension ViewController {
2116 2130
         audioStatusLabel.lineBreakMode = .byTruncatingTail
2117 2131
         aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
2118 2132
 
2133
+        let transcriptButton = NSButton(title: "View transcript", target: self, action: #selector(aiCompanionTranscriptTapped(_:)))
2134
+        transcriptButton.translatesAutoresizingMaskIntoConstraints = false
2135
+        transcriptButton.isBordered = false
2136
+        transcriptButton.bezelStyle = .inline
2137
+        transcriptButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
2138
+        transcriptButton.contentTintColor = palette.primaryBlue
2139
+        transcriptButton.alignment = .left
2140
+        transcriptButton.setButtonType(.momentaryPushIn)
2141
+
2142
+        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
2143
+
2144
+        let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
2145
+        transcriptStatusLabel.alignment = .left
2146
+        transcriptStatusLabel.maximumNumberOfLines = 2
2147
+        transcriptStatusLabel.lineBreakMode = .byTruncatingTail
2148
+        aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
2149
+
2119 2150
         stack.addArrangedSubview(title)
2120 2151
         stack.addArrangedSubview(dateLabel)
2121 2152
         stack.addArrangedSubview(audioButton)
2122 2153
         stack.addArrangedSubview(audioURLLabel)
2123 2154
         stack.addArrangedSubview(audioStatusLabel)
2155
+        stack.addArrangedSubview(transcriptButton)
2156
+        stack.addArrangedSubview(transcriptStatusLabel)
2124 2157
 
2125 2158
         card.addSubview(stack)
2126 2159
         NSLayoutConstraint.activate([
@@ -2305,6 +2338,48 @@ private extension ViewController {
2305 2338
         }.resume()
2306 2339
     }
2307 2340
 
2341
+    @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
2342
+        let senderId = ObjectIdentifier(sender)
2343
+        guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
2344
+        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
2345
+
2346
+        if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
2347
+            aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
2348
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
2349
+            return
2350
+        }
2351
+
2352
+        aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
2353
+
2354
+        let requestId = UUID()
2355
+        aiCompanionTranscriptCurrentRequestId = requestId
2356
+
2357
+        aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
2358
+
2359
+        Task { [weak self] in
2360
+            guard let self else { return }
2361
+
2362
+            do {
2363
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2364
+                let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
2365
+
2366
+                await MainActor.run {
2367
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
2368
+                    self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
2369
+                    self.aiCompanionTranscriptTextView?.string = text
2370
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
2371
+                }
2372
+            } catch {
2373
+                await MainActor.run {
2374
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
2375
+                    let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
2376
+                    self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
2377
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
2378
+                }
2379
+            }
2380
+        }
2381
+    }
2382
+
2308 2383
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
2309 2384
         aiCompanionAudioPlayer?.pause()
2310 2385
         aiCompanionAudioPlayer = nil
@@ -2403,6 +2478,154 @@ private extension ViewController {
2403 2478
         return "https://mock-audio.local/\(slug).mp3"
2404 2479
     }
2405 2480
 
2481
+    @MainActor
2482
+    private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
2483
+        if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
2484
+            window.title = "Transcript - \(meetingTitle)"
2485
+            textView.string = initialText
2486
+            window.makeKeyAndOrderFront(nil)
2487
+            NSApp.activate(ignoringOtherApps: true)
2488
+            return
2489
+        }
2490
+
2491
+        let windowWidth: CGFloat = 640
2492
+        let windowHeight: CGFloat = 560
2493
+        let window = NSWindow(
2494
+            contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
2495
+            styleMask: [.titled, .closable, .resizable],
2496
+            backing: .buffered,
2497
+            defer: false
2498
+        )
2499
+        window.isReleasedWhenClosed = false
2500
+        window.title = "Transcript - \(meetingTitle)"
2501
+        window.center()
2502
+
2503
+        let root = NSView()
2504
+        root.translatesAutoresizingMaskIntoConstraints = false
2505
+
2506
+        let scroll = NSScrollView()
2507
+        scroll.translatesAutoresizingMaskIntoConstraints = false
2508
+        scroll.drawsBackground = false
2509
+        scroll.hasVerticalScroller = true
2510
+
2511
+        let textView = NSTextView()
2512
+        textView.isEditable = false
2513
+        textView.isSelectable = true
2514
+        textView.backgroundColor = .clear
2515
+        textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
2516
+        textView.string = initialText
2517
+        textView.textContainer?.widthTracksTextView = true
2518
+
2519
+        scroll.documentView = textView
2520
+        root.addSubview(scroll)
2521
+
2522
+        NSLayoutConstraint.activate([
2523
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2524
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2525
+            scroll.topAnchor.constraint(equalTo: root.topAnchor),
2526
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
2527
+        ])
2528
+
2529
+        window.contentView = root
2530
+        window.makeKeyAndOrderFront(nil)
2531
+        NSApp.activate(ignoringOtherApps: true)
2532
+
2533
+        aiCompanionTranscriptWindow = window
2534
+        aiCompanionTranscriptTextView = textView
2535
+    }
2536
+
2537
+    private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
2538
+        // Typical: https://meet.google.com/abc-defg-hij
2539
+        guard let host = meetURL.host?.lowercased(),
2540
+              host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
2541
+
2542
+        let codeCandidate = meetURL.pathComponents.filter { !$0.isEmpty }.last
2543
+        guard let codeCandidate else { return nil }
2544
+
2545
+        // Allow flexible token shapes; Meet codes are usually 3 hyphen-separated chunks.
2546
+        let cleaned = codeCandidate.trimmingCharacters(in: .whitespacesAndNewlines)
2547
+        let parts = cleaned.split(separator: "-")
2548
+        guard parts.count >= 3 else { return nil }
2549
+
2550
+        return cleaned
2551
+    }
2552
+
2553
+    private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
2554
+        // Prefer a record whose time window overlaps the calendar meeting.
2555
+        let overlapping = records.filter { r in
2556
+            guard let start = r.startTime, let end = r.endTime else { return false }
2557
+            return start <= meeting.endDate && end >= meeting.startDate
2558
+        }
2559
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
2560
+            return best
2561
+        }
2562
+        // Fallback: choose the most recent one we can.
2563
+        return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
2564
+    }
2565
+
2566
+    private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
2567
+        let overlapping = transcripts.filter { t in
2568
+            guard let start = t.startTime, let end = t.endTime else { return false }
2569
+            return start <= meeting.endDate && end >= meeting.startDate
2570
+        }
2571
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
2572
+            return best
2573
+        }
2574
+        return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
2575
+    }
2576
+
2577
+    private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
2578
+        let lines = entries.compactMap { entry -> String? in
2579
+            guard let raw = entry.text else { return nil }
2580
+            let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
2581
+            guard t.isEmpty == false else { return nil }
2582
+
2583
+            let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
2584
+            guard let speaker, speaker.isEmpty == false else { return t }
2585
+            return "\(speaker): \(t)"
2586
+        }
2587
+
2588
+        if lines.isEmpty { return "(No transcript entries found.)" }
2589
+        return lines.joined(separator: "\n")
2590
+    }
2591
+
2592
+    private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
2593
+        guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
2594
+            throw NSError(
2595
+                domain: "AiCompanionTranscript",
2596
+                code: 1,
2597
+                userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
2598
+            )
2599
+        }
2600
+
2601
+        // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
2602
+        let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
2603
+        let spaceName = space.name ?? "spaces/\(meetingCode)"
2604
+
2605
+        let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
2606
+        guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
2607
+              let conferenceRecordName = conferenceRecord.name else {
2608
+            throw NSError(
2609
+                domain: "AiCompanionTranscript",
2610
+                code: 2,
2611
+                userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
2612
+            )
2613
+        }
2614
+
2615
+        let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
2616
+        guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
2617
+              let transcriptName = transcript.name else {
2618
+            throw NSError(
2619
+                domain: "AiCompanionTranscript",
2620
+                code: 3,
2621
+                userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
2622
+            )
2623
+        }
2624
+
2625
+        let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
2626
+        return aiCompanionFormatTranscriptText(entries: entries)
2627
+    }
2628
+
2406 2629
     private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
2407 2630
         let panel = NSView()
2408 2631
         panel.translatesAutoresizingMaskIntoConstraints = false