CommentGenerationService.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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. Check AIConfiguration or 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. }
  122. struct Payload: Decodable {
  123. let body: String
  124. let variants: [VariantPayload]
  125. }
  126. let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
  127. guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
  128. throw CommentGenerationError.invalidResponse
  129. }
  130. let payload = try JSONDecoder().decode(Payload.self, from: content)
  131. let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
  132. let variants = payload.variants.map {
  133. CommentVariant(
  134. body: String($0.body.prefix(CommentDraftValidator.maxCommentLength)),
  135. score: min(100, max(0, $0.score)),
  136. reasoning: $0.reasoning
  137. )
  138. }
  139. guard !body.isEmpty else { throw CommentGenerationError.invalidResponse }
  140. return GeneratedComment(body: body, variants: variants)
  141. }
  142. }
  143. // MARK: - Mock
  144. struct MockCommentGenerationService: CommentGenerationServiceProtocol {
  145. func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
  146. try CommentDraftValidator.validateForGeneration(from: draft)
  147. try await Task.sleep(for: .milliseconds(900))
  148. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  149. let postTitle = draft.postTitle.trimmingCharacters(in: .whitespaces)
  150. let body = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
  151. let variants = makeVariants(draft: draft, subreddit: subreddit, postTitle: postTitle)
  152. return GeneratedComment(body: body, variants: variants)
  153. }
  154. private func makePrimaryComment(draft: CommentDraft, subreddit: String, postTitle: String) -> String {
  155. let opener: String = switch draft.commentType {
  156. case .topLevel:
  157. "Great post — "
  158. case .reply:
  159. draft.includeQuote && !draft.parentComment.isEmpty
  160. ? "> \(draft.parentComment.prefix(120))\(draft.parentComment.count > 120 ? "…" : "")\n\n"
  161. : ""
  162. }
  163. let core: String = switch draft.intent {
  164. case .addValue:
  165. """
  166. I've been lurking r/\(subreddit) for a while and this is one of the better takes on **\(postTitle)** I've seen.
  167. A few things that worked for me:
  168. - Start small and iterate — don't overthink the first step
  169. - The community wiki/FAQ is underrated
  170. - Happy to share more detail if anyone's interested
  171. Solid discussion thread.
  172. """
  173. case .agree:
  174. """
  175. 100% agree. This matches my experience with \(postTitle.lowercased()) and I'm glad someone finally said it clearly.
  176. The part about taking it one step at a time really resonates. Thanks for posting this to r/\(subreddit).
  177. """
  178. case .disagree:
  179. """
  180. Respectfully disagree on one point here. I think the common advice around \(postTitle.lowercased()) oversimplifies things.
  181. 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.
  182. """
  183. case .question:
  184. """
  185. Genuine question — when you say this applies broadly, do you mean for beginners or everyone in r/\(subreddit)?
  186. I've had mixed results depending on setup. Would love to hear what worked for others on **\(postTitle)**.
  187. """
  188. case .funny:
  189. """
  190. r/\(subreddit) never disappoints. Read the title, thought "yeah same," then read the post and now I'm questioning every life choice.
  191. Saving this for the next time someone asks about \(postTitle.lowercased()). 😅
  192. """
  193. case .empathetic:
  194. """
  195. Thanks for sharing this — it's hard to post about \(postTitle.lowercased()) and I appreciate the honesty.
  196. 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.
  197. """
  198. case .technical:
  199. """
  200. For anyone looking for the technical breakdown on **\(postTitle)**:
  201. 1. **Root cause** — usually configuration or environment mismatch
  202. 2. **Fix** — verify prerequisites first, then apply the minimal change
  203. 3. **Verify** — reproduce the original issue and confirm it's resolved
  204. Happy to go deeper in a follow-up if useful.
  205. """
  206. case .summary:
  207. """
  208. **TL;DR:** \(postTitle) — main takeaway is to start simple, validate early, and lean on community resources in r/\(subreddit).
  209. Worth a read if you're new to this.
  210. """
  211. }
  212. let trimmed = adjustLength(core, length: draft.length)
  213. return String((opener + trimmed).prefix(CommentDraftValidator.maxCommentLength))
  214. }
  215. private func makeVariants(draft: CommentDraft, subreddit: String, postTitle: String) -> [CommentVariant] {
  216. let base = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
  217. let altIntents: [CommentIntent] = [.agree, .question, .summary, .addValue, .funny]
  218. .filter { $0 != draft.intent }
  219. .prefix(draft.variantCount.rawValue - 1)
  220. .map { $0 }
  221. var variants: [CommentVariant] = [
  222. CommentVariant(
  223. body: base,
  224. score: 92,
  225. reasoning: "Best match for \(draft.intent.title) intent and \(draft.tone.title.lowercased()) tone."
  226. ),
  227. ]
  228. for (index, intent) in altIntents.enumerated() {
  229. var altDraft = draft
  230. altDraft.intent = intent
  231. let body = makePrimaryComment(draft: altDraft, subreddit: subreddit, postTitle: postTitle)
  232. variants.append(
  233. CommentVariant(
  234. body: body,
  235. score: 88 - index * 4,
  236. reasoning: "Alternative \(intent.title.lowercased()) angle for r/\(subreddit)."
  237. )
  238. )
  239. }
  240. return Array(variants.prefix(draft.variantCount.rawValue))
  241. }
  242. private func adjustLength(_ text: String, length: CommentLength) -> String {
  243. switch length {
  244. case .short:
  245. let sentences = text.components(separatedBy: ". ")
  246. return sentences.prefix(2).joined(separator: ". ") + (sentences.count > 1 ? "." : "")
  247. case .medium:
  248. return text
  249. case .long:
  250. return text + "\n\nEdit: thanks for the awards kind strangers — didn't expect this to blow up."
  251. }
  252. }
  253. }