|
@@ -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) {
|