Просмотр исходного кода

Fix AI Companion notes language selection.

Notes generation now follows the user’s preferred transcription language (preferred language 1/2) and only falls back to detection when unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 месяцев назад
Родитель
Сommit
a647911c19
2 измененных файлов с 149 добавлено и 28 удалено
  1. 141 27
      meetings_app/Transcription/MeetingTranscriptionService.swift
  2. 8 1
      meetings_app/ViewController.swift

+ 141 - 27
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -510,25 +510,15 @@ final class MeetingNotesService {
         return nil
         return nil
     }
     }
 
 
-    func generateNotes(from transcript: String, apiKey: String? = nil) async throws -> String {
+    func generateNotes(
+        from transcript: String,
+        preferredLanguageIdentifier1: String? = nil,
+        preferredLanguageIdentifier2: String? = nil,
+        apiKey: String? = nil
+    ) async throws -> String {
         let keys = resolveAPIKeyCandidates(apiKey: apiKey)
         let keys = resolveAPIKeyCandidates(apiKey: apiKey)
         guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
         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 {
         struct Message: Encodable {
             let role: String
             let role: String
             let content: String
             let content: String
@@ -539,19 +529,34 @@ final class MeetingNotesService {
             let temperature: Double
             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?
         var lastError: MeetingNotesError?
         for (index, candidate) in keys.enumerated() {
         for (index, candidate) in keys.enumerated() {
             do {
             do {
-                return try await requestNotes(body: body, apiKey: candidate.key)
+                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 {
             } catch let error as MeetingNotesError {
                 switch error {
                 switch error {
                 case .httpStatus(let code, _):
                 case .httpStatus(let code, _):
@@ -570,7 +575,7 @@ final class MeetingNotesService {
         throw lastError ?? MeetingNotesError.invalidResponse
         throw lastError ?? MeetingNotesError.invalidResponse
     }
     }
 
 
-    private func requestNotes(body: Encodable, apiKey: String) async throws -> String {
+    private func requestChatCompletionText(body: Encodable, apiKey: String) async throws -> String {
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
         request.httpMethod = "POST"
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@@ -610,6 +615,115 @@ final class MeetingNotesService {
         return notes
         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)] {
     private func resolveAPIKeyCandidates(apiKey: String?) -> [(source: APIKeySource, key: String)] {
         var candidates: [(APIKeySource, String)] = []
         var candidates: [(APIKeySource, String)] = []
         if let value = normalizedAPIKey(from: apiKey) {
         if let value = normalizedAPIKey(from: apiKey) {

+ 8 - 1
meetings_app/ViewController.swift

@@ -3814,7 +3814,14 @@ private extension ViewController {
             self.aiCompanionNotesProgressByMeetingId[meetingId] = "Generating notes with GPT..."
             self.aiCompanionNotesProgressByMeetingId[meetingId] = "Generating notes with GPT..."
             self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
             self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
         }
         }
-        return try await meetingNotesService.generateNotes(from: transcriptText)
+        let defaults = UserDefaults.standard
+        let preferred1 = defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
+        let preferred2 = defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+        return try await meetingNotesService.generateNotes(
+            from: transcriptText,
+            preferredLanguageIdentifier1: preferred1,
+            preferredLanguageIdentifier2: preferred2
+        )
     }
     }
 
 
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {