CommentGenerationService.swift 13 KB

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