CommentGenerationService.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import Foundation
  2. protocol CommentGenerationServiceProtocol: Sendable {
  3. func generateComment(from draft: CommentDraft) async throws -> GeneratedComment
  4. }
  5. enum CommentGenerationServiceFactory {
  6. static func make(usesLiveAI: Bool) -> any CommentGenerationServiceProtocol {
  7. if usesLiveAI, let apiKey = AIConfiguration.apiKey {
  8. return OpenAICommentGenerationService(apiKey: apiKey)
  9. }
  10. return MockCommentGenerationService()
  11. }
  12. }
  13. enum CommentGenerationError: LocalizedError {
  14. case emptySubreddit
  15. case invalidSubreddit
  16. case emptyPostTitle
  17. case emptyParentComment
  18. case apiUnavailable(String)
  19. case invalidResponse
  20. var errorDescription: String? {
  21. switch self {
  22. case .emptySubreddit:
  23. "Enter a subreddit to tailor the comment."
  24. case .invalidSubreddit:
  25. "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
  26. case .emptyPostTitle:
  27. "Enter the post title for thread context."
  28. case .emptyParentComment:
  29. "Paste the parent comment you're replying to."
  30. case .apiUnavailable(let message):
  31. UserFacingError.sanitized(message) ?? UserFacingError.generic
  32. case .invalidResponse:
  33. UserFacingError.generic
  34. }
  35. }
  36. }
  37. // MARK: - OpenAI
  38. struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
  39. let apiKey: String
  40. var model: String = "gpt-4o-mini"
  41. func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
  42. try CommentDraftValidator.validateForGeneration(from: draft)
  43. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  44. let prompt = buildPrompt(draft: draft, subreddit: subreddit)
  45. var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
  46. request.httpMethod = "POST"
  47. request.timeoutInterval = 60
  48. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  49. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  50. let body: [String: Any] = [
  51. "model": model,
  52. "temperature": 0.85,
  53. "response_format": ["type": "json_object"],
  54. "messages": [
  55. [
  56. "role": "system",
  57. "content": """
  58. You write authentic Reddit comments. Respond with JSON only using keys:
  59. body (string, primary comment, max 10000 chars),
  60. variants (array of objects with body, score 0-100, reasoning strings).
  61. Generate exactly \(draft.variantCount.rawValue) variants ranked by fit.
  62. Write like a real Redditor: conversational, community-aware, no corporate tone.
  63. Use markdown when requested. Never start with "As an AI" or similar.
  64. Match subreddit culture. Be respectful even when disagreeing.
  65. """,
  66. ],
  67. ["role": "user", "content": prompt],
  68. ],
  69. ]
  70. request.httpBody = try JSONSerialization.data(withJSONObject: body)
  71. let data: Data
  72. let response: URLResponse
  73. do {
  74. (data, response) = try await NetworkClient.data(for: request)
  75. } catch let error as URLError {
  76. throw CommentGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
  77. }
  78. if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
  79. throw CommentGenerationError.apiUnavailable(
  80. OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
  81. )
  82. }
  83. return try parseResponse(data)
  84. }
  85. private func buildPrompt(draft: CommentDraft, subreddit: String) -> String {
  86. var lines = [
  87. "Subreddit: r/\(subreddit)",
  88. "Comment type: \(draft.commentType.title)",
  89. "Post title: \(draft.postTitle.trimmingCharacters(in: .whitespaces))",
  90. "Intent: \(draft.intent.title) — \(draft.intent.subtitle)",
  91. "Tone: \(draft.tone.title)",
  92. "Length: \(draft.length.title) (\(draft.length.wordRange))",
  93. "Markdown: \(draft.useMarkdown ? "yes" : "plain text only")",
  94. ]
  95. if !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
  96. lines.append("Focus / angle: \(draft.topic.trimmingCharacters(in: .whitespaces))")
  97. }
  98. if !draft.postBody.trimmingCharacters(in: .whitespaces).isEmpty {
  99. lines.append("Post excerpt: \(draft.postBody.trimmingCharacters(in: .whitespaces))")
  100. }
  101. if draft.commentType == .reply {
  102. lines.append("Parent comment to reply to: \(draft.parentComment.trimmingCharacters(in: .whitespaces))")
  103. if draft.includeQuote {
  104. lines.append("Optionally quote relevant part of parent with > blockquote.")
  105. }
  106. }
  107. return lines.joined(separator: "\n")
  108. }
  109. private func parseResponse(_ data: Data) throws -> GeneratedComment {
  110. struct APIEnvelope: Decodable {
  111. struct Choice: Decodable {
  112. struct Message: Decodable {
  113. let content: String
  114. }
  115. let message: Message
  116. }
  117. let choices: [Choice]
  118. }
  119. struct VariantPayload: Decodable {
  120. let body: String
  121. let score: Int
  122. let reasoning: String
  123. enum CodingKeys: String, CodingKey {
  124. case body, score, reasoning
  125. }
  126. init(from decoder: Decoder) throws {
  127. let container = try decoder.container(keyedBy: CodingKeys.self)
  128. body = try container.decode(String.self, forKey: .body)
  129. score = try container.decode(FlexibleInt.self, forKey: .score).value
  130. reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
  131. }
  132. }
  133. struct Payload: Decodable {
  134. let body: String
  135. let variants: [VariantPayload]
  136. }
  137. let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
  138. guard let content = envelope.choices.first?.message.content,
  139. let payloadData = AIJSONDecoding.payloadData(from: content) else {
  140. throw CommentGenerationError.invalidResponse
  141. }
  142. let payload = try JSONDecoder().decode(Payload.self, from: payloadData)
  143. let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
  144. let variants = payload.variants.map {
  145. CommentVariant(
  146. body: String($0.body.prefix(CommentDraftValidator.maxCommentLength)),
  147. score: min(100, max(0, $0.score)),
  148. reasoning: $0.reasoning
  149. )
  150. }
  151. guard !body.isEmpty else { throw CommentGenerationError.invalidResponse }
  152. return GeneratedComment(body: body, variants: variants)
  153. }
  154. }
  155. // MARK: - Mock
  156. struct MockCommentGenerationService: CommentGenerationServiceProtocol {
  157. func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
  158. try CommentDraftValidator.validateForGeneration(from: draft)
  159. try await Task.sleep(for: .milliseconds(900))
  160. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  161. let postTitle = draft.postTitle.trimmingCharacters(in: .whitespaces)
  162. let body = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
  163. let variants = makeVariants(draft: draft, subreddit: subreddit, postTitle: postTitle)
  164. return GeneratedComment(body: body, variants: variants)
  165. }
  166. private func makePrimaryComment(draft: CommentDraft, subreddit: String, postTitle: String) -> String {
  167. let opener: String = switch draft.commentType {
  168. case .topLevel:
  169. "Great post — "
  170. case .reply:
  171. draft.includeQuote && !draft.parentComment.isEmpty
  172. ? "> \(draft.parentComment.prefix(120))\(draft.parentComment.count > 120 ? "…" : "")\n\n"
  173. : ""
  174. }
  175. let core: String = switch draft.intent {
  176. case .addValue:
  177. """
  178. I've been lurking r/\(subreddit) for a while and this is one of the better takes on **\(postTitle)** I've seen.
  179. A few things that worked for me:
  180. - Start small and iterate — don't overthink the first step
  181. - The community wiki/FAQ is underrated
  182. - Happy to share more detail if anyone's interested
  183. Solid discussion thread.
  184. """
  185. case .agree:
  186. """
  187. 100% agree. This matches my experience with \(postTitle.lowercased()) and I'm glad someone finally said it clearly.
  188. The part about taking it one step at a time really resonates. Thanks for posting this to r/\(subreddit).
  189. """
  190. case .disagree:
  191. """
  192. Respectfully disagree on one point here. I think the common advice around \(postTitle.lowercased()) oversimplifies things.
  193. In my experience the nuance matters a lot — context, timing, and individual circumstances change the answer. Not trying to be contrarian, just adding another data point from someone who's been through it.
  194. """
  195. case .question:
  196. """
  197. Genuine question — when you say this applies broadly, do you mean for beginners or everyone in r/\(subreddit)?
  198. I've had mixed results depending on setup. Would love to hear what worked for others on **\(postTitle)**.
  199. """
  200. case .funny:
  201. """
  202. r/\(subreddit) never disappoints. Read the title, thought "yeah same," then read the post and now I'm questioning every life choice.
  203. Saving this for the next time someone asks about \(postTitle.lowercased()). 😅
  204. """
  205. case .empathetic:
  206. """
  207. Thanks for sharing this — it's hard to post about \(postTitle.lowercased()) and I appreciate the honesty.
  208. You're not alone in feeling this way. Took me a long time to figure out and the r/\(subreddit) community helped more than I expected.
  209. """
  210. case .technical:
  211. """
  212. For anyone looking for the technical breakdown on **\(postTitle)**:
  213. 1. **Root cause** — usually configuration or environment mismatch
  214. 2. **Fix** — verify prerequisites first, then apply the minimal change
  215. 3. **Verify** — reproduce the original issue and confirm it's resolved
  216. Happy to go deeper in a follow-up if useful.
  217. """
  218. case .summary:
  219. """
  220. **TL;DR:** \(postTitle) — main takeaway is to start simple, validate early, and lean on community resources in r/\(subreddit).
  221. Worth a read if you're new to this.
  222. """
  223. }
  224. let trimmed = adjustLength(core, length: draft.length)
  225. return String((opener + trimmed).prefix(CommentDraftValidator.maxCommentLength))
  226. }
  227. private func makeVariants(draft: CommentDraft, subreddit: String, postTitle: String) -> [CommentVariant] {
  228. let base = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
  229. let altIntents: [CommentIntent] = [.agree, .question, .summary, .addValue, .funny]
  230. .filter { $0 != draft.intent }
  231. .prefix(draft.variantCount.rawValue - 1)
  232. .map { $0 }
  233. var variants: [CommentVariant] = [
  234. CommentVariant(
  235. body: base,
  236. score: 92,
  237. reasoning: "Best match for \(draft.intent.title) intent and \(draft.tone.title.lowercased()) tone."
  238. ),
  239. ]
  240. for (index, intent) in altIntents.enumerated() {
  241. var altDraft = draft
  242. altDraft.intent = intent
  243. let body = makePrimaryComment(draft: altDraft, subreddit: subreddit, postTitle: postTitle)
  244. variants.append(
  245. CommentVariant(
  246. body: body,
  247. score: 88 - index * 4,
  248. reasoning: "Alternative \(intent.title.lowercased()) angle for r/\(subreddit)."
  249. )
  250. )
  251. }
  252. return Array(variants.prefix(draft.variantCount.rawValue))
  253. }
  254. private func adjustLength(_ text: String, length: CommentLength) -> String {
  255. switch length {
  256. case .short:
  257. let sentences = text.components(separatedBy: ". ")
  258. return sentences.prefix(2).joined(separator: ". ") + (sentences.count > 1 ? "." : "")
  259. case .medium:
  260. return text
  261. case .long:
  262. return text + "\n\nEdit: thanks for the awards kind strangers — didn't expect this to blow up."
  263. }
  264. }
  265. }