|
@@ -403,3 +403,205 @@ extension Array where Element == TranscriptSegment {
|
|
|
}.joined(separator: "\n")
|
|
}.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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|