| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818 |
- import Foundation
- import AVFoundation
- import Speech
- /// A single piece of transcript text attributed to a channel and a time range
- /// (offsets are in seconds from the start of the recording).
- struct TranscriptSegment: Codable, Hashable {
- let speaker: String
- let startOffset: TimeInterval
- let endOffset: TimeInterval
- let text: String
- }
- /// Logical speaker labels used when merging per-channel transcripts.
- enum TranscriptSpeaker: String {
- case microphone = "You"
- case system = "Meeting"
- var localizedLabel: String {
- switch self {
- case .microphone:
- return "You".localized
- case .system:
- return "Meeting".localized
- }
- }
- }
- /// Progress snapshot for UI status updates.
- struct MeetingTranscriptionProgress: Sendable {
- let totalChunks: Int
- let completedChunks: Int
- }
- enum MeetingTranscriptionError: Error, LocalizedError {
- case authorizationDenied
- case authorizationRestricted
- case recognizerUnavailable(locale: String)
- case noAudioToTranscribe
- var errorDescription: String? {
- switch self {
- case .authorizationDenied:
- return "Speech recognition permission denied. Enable it in System Settings and try again.".localized
- case .authorizationRestricted:
- return "Speech recognition is restricted on this Mac.".localized
- case .recognizerUnavailable(let locale):
- return "Speech recognizer is unavailable for %@.".localized(with: locale)
- case .noAudioToTranscribe:
- return "No audio was available to transcribe.".localized
- }
- }
- }
- /// Transcribes meeting audio by running Apple Speech on per-channel files
- /// in fixed-size chunks, falling back across a list of locales per chunk.
- final class MeetingTranscriptionService {
- private struct ChunkPlan {
- let index: Int
- let startFrame: AVAudioFramePosition
- let frameCount: AVAudioFrameCount
- let startOffset: TimeInterval
- let endOffset: TimeInterval
- }
- /// Shared progress counter used across concurrent channels.
- private actor ProgressCounter {
- private let total: Int
- private var completed: Int = 0
- private let onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?
- init(total: Int, onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?) {
- self.total = total
- self.onProgress = onProgress
- }
- func emitInitial() {
- onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: 0))
- }
- func increment() {
- completed += 1
- onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: completed))
- }
- }
- func requestAuthorization() async throws {
- switch SFSpeechRecognizer.authorizationStatus() {
- case .authorized:
- return
- case .notDetermined:
- let status: SFSpeechRecognizerAuthorizationStatus = await withCheckedContinuation { continuation in
- SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
- }
- guard status == .authorized else { throw MeetingTranscriptionError.authorizationDenied }
- case .denied:
- throw MeetingTranscriptionError.authorizationDenied
- case .restricted:
- throw MeetingTranscriptionError.authorizationRestricted
- @unknown default:
- throw MeetingTranscriptionError.authorizationDenied
- }
- }
- /// Transcribes the mic and system channel audio (either may be nil) and
- /// returns a flat, time-ordered list of transcript segments labeled with
- /// the speaker channel.
- func transcribeMeeting(
- micURL: URL?,
- systemURL: URL?,
- chunkSeconds: TimeInterval = 7,
- overlapSeconds: TimeInterval = 1.2,
- locales: [Locale] = [],
- onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
- ) async throws -> [TranscriptSegment] {
- try await requestAuthorization()
- let micPlan: (URL, [ChunkPlan])? = try micURL.flatMap { url -> (URL, [ChunkPlan])? in
- guard FileManager.default.fileExists(atPath: url.path) else { return nil }
- let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
- return chunks.isEmpty ? nil : (url, chunks)
- }
- let systemPlan: (URL, [ChunkPlan])? = try systemURL.flatMap { url -> (URL, [ChunkPlan])? in
- guard FileManager.default.fileExists(atPath: url.path) else { return nil }
- let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
- return chunks.isEmpty ? nil : (url, chunks)
- }
- let totalChunks = (micPlan?.1.count ?? 0) + (systemPlan?.1.count ?? 0)
- guard totalChunks > 0 else {
- throw MeetingTranscriptionError.noAudioToTranscribe
- }
- let counter = ProgressCounter(total: totalChunks, onProgress: onProgress)
- await counter.emitInitial()
- let effectiveLocales = locales.isEmpty ? preferredLocalesForGlobalRecognition() : locales
- async let micSegments: [TranscriptSegment] = {
- guard let plan = micPlan else { return [] }
- return try await self.transcribeChannel(
- url: plan.0,
- chunks: plan.1,
- speaker: .microphone,
- locales: effectiveLocales,
- counter: counter
- )
- }()
- async let systemSegments: [TranscriptSegment] = {
- guard let plan = systemPlan else { return [] }
- return try await self.transcribeChannel(
- url: plan.0,
- chunks: plan.1,
- speaker: .system,
- locales: effectiveLocales,
- counter: counter
- )
- }()
- let combined = try await micSegments + systemSegments
- return combined
- .filter { $0.text.isEmpty == false }
- .sorted { $0.startOffset < $1.startOffset }
- }
- // MARK: - Chunk planning
- private func planChunks(for url: URL, chunkSeconds: TimeInterval, overlapSeconds: TimeInterval) throws -> [ChunkPlan] {
- let audioFile = try AVAudioFile(forReading: url)
- let sampleRate = audioFile.processingFormat.sampleRate
- guard sampleRate > 0 else { return [] }
- let totalFrames = audioFile.length
- guard totalFrames > 0 else { return [] }
- let chunkSamples = AVAudioFramePosition(max(1, chunkSeconds * sampleRate))
- let overlapSamples = AVAudioFramePosition(max(0, overlapSeconds * sampleRate))
- let step = max(AVAudioFramePosition(1), chunkSamples - overlapSamples)
- var plans: [ChunkPlan] = []
- var start: AVAudioFramePosition = 0
- var index = 0
- while start < totalFrames {
- let end = min(start + chunkSamples, totalFrames)
- let frameCount = AVAudioFrameCount(end - start)
- let startOffset = Double(start) / sampleRate
- let endOffset = Double(end) / sampleRate
- plans.append(ChunkPlan(
- index: index,
- startFrame: start,
- frameCount: frameCount,
- startOffset: startOffset,
- endOffset: endOffset
- ))
- index += 1
- if end >= totalFrames { break }
- start += step
- }
- return plans
- }
- // MARK: - Per-channel transcription
- private func transcribeChannel(
- url: URL,
- chunks: [ChunkPlan],
- speaker: TranscriptSpeaker,
- locales: [Locale],
- counter: ProgressCounter
- ) async throws -> [TranscriptSegment] {
- var segments: [TranscriptSegment] = []
- segments.reserveCapacity(chunks.count)
- var preferredLocale: Locale?
- for plan in chunks {
- try Task.checkCancellation()
- let buffer = try readChunkBuffer(url: url, startFrame: plan.startFrame, frameCount: plan.frameCount)
- let result = await transcribeBufferWithLocaleFallback(
- buffer: buffer,
- locales: locales,
- preferredLocale: preferredLocale
- )
- await counter.increment()
- preferredLocale = result.locale ?? preferredLocale
- let trimmed = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { continue }
- let textForSegment: String
- if let previous = segments.last {
- textForSegment = removeRepeatedPrefix(from: trimmed, previousText: previous.text)
- } else {
- textForSegment = trimmed
- }
- let normalized = textForSegment.trimmingCharacters(in: .whitespacesAndNewlines)
- if normalized.isEmpty { continue }
- segments.append(TranscriptSegment(
- speaker: speaker.localizedLabel,
- startOffset: plan.startOffset,
- endOffset: plan.endOffset,
- text: normalized
- ))
- }
- return segments
- }
- private func readChunkBuffer(url: URL, startFrame: AVAudioFramePosition, frameCount: AVAudioFrameCount) throws -> AVAudioPCMBuffer {
- let audioFile = try AVAudioFile(forReading: url)
- audioFile.framePosition = startFrame
- guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: frameCount) else {
- throw NSError(domain: "MeetingTranscriptionService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to allocate audio buffer.".localized])
- }
- try audioFile.read(into: buffer, frameCount: frameCount)
- return buffer
- }
- private func transcribeBufferWithLocaleFallback(
- buffer: AVAudioPCMBuffer,
- locales: [Locale],
- preferredLocale: Locale?
- ) async -> (text: String, locale: Locale?) {
- var orderedLocales: [Locale] = []
- if let preferredLocale {
- orderedLocales.append(preferredLocale)
- }
- for locale in locales where orderedLocales.contains(where: { $0.identifier == locale.identifier }) == false {
- orderedLocales.append(locale)
- }
- var bestCandidate: (text: String, locale: Locale, score: Double)?
- for locale in orderedLocales {
- guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
- do {
- let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { continue }
- let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
- if let current = bestCandidate {
- if score > current.score {
- bestCandidate = (trimmed, locale, score)
- }
- } else {
- bestCandidate = (trimmed, locale, score)
- }
- } catch {
- // One transient retry before moving on to the next locale.
- try? await Task.sleep(nanoseconds: 500_000_000)
- if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { continue }
- let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
- if let current = bestCandidate {
- if score > current.score {
- bestCandidate = (trimmed, locale, score)
- }
- } else {
- bestCandidate = (trimmed, locale, score)
- }
- }
- continue
- }
- }
- if let bestCandidate {
- return (bestCandidate.text, bestCandidate.locale)
- }
- return ("", nil)
- }
- private func preferredLocalesForGlobalRecognition() -> [Locale] {
- let supported = SFSpeechRecognizer.supportedLocales()
- guard supported.isEmpty == false else {
- return [Locale.current, Locale(identifier: "en-US")]
- }
- let normalizedSupportedByIdentifier: [String: Locale] = Dictionary(
- uniqueKeysWithValues: supported.map { locale in
- (normalizeLocaleIdentifier(locale.identifier), locale)
- }
- )
- var ordered: [Locale] = []
- var seen = Set<String>()
- func appendIfSupportedIdentifier(_ identifier: String) {
- let normalized = normalizeLocaleIdentifier(identifier)
- guard seen.contains(normalized) == false else { return }
- guard let supportedLocale = normalizedSupportedByIdentifier[normalized] else { return }
- seen.insert(normalized)
- ordered.append(supportedLocale)
- }
- appendIfSupportedIdentifier(Locale.current.identifier)
- for identifier in Locale.preferredLanguages {
- appendIfSupportedIdentifier(identifier)
- }
- for locale in supported.sorted(by: { $0.identifier < $1.identifier }) {
- appendIfSupportedIdentifier(locale.identifier)
- }
- if ordered.isEmpty {
- return [Locale(identifier: "en-US")]
- }
- return ordered
- }
- private func normalizeLocaleIdentifier(_ identifier: String) -> String {
- identifier.replacingOccurrences(of: "_", with: "-").lowercased()
- }
- private func scoreTranscriptCandidate(_ text: String, locale: Locale, preferredLocale: Locale?) -> Double {
- let normalizedWords = text
- .components(separatedBy: CharacterSet.letters.inverted)
- .filter { $0.isEmpty == false }
- guard normalizedWords.isEmpty == false else { return 0 }
- let totalChars = max(1, text.count)
- let letterCount = text.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
- let letterRatio = Double(letterCount) / Double(totalChars)
- var repeatedWordPenalty = 0.0
- if normalizedWords.count >= 3 {
- var longestRun = 1
- var run = 1
- for index in 1..<normalizedWords.count {
- if normalizedWords[index].caseInsensitiveCompare(normalizedWords[index - 1]) == .orderedSame {
- run += 1
- longestRun = max(longestRun, run)
- } else {
- run = 1
- }
- }
- if longestRun >= 3 {
- repeatedWordPenalty = Double(longestRun - 2) * 1.8
- }
- }
- // Bias toward continuity, but do not overpower quality score.
- let localeBias: Double
- if let preferredLocale,
- normalizeLocaleIdentifier(preferredLocale.identifier) == normalizeLocaleIdentifier(locale.identifier) {
- localeBias = 1.0
- } else {
- localeBias = 0.0
- }
- return Double(normalizedWords.count) + (letterRatio * 4.0) + localeBias - repeatedWordPenalty
- }
- /// Removes repeated leading words from a chunk that overlap with the
- /// trailing words of the previous chunk.
- private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
- let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
- let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
- let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
- guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }
- let maxOverlap = min(12, currentWords.count, previousWords.count)
- guard maxOverlap > 0 else { return text }
- for overlap in stride(from: maxOverlap, through: 1, by: -1) {
- let previousSuffix = Array(previousWords.suffix(overlap)).map { $0.lowercased() }
- let currentPrefix = Array(currentWords.prefix(overlap)).map { $0.lowercased() }
- if previousSuffix == currentPrefix {
- let remainder = currentWords.dropFirst(overlap)
- return remainder.joined(separator: " ")
- }
- }
- return text
- }
- private func transcribeBuffer(buffer: AVAudioPCMBuffer, recognizer: SFSpeechRecognizer) async throws -> String {
- let request = SFSpeechAudioBufferRecognitionRequest()
- request.shouldReportPartialResults = false
- if #available(macOS 13.0, *) {
- request.addsPunctuation = true
- }
- return try await withCheckedThrowingContinuation { continuation in
- var hasResumed = false
- let lock = NSLock()
- func resumeOnce(with result: Result<String, Error>) {
- lock.lock()
- defer { lock.unlock() }
- if hasResumed { return }
- hasResumed = true
- switch result {
- case .success(let text):
- continuation.resume(returning: text)
- case .failure(let error):
- continuation.resume(throwing: error)
- }
- }
- let task = recognizer.recognitionTask(with: request) { result, error in
- if let error {
- let nsError = error as NSError
- // "No speech detected" is a normal empty-chunk outcome (code 203 in kafAssistant domain).
- if nsError.domain == "kAFAssistantErrorDomain" && (nsError.code == 203 || nsError.code == 1110) {
- resumeOnce(with: .success(""))
- return
- }
- resumeOnce(with: .failure(error))
- return
- }
- if let result, result.isFinal {
- resumeOnce(with: .success(result.bestTranscription.formattedString))
- }
- }
- request.append(buffer)
- request.endAudio()
- _ = task
- }
- }
- }
- extension Array where Element == TranscriptSegment {
- /// Renders segments as a human-readable timeline like:
- /// `[00:12] Hello everyone.`
- func renderedTimelineText() -> String {
- let formatter: (TimeInterval) -> String = { seconds in
- let total = Int(seconds.rounded(.down))
- let h = total / 3600
- let m = (total % 3600) / 60
- let s = total % 60
- if h > 0 {
- return String(format: "%02d:%02d:%02d", h, m, s)
- }
- return String(format: "%02d:%02d", m, s)
- }
- return self.map { segment in
- "[\(formatter(segment.startOffset))] \(segment.text)"
- }.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 or app key vault.".localized
- case .invalidResponse:
- return "Notes generation returned an invalid response.".localized
- case let .httpStatus(code, body):
- return "Notes generation failed (%d): %@".localized(with: code, body)
- case .emptyNotes:
- return "Notes generation returned empty text.".localized
- }
- }
- }
- /// Generates concise meeting notes from transcript text.
- final class MeetingNotesService {
- private let session: URLSession
- private enum InAppKeyVault {
- private static let chunkOrder = [3, 9, 0, 7, 2, 10, 5, 1, 8, 4, 11, 6]
- private static let chunkSeeds: [UInt8] = [41, 77, 113, 149, 185, 221, 17, 53, 89, 125, 161, 197]
- private static let encryptedChunks: [[UInt8]] = [
- [75, 2, 114, 12, 39, 47, 198, 240, 254, 150, 129, 137, 150, 83],
- [117, 115, 43, 206, 197, 204, 221, 174, 164, 167, 158, 125, 44, 102],
- [2, 233, 190, 215, 195, 165, 182, 139, 154, 126, 54, 65, 115, 29],
- [193, 200, 230, 130, 237, 157, 171, 69, 73, 113, 102, 50, 14, 31],
- [192, 140, 136, 193, 169, 70, 105, 115, 46, 106, 91, 4, 221, 160],
- [130, 129, 174, 117, 116, 88, 112, 14, 11, 17, 208, 249, 221, 213],
- [69, 110, 30, 16, 30, 46, 29, 204, 210, 230, 238, 147, 234, 222],
- [102, 127, 49, 41, 74, 184, 171, 216, 210, 248, 136, 152, 84, 104],
- [9, 3, 35, 217, 247, 152, 220, 179, 175, 192, 83, 94, 71, 120],
- [23, 222, 174, 247, 153, 139, 173, 189, 104, 91, 20, 97, 56, 107],
- [238, 255, 240, 187, 150, 195, 79, 119, 106, 11, 40, 47, 44],
- [188, 130, 212, 186, 101, 120, 64, 122, 7, 22, 54, 236, 227, 229]
- ]
- static func resolveKey() -> String? {
- guard encryptedChunks.count == chunkOrder.count, chunkSeeds.count == chunkOrder.count else {
- return nil
- }
- var rebuiltByOriginalIndex = Array(repeating: [UInt8](), count: chunkOrder.count)
- for (scrambledIndex, encryptedChunk) in encryptedChunks.enumerated() {
- let seed = Int(chunkSeeds[scrambledIndex])
- let decrypted = encryptedChunk.enumerated().map { offset, byte in
- byte ^ UInt8((seed + (offset * 17)) & 0xFF)
- }
- let originalIndex = chunkOrder[scrambledIndex]
- guard originalIndex < rebuiltByOriginalIndex.count else { return nil }
- rebuiltByOriginalIndex[originalIndex] = decrypted
- }
- let joined = rebuiltByOriginalIndex.flatMap { $0 }
- guard joined.isEmpty == false else { return nil }
- return String(decoding: joined, as: UTF8.self)
- }
- }
- init(session: URLSession = .shared) {
- self.session = session
- }
- private enum APIKeySource: String {
- case argument
- case environment
- case inAppVault
- }
- func resolveAPIKey() -> String? {
- let env = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
- if let env, env.isEmpty == false { return env }
- let vault = normalizedAPIKey(from: InAppKeyVault.resolveKey())
- if let vault, vault.isEmpty == false { return vault }
- return nil
- }
- func generateNotes(
- from transcript: String,
- preferredLanguageIdentifier1: String? = nil,
- preferredLanguageIdentifier2: String? = nil,
- apiKey: String? = nil
- ) async throws -> String {
- let keys = resolveAPIKeyCandidates(apiKey: apiKey)
- guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
- struct Message: Encodable {
- let role: String
- let content: String
- }
- struct Body: Encodable {
- let model: String
- let messages: [Message]
- let temperature: Double
- }
- var lastError: MeetingNotesError?
- for (index, candidate) in keys.enumerated() {
- do {
- let preference = preferredNotesLanguage(
- primary: preferredLanguageIdentifier1,
- secondary: preferredLanguageIdentifier2
- )
- let notesLanguage: DominantLanguage
- if let preference {
- notesLanguage = preference
- } else {
- notesLanguage = try await detectDominantLanguage(in: transcript, apiKey: candidate.key)
- }
- let notesPrompt = buildNotesPrompt(
- transcript: transcript,
- dominantLanguage: notesLanguage,
- preferredLanguage: preference
- )
- 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: notesPrompt)
- ],
- temperature: 0.2
- )
- return try await requestChatCompletionText(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 requestChatCompletionText(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: 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: 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 struct DominantLanguage: Sendable {
- let tag: String
- let name: String
- }
- private func preferredNotesLanguage(primary: String?, secondary: String?) -> DominantLanguage? {
- // Notes should follow Preferred Language 1 when set (user intent),
- // because dominant-language detection can confuse similar scripts (e.g., Urdu vs Hindi).
- let cleaned = [
- primary?.trimmingCharacters(in: .whitespacesAndNewlines),
- secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
- ]
- .compactMap { $0 }
- .filter { $0.isEmpty == false }
- guard let identifier = cleaned.first else { return nil }
- let tag = identifier.replacingOccurrences(of: "_", with: "-")
- let english = Locale(identifier: "en")
- let languageName = english.localizedString(forIdentifier: identifier)
- ?? english.localizedString(forIdentifier: tag)
- ?? identifier
- return DominantLanguage(tag: tag, name: languageName)
- }
- private func detectDominantLanguage(in transcript: String, apiKey: String) async throws -> DominantLanguage {
- struct DetectResponse: Decodable {
- let tag: String?
- let name: String?
- }
- let detectPrompt = """
- Identify the dominant language used in the transcript (the language used most overall).
- If multiple languages are present, still choose only ONE dominant language.
- Return ONLY valid JSON with this exact schema:
- {"tag":"<BCP-47 language tag>","name":"<language name in English>"}
- 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 detect languages and reply with strict JSON only."),
- Message(role: "user", content: detectPrompt)
- ],
- temperature: 0.0
- )
- let raw = try await requestChatCompletionText(body: body, apiKey: apiKey)
- let data = Data(raw.utf8)
- let decoded = try JSONDecoder().decode(DetectResponse.self, from: data)
- let tag = (decoded.tag ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- let name = (decoded.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- if tag.isEmpty == false, name.isEmpty == false {
- return DominantLanguage(tag: tag, name: name)
- }
- // Fallback: if decoding fails partially, still force non-English when possible.
- if tag.isEmpty == false {
- return DominantLanguage(tag: tag, name: tag)
- }
- return DominantLanguage(tag: "und", name: "the dominant language of the transcript")
- }
- private func buildNotesPrompt(
- transcript: String,
- dominantLanguage: DominantLanguage,
- preferredLanguage: DominantLanguage?
- ) -> String {
- let preferenceLine: String = {
- guard let preferredLanguage else { return "" }
- return "\nPreferred language selected by the user: \(preferredLanguage.name) (\(preferredLanguage.tag)). Follow this preference."
- }()
- return """
- 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.
- \(preferenceLine)
- Output language:
- - Write ALL notes (including headings) strictly in \(dominantLanguage.name) (\(dominantLanguage.tag)).
- - Do NOT respond in English unless \(dominantLanguage.name) is English.
- - Keep proper nouns, people names, product names, acronyms, URLs, and code tokens exactly as-is.
- Output sections (translate these section headings into \(dominantLanguage.name)):
- 1) Summary (3-5 bullets)
- 2) Decisions
- 3) Action Items (include owner if identifiable; otherwise write the equivalent of "Unassigned" in \(dominantLanguage.name))
- 4) Risks / Open Questions
- Transcript:
- \(transcript)
- """
- }
- 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: InAppKeyVault.resolveKey()) {
- candidates.append((.inAppVault, 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)
- }
- }
|