|
|
@@ -403,3 +403,205 @@ extension Array where Element == TranscriptSegment {
|
|
403
|
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
|
+}
|