PostGenerationService.swift 11 KB

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