PostGenerationService.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import Foundation
  2. protocol PostGenerationServiceProtocol: Sendable {
  3. func generatePost(from draft: PostDraft) async throws -> GeneratedPost
  4. }
  5. enum PostGenerationServiceFactory {
  6. static func make(hasPremiumAccess: Bool) -> any PostGenerationServiceProtocol {
  7. if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
  8. return OpenAIPostGenerationService(apiKey: apiKey)
  9. }
  10. return MockPostGenerationService()
  11. }
  12. }
  13. enum PostGenerationError: LocalizedError {
  14. case emptyTopic
  15. case emptySubreddit
  16. case invalidSubreddit
  17. case apiUnavailable(String)
  18. case invalidResponse
  19. var errorDescription: String? {
  20. switch self {
  21. case .emptyTopic:
  22. "Enter a topic or keywords for AI generation."
  23. case .emptySubreddit:
  24. "Enter a subreddit to tailor the post."
  25. case .invalidSubreddit:
  26. "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
  27. case .apiUnavailable(let message):
  28. UserFacingError.sanitized(message) ?? UserFacingError.generic
  29. case .invalidResponse:
  30. UserFacingError.generic
  31. }
  32. }
  33. }
  34. // MARK: - OpenAI
  35. struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
  36. let apiKey: String
  37. var model: String = "gpt-4o-mini"
  38. func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
  39. try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
  40. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  41. let topic = draft.topic.trimmingCharacters(in: .whitespaces)
  42. let prompt = buildPrompt(draft: draft, subreddit: subreddit, topic: topic)
  43. var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
  44. request.httpMethod = "POST"
  45. request.timeoutInterval = 60
  46. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  47. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  48. let body: [String: Any] = [
  49. "model": model,
  50. "temperature": 0.8,
  51. "response_format": ["type": "json_object"],
  52. "messages": [
  53. [
  54. "role": "system",
  55. "content": """
  56. You write Reddit posts. Respond with JSON only using keys:
  57. title (string, max 300 chars), body (string), suggestedFlair (string or null), \
  58. pollOptions (array of 2-6 strings when post type is poll, otherwise null).
  59. Respect NSFW, spoiler, and OC tags. Match the requested tone and post type.
  60. """,
  61. ],
  62. ["role": "user", "content": prompt],
  63. ],
  64. ]
  65. request.httpBody = try JSONSerialization.data(withJSONObject: body)
  66. let data: Data
  67. let response: URLResponse
  68. do {
  69. (data, response) = try await NetworkClient.data(for: request)
  70. } catch let error as URLError {
  71. throw PostGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
  72. }
  73. if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
  74. throw PostGenerationError.apiUnavailable(
  75. OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
  76. )
  77. }
  78. return try parseResponse(data, postType: draft.postType)
  79. }
  80. private func buildPrompt(draft: PostDraft, subreddit: String, topic: String) -> String {
  81. var lines = [
  82. "Subreddit: r/\(subreddit)",
  83. "Post type: \(draft.postType.title)",
  84. "Topic: \(topic)",
  85. "Tone: \(draft.tone.title)",
  86. ]
  87. if draft.isNSFW { lines.append("Tag: NSFW") }
  88. if draft.isSpoiler { lines.append("Tag: Spoiler") }
  89. if draft.isOC { lines.append("Tag: Original Content") }
  90. if !draft.flair.isEmpty { lines.append("Preferred flair: \(draft.flair)") }
  91. switch draft.postType {
  92. case .text:
  93. lines.append("Write a full text post with markdown formatting.")
  94. case .image:
  95. lines.append("Write a short image caption and optional supporting context in body.")
  96. case .link:
  97. lines.append("Write a link post description explaining why r/\(subreddit) should care.")
  98. case .video:
  99. lines.append("Write a video post description with a compelling hook.")
  100. case .poll:
  101. lines.append("Write poll context in body and provide \(draft.pollOptions.count) distinct poll options.")
  102. }
  103. return lines.joined(separator: "\n")
  104. }
  105. private func parseResponse(_ data: Data, postType: RedditPostType) throws -> GeneratedPost {
  106. struct APIEnvelope: Decodable {
  107. struct Choice: Decodable {
  108. struct Message: Decodable {
  109. let content: String
  110. }
  111. let message: Message
  112. }
  113. let choices: [Choice]
  114. }
  115. struct Payload: Decodable {
  116. let title: String
  117. let body: String
  118. let suggestedFlair: String?
  119. let pollOptions: [String]?
  120. }
  121. let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
  122. guard let content = envelope.choices.first?.message.content,
  123. let payloadData = AIJSONDecoding.payloadData(from: content) else {
  124. throw PostGenerationError.invalidResponse
  125. }
  126. let payload = try JSONDecoder().decode(Payload.self, from: payloadData)
  127. let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength))
  128. let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
  129. var pollOptions = payload.pollOptions
  130. if postType == .poll {
  131. pollOptions = pollOptions?.map {
  132. $0.trimmingCharacters(in: .whitespaces)
  133. }.filter { !$0.isEmpty }
  134. if let pollOptions, pollOptions.count < 2 {
  135. throw PostGenerationError.invalidResponse
  136. }
  137. } else {
  138. pollOptions = nil
  139. }
  140. return GeneratedPost(
  141. title: title,
  142. body: body,
  143. suggestedFlair: payload.suggestedFlair,
  144. pollOptions: pollOptions
  145. )
  146. }
  147. }
  148. // MARK: - Mock
  149. struct MockPostGenerationService: PostGenerationServiceProtocol {
  150. func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
  151. try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
  152. try await Task.sleep(for: .milliseconds(900))
  153. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  154. let topic = draft.topic.trimmingCharacters(in: .whitespaces)
  155. let tone = draft.tone.title.lowercased()
  156. let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone, tags: draft)
  157. let body = makeBody(
  158. topic: topic,
  159. subreddit: subreddit,
  160. tone: tone,
  161. postType: draft.postType,
  162. tags: draft
  163. )
  164. let flair = suggestFlair(for: draft.postType, draft: draft)
  165. let pollOptions = draft.postType == .poll ? makePollOptions(topic: topic, subreddit: subreddit) : nil
  166. return GeneratedPost(
  167. title: String(title.prefix(PostDraftValidator.maxTitleLength)),
  168. body: String(body.prefix(PostDraftValidator.maxBodyLength)),
  169. suggestedFlair: flair,
  170. pollOptions: pollOptions
  171. )
  172. }
  173. private func makeTitle(
  174. topic: String,
  175. subreddit: String,
  176. tone: PostTone,
  177. tags: PostDraft
  178. ) -> String {
  179. let prefix = tags.isNSFW ? "[NSFW] " : ""
  180. let base: String = switch tone {
  181. case .humorous:
  182. "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong"
  183. case .professional:
  184. "Analysis: \(topic) — findings from r/\(subreddit)"
  185. case .informative:
  186. "Everything you need to know about \(topic) [Guide]"
  187. case .controversial:
  188. "Unpopular opinion: \(topic) is overrated. Change my mind."
  189. case .storytelling:
  190. "How \(topic) completely changed my perspective"
  191. case .casual:
  192. "Anyone else obsessed with \(topic) lately?"
  193. }
  194. return prefix + base
  195. }
  196. private func makeBody(
  197. topic: String,
  198. subreddit: String,
  199. tone: String,
  200. postType: RedditPostType,
  201. tags: PostDraft
  202. ) -> String {
  203. var sections: [String] = []
  204. if tags.isSpoiler {
  205. sections.append("> **Spoiler warning** — details below.")
  206. }
  207. if tags.isOC {
  208. sections.append("*Original content — created by me.*")
  209. }
  210. let core: String = switch postType {
  211. case .text:
  212. """
  213. Hey r/\(subreddit),
  214. I've been diving deep into **\(topic)** and wanted to share what I've learned.
  215. **Key points:**
  216. - Started exploring this after seeing it trending here
  217. - The community's take has been incredibly helpful
  218. - Still have a few open questions (see below)
  219. **My experience:**
  220. This started as a casual experiment but turned into something I genuinely care about. Would love to hear how others in this subreddit approach \(topic).
  221. **Questions for the community:**
  222. 1. What's your go-to resource for \(topic)?
  223. 2. Any common mistakes beginners should avoid?
  224. 3. Who else here is working on something similar?
  225. Written in a \(tone) tone — happy to refine based on feedback!
  226. """
  227. case .image:
  228. """
  229. Visual breakdown of **\(topic)** — hope this helps someone in r/\(subreddit).
  230. Happy to answer questions in the comments!
  231. """
  232. case .link:
  233. """
  234. Found this resource on **\(topic)** and thought r/\(subreddit) would find it useful.
  235. **Why it matters:** concise overview with practical takeaways for this community.
  236. """
  237. case .video:
  238. """
  239. Just watched this breakdown of **\(topic)** and had to share with r/\(subreddit).
  240. The section that really nails it is around the middle — curious if you agree.
  241. """
  242. case .poll:
  243. """
  244. Curious where r/\(subreddit) stands on **\(topic)**. Vote below and share your reasoning!
  245. """
  246. }
  247. sections.append(core)
  248. return sections.joined(separator: "\n\n")
  249. }
  250. private func makePollOptions(topic: String, subreddit: String) -> [String] {
  251. [
  252. "Love it — best thing for r/\(subreddit)",
  253. "It's fine, but overhyped",
  254. "Not for me",
  255. "Still figuring out \(topic)",
  256. ]
  257. }
  258. private func suggestFlair(for postType: RedditPostType, draft: PostDraft) -> String {
  259. if !draft.flair.isEmpty { return draft.flair }
  260. if draft.isNSFW { return "NSFW" }
  261. return switch postType {
  262. case .text: "Discussion"
  263. case .image: "Media"
  264. case .link: "News"
  265. case .video: "Video"
  266. case .poll: "Poll"
  267. }
  268. }
  269. }