PostGenerationService.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import Foundation
  2. protocol PostGenerationServiceProtocol: Sendable {
  3. func generatePost(from draft: PostDraft) async throws -> GeneratedPost
  4. }
  5. enum PostGenerationServiceFactory {
  6. static func make(usesLiveAI: Bool) -> any PostGenerationServiceProtocol {
  7. if usesLiveAI, 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 = AIRequestConfiguration.model
  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 = AIRequestConfiguration.requestTimeout
  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.6,
  51. "max_tokens": AIRequestConfiguration.postGeneratorMaxTokens(for: draft.postType),
  52. "response_format": ["type": "json_object"],
  53. "messages": [
  54. [
  55. "role": "system",
  56. "content": """
  57. Write Reddit posts. JSON only: title, body, suggestedFlair (string or null), \
  58. pollOptions (2-6 strings for polls, else null).
  59. Keep body concise (under 250 words). Match 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.aiData(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 concise text post with light markdown. Under 250 words.")
  94. case .image:
  95. lines.append("Write a short image caption. Body under 80 words.")
  96. case .link:
  97. lines.append("Write a brief link post description. Under 100 words.")
  98. case .video:
  99. lines.append("Write a short video post hook. Under 100 words.")
  100. case .poll:
  101. lines.append("Write brief poll context and provide \(draft.pollOptions.count) 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. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  153. let topic = draft.topic.trimmingCharacters(in: .whitespaces)
  154. let tone = draft.tone.title.lowercased()
  155. let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone, tags: draft)
  156. let body = makeBody(
  157. topic: topic,
  158. subreddit: subreddit,
  159. tone: tone,
  160. postType: draft.postType,
  161. tags: draft
  162. )
  163. let flair = suggestFlair(for: draft.postType, draft: draft)
  164. let pollOptions = draft.postType == .poll ? makePollOptions(topic: topic, subreddit: subreddit) : nil
  165. return GeneratedPost(
  166. title: String(title.prefix(PostDraftValidator.maxTitleLength)),
  167. body: String(body.prefix(PostDraftValidator.maxBodyLength)),
  168. suggestedFlair: flair,
  169. pollOptions: pollOptions
  170. )
  171. }
  172. private func makeTitle(
  173. topic: String,
  174. subreddit: String,
  175. tone: PostTone,
  176. tags: PostDraft
  177. ) -> String {
  178. let prefix = tags.isNSFW ? "[NSFW] " : ""
  179. let base: String = switch tone {
  180. case .humorous:
  181. "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong"
  182. case .professional:
  183. "Analysis: \(topic) — findings from r/\(subreddit)"
  184. case .informative:
  185. "Everything you need to know about \(topic) [Guide]"
  186. case .controversial:
  187. "Unpopular opinion: \(topic) is overrated. Change my mind."
  188. case .storytelling:
  189. "How \(topic) completely changed my perspective"
  190. case .casual:
  191. "Anyone else obsessed with \(topic) lately?"
  192. }
  193. return prefix + base
  194. }
  195. private func makeBody(
  196. topic: String,
  197. subreddit: String,
  198. tone: String,
  199. postType: RedditPostType,
  200. tags: PostDraft
  201. ) -> String {
  202. var sections: [String] = []
  203. if tags.isSpoiler {
  204. sections.append("> **Spoiler warning** — details below.")
  205. }
  206. if tags.isOC {
  207. sections.append("*Original content — created by me.*")
  208. }
  209. let core: String = switch postType {
  210. case .text:
  211. """
  212. Hey r/\(subreddit),
  213. I've been diving deep into **\(topic)** and wanted to share what I've learned.
  214. **Key points:**
  215. - Started exploring this after seeing it trending here
  216. - The community's take has been incredibly helpful
  217. - Still have a few open questions (see below)
  218. **My experience:**
  219. 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).
  220. **Questions for the community:**
  221. 1. What's your go-to resource for \(topic)?
  222. 2. Any common mistakes beginners should avoid?
  223. 3. Who else here is working on something similar?
  224. Written in a \(tone) tone — happy to refine based on feedback!
  225. """
  226. case .image:
  227. """
  228. Visual breakdown of **\(topic)** — hope this helps someone in r/\(subreddit).
  229. Happy to answer questions in the comments!
  230. """
  231. case .link:
  232. """
  233. Found this resource on **\(topic)** and thought r/\(subreddit) would find it useful.
  234. **Why it matters:** concise overview with practical takeaways for this community.
  235. """
  236. case .video:
  237. """
  238. Just watched this breakdown of **\(topic)** and had to share with r/\(subreddit).
  239. The section that really nails it is around the middle — curious if you agree.
  240. """
  241. case .poll:
  242. """
  243. Curious where r/\(subreddit) stands on **\(topic)**. Vote below and share your reasoning!
  244. """
  245. }
  246. sections.append(core)
  247. return sections.joined(separator: "\n\n")
  248. }
  249. private func makePollOptions(topic: String, subreddit: String) -> [String] {
  250. [
  251. "Love it — best thing for r/\(subreddit)",
  252. "It's fine, but overhyped",
  253. "Not for me",
  254. "Still figuring out \(topic)",
  255. ]
  256. }
  257. private func suggestFlair(for postType: RedditPostType, draft: PostDraft) -> String {
  258. if !draft.flair.isEmpty { return draft.flair }
  259. if draft.isNSFW { return "NSFW" }
  260. return switch postType {
  261. case .text: "Discussion"
  262. case .image: "Media"
  263. case .link: "News"
  264. case .video: "Video"
  265. case .poll: "Poll"
  266. }
  267. }
  268. }