Ver código fonte

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 2 meses atrás
pai
commit
f563de8e25

+ 3 - 1
meetings_app/Auth/GoogleOAuthService.swift

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

+ 172 - 7
meetings_app/Google/GoogleMeetClient.swift

@@ -2,7 +2,8 @@ import Foundation
 
 enum GoogleMeetClientError: Error {
     case invalidResponse
-    case httpStatus(Int)
+    case httpStatus(Int, String)
+    case decodeFailed(String)
 }
 
 /// Thin Meet REST API wrapper.
@@ -14,12 +15,36 @@ final class GoogleMeetClient {
         self.session = session
     }
 
-    /// Lists conference records for a given meeting space resource name.
-    /// This is intentionally minimal scaffolding for phase 2 enrichment.
+    func getSpace(accessToken: String, spaceNameOrMeetingCode: String) async throws -> Space {
+        let encoded = spaceNameOrMeetingCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spaceNameOrMeetingCode
+        let url = URL(string: "https://meet.googleapis.com/v2/\(encoded)")!
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
+        }
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        do {
+            return try decoder.decode(Space.self, from: data)
+        } catch {
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+            throw GoogleMeetClientError.decodeFailed(raw)
+        }
+    }
+
+    /// Lists conference records filtered by meeting space resource name.
     func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
-        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/conferenceRecords")!
         components.queryItems = [
-            URLQueryItem(name: "pageSize", value: String(pageSize))
+            URLQueryItem(name: "pageSize", value: String(pageSize)),
+            URLQueryItem(name: "filter", value: "space.name = \"\(spaceResourceName)\"")
         ]
 
         var request = URLRequest(url: components.url!)
@@ -28,11 +53,107 @@ final class GoogleMeetClient {
 
         let (data, response) = try await session.data(for: request)
         guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
-        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
+        guard (200..<300).contains(http.statusCode) else {
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
+        }
 
-        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        let decoded: ListConferenceRecordsResponse
+        do {
+            decoded = try decoder.decode(ListConferenceRecordsResponse.self, from: data)
+        } catch {
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+            throw GoogleMeetClientError.decodeFailed(raw)
+        }
         return decoded.conferenceRecords ?? []
     }
+
+    func listTranscripts(accessToken: String, conferenceRecordName: String, pageSize: Int = 20) async throws -> [Transcript] {
+        var transcripts: [Transcript] = []
+        var nextPageToken: String?
+
+        repeat {
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(conferenceRecordName)/transcripts")!
+            components.queryItems = [
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
+            ].filter { $0.value?.isEmpty == false }
+
+            var request = URLRequest(url: components.url!)
+            request.httpMethod = "GET"
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+            let (data, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
+            guard (200..<300).contains(http.statusCode) else {
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
+            }
+
+            let decoder = JSONDecoder()
+            decoder.dateDecodingStrategy = .iso8601
+            let decoded: ListTranscriptsResponse
+            do {
+                decoded = try decoder.decode(ListTranscriptsResponse.self, from: data)
+            } catch {
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+                throw GoogleMeetClientError.decodeFailed(raw)
+            }
+            transcripts.append(contentsOf: decoded.transcripts ?? [])
+            nextPageToken = decoded.nextPageToken
+        } while nextPageToken != nil
+
+        return transcripts
+    }
+
+    func listTranscriptEntries(
+        accessToken: String,
+        transcriptName: String,
+        pageSize: Int = 200,
+        maxEntries: Int = 10_000
+    ) async throws -> [TranscriptEntry] {
+        var entries: [TranscriptEntry] = []
+        var nextPageToken: String?
+
+        repeat {
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(transcriptName)/entries")!
+            components.queryItems = [
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
+            ].filter { $0.value?.isEmpty == false }
+
+            var request = URLRequest(url: components.url!)
+            request.httpMethod = "GET"
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+            let (data, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
+            guard (200..<300).contains(http.statusCode) else {
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
+            }
+
+            let decoder = JSONDecoder()
+            decoder.dateDecodingStrategy = .iso8601
+            let decoded: ListTranscriptEntriesResponse
+            do {
+                decoded = try decoder.decode(ListTranscriptEntriesResponse.self, from: data)
+            } catch {
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+                throw GoogleMeetClientError.decodeFailed(raw)
+            }
+            entries.append(contentsOf: decoded.transcriptEntries ?? [])
+            nextPageToken = decoded.nextPageToken
+
+            if entries.count >= maxEntries {
+                break
+            }
+        } while nextPageToken != nil
+
+        return entries
+    }
 }
 
 // MARK: - Minimal models (v2)
@@ -43,8 +164,52 @@ struct ConferenceRecord: Decodable, Equatable {
     let endTime: Date?
 }
 
+struct Space: Decodable, Equatable {
+    let name: String?
+    let meetingUri: String?
+    let meetingCode: String?
+}
+
+struct Transcript: Decodable, Equatable {
+    let name: String?
+    let state: String?
+    let startTime: Date?
+    let endTime: Date?
+}
+
+struct TranscriptEntry: Decodable, Equatable {
+    let name: String?
+    let participant: String?
+    let text: String?
+    let languageCode: String?
+    let startTime: Date?
+    let endTime: Date?
+}
+
 private struct ListConferenceRecordsResponse: Decodable {
     let conferenceRecords: [ConferenceRecord]?
     let nextPageToken: String?
 }
 
+private struct ListTranscriptsResponse: Decodable {
+    let transcripts: [Transcript]?
+    let nextPageToken: String?
+}
+
+private struct ListTranscriptEntriesResponse: Decodable {
+    let transcriptEntries: [TranscriptEntry]?
+    let nextPageToken: String?
+}
+
+extension GoogleMeetClientError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .invalidResponse:
+            return "Google Meet returned an invalid response."
+        case let .httpStatus(status, body):
+            return "Google Meet API error (\(status)): \(body)"
+        case let .decodeFailed(raw):
+            return "Failed to parse Google Meet response: \(raw)"
+        }
+    }
+}

+ 225 - 2
meetings_app/ViewController.swift

@@ -270,6 +270,12 @@ final class ViewController: NSViewController {
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
     private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
     private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
+    private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
+    private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
+    private var aiCompanionTranscriptTextByMeetingId = [String: String]()
+    private var aiCompanionTranscriptCurrentRequestId: UUID?
+    private var aiCompanionTranscriptWindow: NSWindow?
+    private weak var aiCompanionTranscriptTextView: NSTextView?
     private var aiCompanionAudioPlayer: AVPlayer?
     private var aiCompanionCurrentlyPlayingURL: URL?
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
@@ -314,6 +320,7 @@ final class ViewController: NSViewController {
     private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
     private let googleOAuth = GoogleOAuthService.shared
     private let calendarClient = GoogleCalendarClient()
+    private let googleMeetClient = GoogleMeetClient()
     private let storeKitCoordinator = StoreKitCoordinator()
     private var storeKitStartupTask: Task<Void, Never>?
     private var paywallPurchaseTask: Task<Void, Never>?
@@ -1977,6 +1984,13 @@ private extension ViewController {
         aiCompanionAudioURLByView.removeAll()
         aiCompanionAudioStatusLabelByView.removeAll()
         aiCompanionSpeechTextByView.removeAll()
+        aiCompanionTranscriptMeetingIdByView.removeAll()
+        aiCompanionTranscriptStatusLabelByView.removeAll()
+        aiCompanionTranscriptTextByMeetingId.removeAll()
+        aiCompanionTranscriptCurrentRequestId = nil
+        aiCompanionTranscriptWindow?.close()
+        aiCompanionTranscriptWindow = nil
+        aiCompanionTranscriptTextView = nil
 
         let panel = NSView()
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -2012,7 +2026,7 @@ private extension ViewController {
         titleLabel.alignment = .left
         contentStack.addArrangedSubview(titleLabel)
 
-        let subtitle = textLabel("Ended meetings with temporary audio links", font: typography.fieldLabel, color: palette.textSecondary)
+        let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
         subtitle.alignment = .left
         contentStack.addArrangedSubview(subtitle)
         contentStack.setCustomSpacing(14, after: subtitle)
@@ -2026,7 +2040,7 @@ private extension ViewController {
 
         if endedMeetings.isEmpty {
             let emptyLabel = textLabel(
-                "No ended meetings yet. Audio items will appear here after meetings end.",
+                "No ended meetings yet. Transcript items will appear here after meetings end.",
                 font: typography.fieldLabel,
                 color: palette.textMuted
             )
@@ -2116,11 +2130,30 @@ private extension ViewController {
         audioStatusLabel.lineBreakMode = .byTruncatingTail
         aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
 
+        let transcriptButton = NSButton(title: "View transcript", target: self, action: #selector(aiCompanionTranscriptTapped(_:)))
+        transcriptButton.translatesAutoresizingMaskIntoConstraints = false
+        transcriptButton.isBordered = false
+        transcriptButton.bezelStyle = .inline
+        transcriptButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
+        transcriptButton.contentTintColor = palette.primaryBlue
+        transcriptButton.alignment = .left
+        transcriptButton.setButtonType(.momentaryPushIn)
+
+        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
+
+        let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
+        transcriptStatusLabel.alignment = .left
+        transcriptStatusLabel.maximumNumberOfLines = 2
+        transcriptStatusLabel.lineBreakMode = .byTruncatingTail
+        aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
+
         stack.addArrangedSubview(title)
         stack.addArrangedSubview(dateLabel)
         stack.addArrangedSubview(audioButton)
         stack.addArrangedSubview(audioURLLabel)
         stack.addArrangedSubview(audioStatusLabel)
+        stack.addArrangedSubview(transcriptButton)
+        stack.addArrangedSubview(transcriptStatusLabel)
 
         card.addSubview(stack)
         NSLayoutConstraint.activate([
@@ -2305,6 +2338,48 @@ private extension ViewController {
         }.resume()
     }
 
+    @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
+        let senderId = ObjectIdentifier(sender)
+        guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
+        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
+
+        if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
+            aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
+            return
+        }
+
+        aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
+
+        let requestId = UUID()
+        aiCompanionTranscriptCurrentRequestId = requestId
+
+        aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
+
+        Task { [weak self] in
+            guard let self else { return }
+
+            do {
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
+
+                await MainActor.run {
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
+                    self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
+                    self.aiCompanionTranscriptTextView?.string = text
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
+                }
+            } catch {
+                await MainActor.run {
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
+                    let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
+                    self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
+                }
+            }
+        }
+    }
+
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
         aiCompanionAudioPlayer?.pause()
         aiCompanionAudioPlayer = nil
@@ -2403,6 +2478,154 @@ private extension ViewController {
         return "https://mock-audio.local/\(slug).mp3"
     }
 
+    @MainActor
+    private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
+        if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
+            window.title = "Transcript - \(meetingTitle)"
+            textView.string = initialText
+            window.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            return
+        }
+
+        let windowWidth: CGFloat = 640
+        let windowHeight: CGFloat = 560
+        let window = NSWindow(
+            contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
+            styleMask: [.titled, .closable, .resizable],
+            backing: .buffered,
+            defer: false
+        )
+        window.isReleasedWhenClosed = false
+        window.title = "Transcript - \(meetingTitle)"
+        window.center()
+
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+
+        let scroll = NSScrollView()
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = false
+        scroll.hasVerticalScroller = true
+
+        let textView = NSTextView()
+        textView.isEditable = false
+        textView.isSelectable = true
+        textView.backgroundColor = .clear
+        textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
+        textView.string = initialText
+        textView.textContainer?.widthTracksTextView = true
+
+        scroll.documentView = textView
+        root.addSubview(scroll)
+
+        NSLayoutConstraint.activate([
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            scroll.topAnchor.constraint(equalTo: root.topAnchor),
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
+
+        window.contentView = root
+        window.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+
+        aiCompanionTranscriptWindow = window
+        aiCompanionTranscriptTextView = textView
+    }
+
+    private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
+        // Typical: https://meet.google.com/abc-defg-hij
+        guard let host = meetURL.host?.lowercased(),
+              host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
+
+        let codeCandidate = meetURL.pathComponents.filter { !$0.isEmpty }.last
+        guard let codeCandidate else { return nil }
+
+        // Allow flexible token shapes; Meet codes are usually 3 hyphen-separated chunks.
+        let cleaned = codeCandidate.trimmingCharacters(in: .whitespacesAndNewlines)
+        let parts = cleaned.split(separator: "-")
+        guard parts.count >= 3 else { return nil }
+
+        return cleaned
+    }
+
+    private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
+        // Prefer a record whose time window overlaps the calendar meeting.
+        let overlapping = records.filter { r in
+            guard let start = r.startTime, let end = r.endTime else { return false }
+            return start <= meeting.endDate && end >= meeting.startDate
+        }
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
+            return best
+        }
+        // Fallback: choose the most recent one we can.
+        return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
+    }
+
+    private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
+        let overlapping = transcripts.filter { t in
+            guard let start = t.startTime, let end = t.endTime else { return false }
+            return start <= meeting.endDate && end >= meeting.startDate
+        }
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
+            return best
+        }
+        return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
+    }
+
+    private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
+        let lines = entries.compactMap { entry -> String? in
+            guard let raw = entry.text else { return nil }
+            let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard t.isEmpty == false else { return nil }
+
+            let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard let speaker, speaker.isEmpty == false else { return t }
+            return "\(speaker): \(t)"
+        }
+
+        if lines.isEmpty { return "(No transcript entries found.)" }
+        return lines.joined(separator: "\n")
+    }
+
+    private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
+        guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 1,
+                userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
+            )
+        }
+
+        // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
+        let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
+        let spaceName = space.name ?? "spaces/\(meetingCode)"
+
+        let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
+        guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
+              let conferenceRecordName = conferenceRecord.name else {
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 2,
+                userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
+            )
+        }
+
+        let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
+        guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
+              let transcriptName = transcript.name else {
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 3,
+                userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
+            )
+        }
+
+        let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
+        return aiCompanionFormatTranscriptText(entries: entries)
+    }
+
     private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
         let panel = NSView()
         panel.translatesAutoresizingMaskIntoConstraints = false