瀏覽代碼

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 1 月之前
父節點
當前提交
1e7d27a247
共有 1 個文件被更改,包括 202 次插入0 次删除
  1. 202 0
      meetings_app/Transcription/MeetingTranscriptionService.swift

+ 202 - 0
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -403,3 +403,205 @@ extension Array where Element == TranscriptSegment {
403
         }.joined(separator: "\n")
403
         }.joined(separator: "\n")
404
     }
404
     }
405
 }
405
 }
406
+
407
+enum MeetingNotesError: Error, LocalizedError {
408
+    case missingAPIKey
409
+    case invalidResponse
410
+    case httpStatus(Int, String)
411
+    case emptyNotes
412
+
413
+    var errorDescription: String? {
414
+        switch self {
415
+        case .missingAPIKey:
416
+            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment, UserDefaults, or Info.plist."
417
+        case .invalidResponse:
418
+            return "Notes generation returned an invalid response."
419
+        case let .httpStatus(code, body):
420
+            return "Notes generation failed (\(code)): \(body)"
421
+        case .emptyNotes:
422
+            return "Notes generation returned empty text."
423
+        }
424
+    }
425
+}
426
+
427
+/// Generates concise meeting notes from transcript text.
428
+final class MeetingNotesService {
429
+    private let session: URLSession
430
+
431
+    init(session: URLSession = .shared) {
432
+        self.session = session
433
+    }
434
+
435
+    private enum APIKeySource: String {
436
+        case argument
437
+        case environment
438
+        case userDefaults
439
+        case infoPlist
440
+    }
441
+
442
+    func resolveAPIKey() -> String? {
443
+        let env = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
444
+        if let env, env.isEmpty == false { return env }
445
+
446
+        let defaults = normalizedAPIKey(from: UserDefaults.standard.string(forKey: "openai.apiKey"))
447
+        if let defaults, defaults.isEmpty == false { return defaults }
448
+
449
+        let plist = normalizedAPIKey(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIAPIKey") as? String)
450
+        if let plist, plist.isEmpty == false { return plist }
451
+        return nil
452
+    }
453
+
454
+    func generateNotes(from transcript: String, apiKey: String? = nil) async throws -> String {
455
+        let keys = resolveAPIKeyCandidates(apiKey: apiKey)
456
+        guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
457
+
458
+        let prompt = """
459
+        You are a meeting assistant. Generate structured notes from the transcript.
460
+        Important: the transcript may have missing words, dropped sentences, or minor recognition errors.
461
+        Infer likely intent conservatively and produce useful notes without inventing specific facts.
462
+        
463
+        Output sections:
464
+        1) Summary (3-5 bullets)
465
+        2) Decisions
466
+        3) Action Items (owner if identifiable, otherwise "Unassigned")
467
+        4) Risks / Open Questions
468
+        
469
+        Transcript:
470
+        \(transcript)
471
+        """
472
+
473
+        struct Message: Encodable {
474
+            let role: String
475
+            let content: String
476
+        }
477
+        struct Body: Encodable {
478
+            let model: String
479
+            let messages: [Message]
480
+            let temperature: Double
481
+        }
482
+
483
+        let body = Body(
484
+            model: "gpt-4.1-mini",
485
+            messages: [
486
+                Message(role: "system", content: "You create practical meeting notes from imperfect transcripts."),
487
+                Message(role: "user", content: prompt)
488
+            ],
489
+            temperature: 0.2
490
+        )
491
+
492
+        var lastError: MeetingNotesError?
493
+        for (index, candidate) in keys.enumerated() {
494
+            do {
495
+                return try await requestNotes(body: body, apiKey: candidate.key)
496
+            } catch let error as MeetingNotesError {
497
+                switch error {
498
+                case .httpStatus(let code, _):
499
+                    lastError = error
500
+                    if code == 401, index < (keys.count - 1) {
501
+                        // Try next configured source when one key is stale/revoked.
502
+                        continue
503
+                    }
504
+                    throw error
505
+                default:
506
+                    throw error
507
+                }
508
+            }
509
+        }
510
+
511
+        throw lastError ?? MeetingNotesError.invalidResponse
512
+    }
513
+
514
+    private func requestNotes(body: Encodable, apiKey: String) async throws -> String {
515
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
516
+        request.httpMethod = "POST"
517
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
518
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
519
+        if let project = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_PROJECT_ID"])
520
+            ?? normalizedConfigValue(from: UserDefaults.standard.string(forKey: "openai.projectID"))
521
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIProjectID") as? String) {
522
+            request.setValue(project, forHTTPHeaderField: "OpenAI-Project")
523
+        }
524
+        if let org = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_ORG_ID"])
525
+            ?? normalizedConfigValue(from: UserDefaults.standard.string(forKey: "openai.organizationID"))
526
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIOrganizationID") as? String) {
527
+            request.setValue(org, forHTTPHeaderField: "OpenAI-Organization")
528
+        }
529
+        request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
530
+
531
+        let (data, response) = try await session.data(for: request)
532
+        guard let http = response as? HTTPURLResponse else { throw MeetingNotesError.invalidResponse }
533
+        guard (200..<300).contains(http.statusCode) else {
534
+            let bodyText = String(data: data, encoding: .utf8) ?? "<no body>"
535
+            throw MeetingNotesError.httpStatus(http.statusCode, bodyText)
536
+        }
537
+
538
+        struct ChoiceMessage: Decodable {
539
+            let content: String?
540
+        }
541
+        struct Choice: Decodable {
542
+            let message: ChoiceMessage
543
+        }
544
+        struct ChatResponse: Decodable {
545
+            let choices: [Choice]
546
+        }
547
+
548
+        let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
549
+        let notes = decoded.choices.first?.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
550
+        guard notes.isEmpty == false else { throw MeetingNotesError.emptyNotes }
551
+        return notes
552
+    }
553
+
554
+    private func resolveAPIKeyCandidates(apiKey: String?) -> [(source: APIKeySource, key: String)] {
555
+        var candidates: [(APIKeySource, String)] = []
556
+        if let value = normalizedAPIKey(from: apiKey) {
557
+            candidates.append((.argument, value))
558
+        }
559
+        if let value = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]) {
560
+            candidates.append((.environment, value))
561
+        }
562
+        if let value = normalizedAPIKey(from: UserDefaults.standard.string(forKey: "openai.apiKey")) {
563
+            candidates.append((.userDefaults, value))
564
+        }
565
+        if let value = normalizedAPIKey(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIAPIKey") as? String) {
566
+            candidates.append((.infoPlist, value))
567
+        }
568
+
569
+        var unique: [(APIKeySource, String)] = []
570
+        var seen = Set<String>()
571
+        for candidate in candidates where seen.contains(candidate.1) == false {
572
+            seen.insert(candidate.1)
573
+            unique.append(candidate)
574
+        }
575
+        return unique
576
+    }
577
+
578
+    private func normalizedConfigValue(from value: String?) -> String? {
579
+        guard var cleaned = value?.trimmingCharacters(in: .whitespacesAndNewlines), cleaned.isEmpty == false else {
580
+            return nil
581
+        }
582
+        cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
583
+        return cleaned.isEmpty ? nil : cleaned
584
+    }
585
+
586
+    private func normalizedAPIKey(from value: String?) -> String? {
587
+        guard var cleaned = normalizedConfigValue(from: value) else { return nil }
588
+        cleaned = cleaned.replacingOccurrences(of: "—", with: "-")
589
+        cleaned = cleaned.replacingOccurrences(of: "–", with: "-")
590
+        cleaned = cleaned.replacingOccurrences(of: " ", with: "")
591
+        cleaned = cleaned.replacingOccurrences(of: "\u{200B}", with: "")
592
+        cleaned = cleaned.replacingOccurrences(of: "\u{FEFF}", with: "")
593
+        return cleaned.isEmpty ? nil : cleaned
594
+    }
595
+}
596
+
597
+private struct AnyEncodable: Encodable {
598
+    private let encodeBlock: (Encoder) throws -> Void
599
+
600
+    init(_ wrapped: Encodable) {
601
+        self.encodeBlock = wrapped.encode(to:)
602
+    }
603
+
604
+    func encode(to encoder: Encoder) throws {
605
+        try encodeBlock(encoder)
606
+    }
607
+}