Browse Source

Retry notes generation across configured API key sources.

Automatically fall back to alternate key sources when a stale key returns 401 so notes generation succeeds without manual cleanup of cached credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 tháng trước cách đây
mục cha
commit
1e7d27a247
1 tập tin đã thay đổi với 202 bổ sung0 xóa
  1. 202 0
      meetings_app/Transcription/MeetingTranscriptionService.swift

+ 202 - 0
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -403,3 +403,205 @@ extension Array where Element == TranscriptSegment {
         }.joined(separator: "\n")
     }
 }
+
+enum MeetingNotesError: Error, LocalizedError {
+    case missingAPIKey
+    case invalidResponse
+    case httpStatus(Int, String)
+    case emptyNotes
+
+    var errorDescription: String? {
+        switch self {
+        case .missingAPIKey:
+            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment, UserDefaults, or Info.plist."
+        case .invalidResponse:
+            return "Notes generation returned an invalid response."
+        case let .httpStatus(code, body):
+            return "Notes generation failed (\(code)): \(body)"
+        case .emptyNotes:
+            return "Notes generation returned empty text."
+        }
+    }
+}
+
+/// Generates concise meeting notes from transcript text.
+final class MeetingNotesService {
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    private enum APIKeySource: String {
+        case argument
+        case environment
+        case userDefaults
+        case infoPlist
+    }
+
+    func resolveAPIKey() -> String? {
+        let env = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
+        if let env, env.isEmpty == false { return env }
+
+        let defaults = normalizedAPIKey(from: UserDefaults.standard.string(forKey: "openai.apiKey"))
+        if let defaults, defaults.isEmpty == false { return defaults }
+
+        let plist = normalizedAPIKey(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIAPIKey") as? String)
+        if let plist, plist.isEmpty == false { return plist }
+        return nil
+    }
+
+    func generateNotes(from transcript: String, apiKey: String? = nil) async throws -> String {
+        let keys = resolveAPIKeyCandidates(apiKey: apiKey)
+        guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
+
+        let prompt = """
+        You are a meeting assistant. Generate structured notes from the transcript.
+        Important: the transcript may have missing words, dropped sentences, or minor recognition errors.
+        Infer likely intent conservatively and produce useful notes without inventing specific facts.
+        
+        Output sections:
+        1) Summary (3-5 bullets)
+        2) Decisions
+        3) Action Items (owner if identifiable, otherwise "Unassigned")
+        4) Risks / Open Questions
+        
+        Transcript:
+        \(transcript)
+        """
+
+        struct Message: Encodable {
+            let role: String
+            let content: String
+        }
+        struct Body: Encodable {
+            let model: String
+            let messages: [Message]
+            let temperature: Double
+        }
+
+        let body = Body(
+            model: "gpt-4.1-mini",
+            messages: [
+                Message(role: "system", content: "You create practical meeting notes from imperfect transcripts."),
+                Message(role: "user", content: prompt)
+            ],
+            temperature: 0.2
+        )
+
+        var lastError: MeetingNotesError?
+        for (index, candidate) in keys.enumerated() {
+            do {
+                return try await requestNotes(body: body, apiKey: candidate.key)
+            } catch let error as MeetingNotesError {
+                switch error {
+                case .httpStatus(let code, _):
+                    lastError = error
+                    if code == 401, index < (keys.count - 1) {
+                        // Try next configured source when one key is stale/revoked.
+                        continue
+                    }
+                    throw error
+                default:
+                    throw error
+                }
+            }
+        }
+
+        throw lastError ?? MeetingNotesError.invalidResponse
+    }
+
+    private func requestNotes(body: Encodable, apiKey: String) async throws -> String {
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        if let project = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_PROJECT_ID"])
+            ?? normalizedConfigValue(from: UserDefaults.standard.string(forKey: "openai.projectID"))
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIProjectID") as? String) {
+            request.setValue(project, forHTTPHeaderField: "OpenAI-Project")
+        }
+        if let org = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_ORG_ID"])
+            ?? normalizedConfigValue(from: UserDefaults.standard.string(forKey: "openai.organizationID"))
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIOrganizationID") as? String) {
+            request.setValue(org, forHTTPHeaderField: "OpenAI-Organization")
+        }
+        request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw MeetingNotesError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let bodyText = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw MeetingNotesError.httpStatus(http.statusCode, bodyText)
+        }
+
+        struct ChoiceMessage: Decodable {
+            let content: String?
+        }
+        struct Choice: Decodable {
+            let message: ChoiceMessage
+        }
+        struct ChatResponse: Decodable {
+            let choices: [Choice]
+        }
+
+        let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
+        let notes = decoded.choices.first?.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard notes.isEmpty == false else { throw MeetingNotesError.emptyNotes }
+        return notes
+    }
+
+    private func resolveAPIKeyCandidates(apiKey: String?) -> [(source: APIKeySource, key: String)] {
+        var candidates: [(APIKeySource, String)] = []
+        if let value = normalizedAPIKey(from: apiKey) {
+            candidates.append((.argument, value))
+        }
+        if let value = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]) {
+            candidates.append((.environment, value))
+        }
+        if let value = normalizedAPIKey(from: UserDefaults.standard.string(forKey: "openai.apiKey")) {
+            candidates.append((.userDefaults, value))
+        }
+        if let value = normalizedAPIKey(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIAPIKey") as? String) {
+            candidates.append((.infoPlist, value))
+        }
+
+        var unique: [(APIKeySource, String)] = []
+        var seen = Set<String>()
+        for candidate in candidates where seen.contains(candidate.1) == false {
+            seen.insert(candidate.1)
+            unique.append(candidate)
+        }
+        return unique
+    }
+
+    private func normalizedConfigValue(from value: String?) -> String? {
+        guard var cleaned = value?.trimmingCharacters(in: .whitespacesAndNewlines), cleaned.isEmpty == false else {
+            return nil
+        }
+        cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
+        return cleaned.isEmpty ? nil : cleaned
+    }
+
+    private func normalizedAPIKey(from value: String?) -> String? {
+        guard var cleaned = normalizedConfigValue(from: value) else { return nil }
+        cleaned = cleaned.replacingOccurrences(of: "—", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: "–", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: " ", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{200B}", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{FEFF}", with: "")
+        return cleaned.isEmpty ? nil : cleaned
+    }
+}
+
+private struct AnyEncodable: Encodable {
+    private let encodeBlock: (Encoder) throws -> Void
+
+    init(_ wrapped: Encodable) {
+        self.encodeBlock = wrapped.encode(to:)
+    }
+
+    func encode(to encoder: Encoder) throws {
+        try encodeBlock(encoder)
+    }
+}