EmailWriterModels.swift 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import Foundation
  2. protocol EmailWriterServicing: Sendable {
  3. func generateEmail(
  4. prompt: String,
  5. subject: String,
  6. emailType: EmailType,
  7. tone: EmailTone
  8. ) async throws -> String
  9. }
  10. struct EmailWriterService: EmailWriterServicing {
  11. private let openAI: OpenAIChatServicing
  12. init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
  13. self.openAI = openAI
  14. }
  15. func generateEmail(
  16. prompt: String,
  17. subject: String,
  18. emailType: EmailType,
  19. tone: EmailTone
  20. ) async throws -> String {
  21. let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
  22. guard !trimmed.isEmpty else {
  23. throw OpenAIServiceError.apiError("Describe what the email should say.")
  24. }
  25. var userPrompt = "Email type: \(emailType.title)\nTone: \(tone.title)\n"
  26. let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
  27. if !trimmedSubject.isEmpty {
  28. userPrompt += "Subject: \(trimmedSubject)\n"
  29. }
  30. userPrompt += "\nContent instructions:\n\(trimmed)"
  31. return try await openAI.completeText(
  32. systemPrompt: """
  33. You are a professional email writer. Write a complete, ready-to-send email based on the \
  34. user's instructions. Include a subject line only if one was not already provided. \
  35. Return only the email text with no preamble or explanation.
  36. """,
  37. userPrompt: userPrompt
  38. )
  39. }
  40. }
  41. enum EmailWriterDefaults {
  42. static let service: any EmailWriterServicing = EmailWriterService()
  43. }
  44. enum EmailWriterWordLimit {
  45. static func message(maxWords: Int) -> String {
  46. "Maximum \(maxWords) words allowed."
  47. }
  48. static func count(in text: String) -> Int {
  49. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  50. guard !trimmed.isEmpty else { return 0 }
  51. return trimmed
  52. .components(separatedBy: .whitespacesAndNewlines)
  53. .filter { !$0.isEmpty }
  54. .count
  55. }
  56. static func apply(_ text: String, maxWords: Int) -> (text: String, exceeded: Bool) {
  57. guard count(in: text) > maxWords else { return (text, false) }
  58. return (truncated(text, toMaxWords: maxWords), true)
  59. }
  60. static func applyImported(
  61. _ text: String,
  62. maxWords: Int,
  63. setError: (String?) -> Void
  64. ) -> String {
  65. let (limited, exceeded) = apply(text, maxWords: maxWords)
  66. setError(exceeded ? message(maxWords: maxWords) : nil)
  67. return limited
  68. }
  69. private static func truncated(_ text: String, toMaxWords maxWords: Int) -> String {
  70. guard maxWords > 0 else { return "" }
  71. guard count(in: text) > maxWords else { return text }
  72. var wordsSeen = 0
  73. var index = text.startIndex
  74. while index < text.endIndex {
  75. while index < text.endIndex, text[index].isWhitespace {
  76. index = text.index(after: index)
  77. }
  78. guard index < text.endIndex else { break }
  79. wordsSeen += 1
  80. while index < text.endIndex, !text[index].isWhitespace {
  81. index = text.index(after: index)
  82. }
  83. if wordsSeen >= maxWords {
  84. return String(text[..<index])
  85. }
  86. }
  87. return text
  88. }
  89. }
  90. enum EmailWriterErrors {
  91. static func message(for error: Error) -> String {
  92. UserFacingError.message(for: error)
  93. }
  94. }
  95. enum EmailType: String, CaseIterable, Identifiable, Codable {
  96. case followUp
  97. case thankYou
  98. case introduction
  99. case apology
  100. case request
  101. case meetingInvite
  102. case salesOutreach
  103. case custom
  104. var id: String { rawValue }
  105. var title: String {
  106. switch self {
  107. case .followUp: "Follow-up"
  108. case .thankYou: "Thank You"
  109. case .introduction: "Introduction"
  110. case .apology: "Apology"
  111. case .request: "Request"
  112. case .meetingInvite: "Meeting Invite"
  113. case .salesOutreach: "Sales Outreach"
  114. case .custom: "Custom"
  115. }
  116. }
  117. }
  118. enum EmailTone: String, CaseIterable, Identifiable, Codable {
  119. case professional
  120. case friendly
  121. case formal
  122. case casual
  123. case persuasive
  124. var id: String { rawValue }
  125. var title: String {
  126. switch self {
  127. case .professional: "Professional"
  128. case .friendly: "Friendly"
  129. case .formal: "Formal"
  130. case .casual: "Casual"
  131. case .persuasive: "Persuasive"
  132. }
  133. }
  134. }