201 lines
7.9 KiB
Swift
201 lines
7.9 KiB
Swift
// NotesGenerator.swift
|
|
// Handles AI-powered note generation through Coder
|
|
|
|
import Foundation
|
|
|
|
/// Result type for note generation streaming
|
|
enum GenerationResult {
|
|
case content(String)
|
|
case error(String)
|
|
}
|
|
|
|
/// Generates meeting notes using the selected Coder model
|
|
class NotesGenerator {
|
|
static let shared = NotesGenerator()
|
|
|
|
private init() {}
|
|
|
|
/// Generates meeting notes from meeting data using template-based system prompt with streaming
|
|
/// - Parameters:
|
|
/// - meeting: The meeting object containing all necessary data
|
|
/// - userBlurb: Information about the user for context
|
|
/// - systemPrompt: The system prompt template with placeholders
|
|
/// - templateId: Optional template ID to use for generating notes
|
|
/// - Returns: AsyncStream of partial generated notes
|
|
func generateNotesStream(meeting: Meeting,
|
|
userBlurb: String,
|
|
systemPrompt: String,
|
|
templateId: UUID? = nil) -> AsyncStream<GenerationResult> {
|
|
|
|
return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in
|
|
Task {
|
|
do {
|
|
guard let apiKey = KeychainHelper.shared.getCoderAPIKey(), !apiKey.isEmpty else {
|
|
continuation.yield(.error(ErrorMessage.noAPIKey))
|
|
continuation.finish()
|
|
return
|
|
}
|
|
|
|
// Validate API key before proceeding
|
|
let validationResult = await CoderAPIValidator.shared.validateAPIKey(apiKey)
|
|
switch validationResult {
|
|
case .failure(let error):
|
|
continuation.yield(.error(error.localizedDescription))
|
|
continuation.finish()
|
|
return
|
|
case .success():
|
|
break
|
|
}
|
|
|
|
// Create date formatter for meeting date
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .full
|
|
dateFormatter.timeStyle = .short
|
|
|
|
// Load template content
|
|
var templateContent = ""
|
|
if let templateId = templateId {
|
|
let templates = LocalStorageManager.shared.loadTemplates()
|
|
if let template = templates.first(where: { $0.id == templateId }) {
|
|
templateContent = template.formattedContent
|
|
}
|
|
}
|
|
|
|
// If no template content, use default
|
|
if templateContent.isEmpty {
|
|
continuation.yield(.error(ErrorMessage.noTemplate))
|
|
continuation.finish()
|
|
return
|
|
}
|
|
|
|
// Check if transcript is empty
|
|
if meeting.formattedTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
continuation.yield(.error(ErrorMessage.noTranscript))
|
|
continuation.finish()
|
|
return
|
|
}
|
|
|
|
// Prepare template variables
|
|
let templateVariables: [String: String] = [
|
|
"meeting_title": meeting.title.isEmpty ? "Untitled Meeting" : meeting.title,
|
|
"meeting_date": dateFormatter.string(from: meeting.date),
|
|
"transcript": meeting.formattedTranscript,
|
|
"user_blurb": userBlurb,
|
|
"user_notes": meeting.userNotes,
|
|
"template_content": templateContent
|
|
]
|
|
|
|
// Process the system prompt template
|
|
let systemContent = Settings.processTemplate(systemPrompt, with: templateVariables)
|
|
let stream = CoderAPIClient.shared.streamChat(
|
|
systemPrompt: systemContent,
|
|
model: UserDefaultsManager.shared.notesModel
|
|
)
|
|
for try await content in stream {
|
|
continuation.yield(.content(content))
|
|
}
|
|
|
|
continuation.finish()
|
|
} catch {
|
|
let errorMessage = ErrorHandler.shared.handleError(error)
|
|
print("Error in streaming generation: \(error)")
|
|
continuation.yield(.error(errorMessage))
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generates a short title after meeting notes have been created.
|
|
func generateMeetingTitle(
|
|
meeting: Meeting,
|
|
generatedNotes: String,
|
|
templateId: UUID?
|
|
) async throws -> String? {
|
|
let templates = LocalStorageManager.shared.loadTemplates()
|
|
let template = templateId.flatMap { id in
|
|
templates.first(where: { $0.id == id })
|
|
}
|
|
let meetingContext: String
|
|
if let template {
|
|
meetingContext = "\(template.title): \(template.context)"
|
|
} else {
|
|
meetingContext = "General meeting"
|
|
}
|
|
|
|
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
|
|
guard !meetingContent.isEmpty else { return nil }
|
|
|
|
let systemPrompt = """
|
|
Create a concise, descriptive title for a recorded meeting from its context and content.
|
|
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
|
|
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
|
|
"""
|
|
let userPrompt = """
|
|
Meeting context:
|
|
\(meetingContext)
|
|
|
|
Meeting content:
|
|
\(meetingContent)
|
|
"""
|
|
|
|
var generatedTitle = ""
|
|
let stream = CoderAPIClient.shared.streamChat(
|
|
systemPrompt: systemPrompt,
|
|
userPrompt: userPrompt,
|
|
model: UserDefaultsManager.shared.notesModel
|
|
)
|
|
for try await content in stream {
|
|
generatedTitle += content
|
|
}
|
|
|
|
return normalizedTitle(generatedTitle)
|
|
}
|
|
|
|
private func normalizedTitle(_ value: String) -> String? {
|
|
guard var title = value
|
|
.split(whereSeparator: \Character.isNewline)
|
|
.map(String.init)
|
|
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
return nil
|
|
}
|
|
|
|
while title.hasPrefix("#") {
|
|
title.removeFirst()
|
|
title = title.trimmingCharacters(in: .whitespaces)
|
|
}
|
|
if title.lowercased().hasPrefix("title:") {
|
|
title = String(title.dropFirst("title:".count))
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
|
if title.hasSuffix(".") {
|
|
title.removeLast()
|
|
}
|
|
|
|
if title.count > 80 {
|
|
let prefix = String(title.prefix(80))
|
|
if let lastSpace = prefix.lastIndex(of: " ") {
|
|
title = String(prefix[..<lastSpace])
|
|
} else {
|
|
title = prefix
|
|
}
|
|
}
|
|
|
|
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return title.isEmpty ? nil : title
|
|
}
|
|
|
|
/// Validates if the Coder service token is configured
|
|
/// - Returns: True if API key exists, false otherwise
|
|
func isConfigured() -> Bool {
|
|
guard let key = KeychainHelper.shared.getCoderAPIKey(),
|
|
!key.isEmpty else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|