|
@@ -9,6 +9,108 @@ protocol EmailWriterServicing: Sendable {
|
|
|
) async throws -> String
|
|
) async throws -> String
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+struct EmailWriterService: EmailWriterServicing {
|
|
|
|
|
+ private let openAI: OpenAIChatServicing
|
|
|
|
|
+
|
|
|
|
|
+ init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
|
|
|
|
|
+ self.openAI = openAI
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func generateEmail(
|
|
|
|
|
+ prompt: String,
|
|
|
|
|
+ subject: String,
|
|
|
|
|
+ emailType: EmailType,
|
|
|
|
|
+ tone: EmailTone
|
|
|
|
|
+ ) async throws -> String {
|
|
|
|
|
+ let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !trimmed.isEmpty else {
|
|
|
|
|
+ throw OpenAIServiceError.apiError("Describe what the email should say.")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var userPrompt = "Email type: \(emailType.title)\nTone: \(tone.title)\n"
|
|
|
|
|
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if !trimmedSubject.isEmpty {
|
|
|
|
|
+ userPrompt += "Subject: \(trimmedSubject)\n"
|
|
|
|
|
+ }
|
|
|
|
|
+ userPrompt += "\nContent instructions:\n\(trimmed)"
|
|
|
|
|
+
|
|
|
|
|
+ return try await openAI.completeText(
|
|
|
|
|
+ systemPrompt: """
|
|
|
|
|
+ You are a professional email writer. Write a complete, ready-to-send email based on the \
|
|
|
|
|
+ user's instructions. Include a subject line only if one was not already provided. \
|
|
|
|
|
+ Return only the email text with no preamble or explanation.
|
|
|
|
|
+ """,
|
|
|
|
|
+ userPrompt: userPrompt
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum EmailWriterDefaults {
|
|
|
|
|
+ static let service: any EmailWriterServicing = EmailWriterService()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum EmailWriterWordLimit {
|
|
|
|
|
+ static func message(maxWords: Int) -> String {
|
|
|
|
|
+ "Maximum \(maxWords) words allowed."
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func count(in text: String) -> Int {
|
|
|
|
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !trimmed.isEmpty else { return 0 }
|
|
|
|
|
+ return trimmed
|
|
|
|
|
+ .components(separatedBy: .whitespacesAndNewlines)
|
|
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
|
|
+ .count
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func apply(_ text: String, maxWords: Int) -> (text: String, exceeded: Bool) {
|
|
|
|
|
+ guard count(in: text) > maxWords else { return (text, false) }
|
|
|
|
|
+ return (truncated(text, toMaxWords: maxWords), true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func applyImported(
|
|
|
|
|
+ _ text: String,
|
|
|
|
|
+ maxWords: Int,
|
|
|
|
|
+ setError: (String?) -> Void
|
|
|
|
|
+ ) -> String {
|
|
|
|
|
+ let (limited, exceeded) = apply(text, maxWords: maxWords)
|
|
|
|
|
+ setError(exceeded ? message(maxWords: maxWords) : nil)
|
|
|
|
|
+ return limited
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func truncated(_ text: String, toMaxWords maxWords: Int) -> String {
|
|
|
|
|
+ guard maxWords > 0 else { return "" }
|
|
|
|
|
+ guard count(in: text) > maxWords else { return text }
|
|
|
|
|
+
|
|
|
|
|
+ var wordsSeen = 0
|
|
|
|
|
+ var index = text.startIndex
|
|
|
|
|
+
|
|
|
|
|
+ while index < text.endIndex {
|
|
|
|
|
+ while index < text.endIndex, text[index].isWhitespace {
|
|
|
|
|
+ index = text.index(after: index)
|
|
|
|
|
+ }
|
|
|
|
|
+ guard index < text.endIndex else { break }
|
|
|
|
|
+
|
|
|
|
|
+ wordsSeen += 1
|
|
|
|
|
+ while index < text.endIndex, !text[index].isWhitespace {
|
|
|
|
|
+ index = text.index(after: index)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if wordsSeen >= maxWords {
|
|
|
|
|
+ return String(text[..<index])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return text
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum EmailWriterErrors {
|
|
|
|
|
+ static func message(for error: Error) -> String {
|
|
|
|
|
+ UserFacingError.message(for: error)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
enum EmailType: String, CaseIterable, Identifiable, Codable {
|
|
enum EmailType: String, CaseIterable, Identifiable, Codable {
|
|
|
case followUp
|
|
case followUp
|
|
|
case thankYou
|
|
case thankYou
|