| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- import Foundation
- protocol CommentGenerationServiceProtocol: Sendable {
- func generateComment(from draft: CommentDraft) async throws -> GeneratedComment
- }
- enum CommentGenerationServiceFactory {
- static func makeDefault() -> any CommentGenerationServiceProtocol {
- if let apiKey = AIConfiguration.apiKey {
- return OpenAICommentGenerationService(apiKey: apiKey)
- }
- return MockCommentGenerationService()
- }
- }
- enum CommentGenerationError: LocalizedError {
- case emptySubreddit
- case invalidSubreddit
- case emptyPostTitle
- case emptyParentComment
- case apiUnavailable(String)
- case invalidResponse
- var errorDescription: String? {
- switch self {
- case .emptySubreddit:
- "Enter a subreddit to tailor the comment."
- case .invalidSubreddit:
- "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
- case .emptyPostTitle:
- "Enter the post title for thread context."
- case .emptyParentComment:
- "Paste the parent comment you're replying to."
- case .apiUnavailable(let message):
- message
- case .invalidResponse:
- "The AI returned an unexpected response. Please try again."
- }
- }
- }
- // MARK: - OpenAI
- struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
- let apiKey: String
- var model: String = "gpt-4o-mini"
- func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
- try CommentDraftValidator.validateForGeneration(from: draft)
- let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
- let prompt = buildPrompt(draft: draft, subreddit: subreddit)
- var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
- request.httpMethod = "POST"
- request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let body: [String: Any] = [
- "model": model,
- "temperature": 0.85,
- "response_format": ["type": "json_object"],
- "messages": [
- [
- "role": "system",
- "content": """
- You write authentic Reddit comments. Respond with JSON only using keys:
- body (string, primary comment, max 10000 chars),
- variants (array of objects with body, score 0-100, reasoning strings).
- Generate exactly \(draft.variantCount.rawValue) variants ranked by fit.
- Write like a real Redditor: conversational, community-aware, no corporate tone.
- Use markdown when requested. Never start with "As an AI" or similar.
- Match subreddit culture. Be respectful even when disagreeing.
- """,
- ],
- ["role": "user", "content": prompt],
- ],
- ]
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw CommentGenerationError.apiUnavailable("Couldn't reach the AI service.")
- }
- if http.statusCode == 401 {
- throw CommentGenerationError.apiUnavailable("Invalid OpenAI API key.")
- }
- guard (200 ... 299).contains(http.statusCode) else {
- let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
- throw CommentGenerationError.apiUnavailable("AI request failed: \(message)")
- }
- return try parseResponse(data)
- }
- private func buildPrompt(draft: CommentDraft, subreddit: String) -> String {
- var lines = [
- "Subreddit: r/\(subreddit)",
- "Comment type: \(draft.commentType.title)",
- "Post title: \(draft.postTitle.trimmingCharacters(in: .whitespaces))",
- "Intent: \(draft.intent.title) — \(draft.intent.subtitle)",
- "Tone: \(draft.tone.title)",
- "Length: \(draft.length.title) (\(draft.length.wordRange))",
- "Markdown: \(draft.useMarkdown ? "yes" : "plain text only")",
- ]
- if !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
- lines.append("Focus / angle: \(draft.topic.trimmingCharacters(in: .whitespaces))")
- }
- if !draft.postBody.trimmingCharacters(in: .whitespaces).isEmpty {
- lines.append("Post excerpt: \(draft.postBody.trimmingCharacters(in: .whitespaces))")
- }
- if draft.commentType == .reply {
- lines.append("Parent comment to reply to: \(draft.parentComment.trimmingCharacters(in: .whitespaces))")
- if draft.includeQuote {
- lines.append("Optionally quote relevant part of parent with > blockquote.")
- }
- }
- return lines.joined(separator: "\n")
- }
- private func parseResponse(_ data: Data) throws -> GeneratedComment {
- struct APIEnvelope: Decodable {
- struct Choice: Decodable {
- struct Message: Decodable {
- let content: String
- }
- let message: Message
- }
- let choices: [Choice]
- }
- struct VariantPayload: Decodable {
- let body: String
- let score: Int
- let reasoning: String
- }
- struct Payload: Decodable {
- let body: String
- let variants: [VariantPayload]
- }
- let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
- guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
- throw CommentGenerationError.invalidResponse
- }
- let payload = try JSONDecoder().decode(Payload.self, from: content)
- let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
- let variants = payload.variants.map {
- CommentVariant(
- body: String($0.body.prefix(CommentDraftValidator.maxCommentLength)),
- score: min(100, max(0, $0.score)),
- reasoning: $0.reasoning
- )
- }
- guard !body.isEmpty else { throw CommentGenerationError.invalidResponse }
- return GeneratedComment(body: body, variants: variants)
- }
- }
- // MARK: - Mock
- struct MockCommentGenerationService: CommentGenerationServiceProtocol {
- func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
- try CommentDraftValidator.validateForGeneration(from: draft)
- try await Task.sleep(for: .milliseconds(900))
- let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
- let postTitle = draft.postTitle.trimmingCharacters(in: .whitespaces)
- let body = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
- let variants = makeVariants(draft: draft, subreddit: subreddit, postTitle: postTitle)
- return GeneratedComment(body: body, variants: variants)
- }
- private func makePrimaryComment(draft: CommentDraft, subreddit: String, postTitle: String) -> String {
- let opener: String = switch draft.commentType {
- case .topLevel:
- "Great post — "
- case .reply:
- draft.includeQuote && !draft.parentComment.isEmpty
- ? "> \(draft.parentComment.prefix(120))\(draft.parentComment.count > 120 ? "…" : "")\n\n"
- : ""
- }
- let core: String = switch draft.intent {
- case .addValue:
- """
- I've been lurking r/\(subreddit) for a while and this is one of the better takes on **\(postTitle)** I've seen.
- A few things that worked for me:
- - Start small and iterate — don't overthink the first step
- - The community wiki/FAQ is underrated
- - Happy to share more detail if anyone's interested
- Solid discussion thread.
- """
- case .agree:
- """
- 100% agree. This matches my experience with \(postTitle.lowercased()) and I'm glad someone finally said it clearly.
- The part about taking it one step at a time really resonates. Thanks for posting this to r/\(subreddit).
- """
- case .disagree:
- """
- Respectfully disagree on one point here. I think the common advice around \(postTitle.lowercased()) oversimplifies things.
- 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.
- """
- case .question:
- """
- Genuine question — when you say this applies broadly, do you mean for beginners or everyone in r/\(subreddit)?
- I've had mixed results depending on setup. Would love to hear what worked for others on **\(postTitle)**.
- """
- case .funny:
- """
- r/\(subreddit) never disappoints. Read the title, thought "yeah same," then read the post and now I'm questioning every life choice.
- Saving this for the next time someone asks about \(postTitle.lowercased()). 😅
- """
- case .empathetic:
- """
- Thanks for sharing this — it's hard to post about \(postTitle.lowercased()) and I appreciate the honesty.
- 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.
- """
- case .technical:
- """
- For anyone looking for the technical breakdown on **\(postTitle)**:
- 1. **Root cause** — usually configuration or environment mismatch
- 2. **Fix** — verify prerequisites first, then apply the minimal change
- 3. **Verify** — reproduce the original issue and confirm it's resolved
- Happy to go deeper in a follow-up if useful.
- """
- case .summary:
- """
- **TL;DR:** \(postTitle) — main takeaway is to start simple, validate early, and lean on community resources in r/\(subreddit).
- Worth a read if you're new to this.
- """
- }
- let trimmed = adjustLength(core, length: draft.length)
- return String((opener + trimmed).prefix(CommentDraftValidator.maxCommentLength))
- }
- private func makeVariants(draft: CommentDraft, subreddit: String, postTitle: String) -> [CommentVariant] {
- let base = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
- let altIntents: [CommentIntent] = [.agree, .question, .summary, .addValue, .funny]
- .filter { $0 != draft.intent }
- .prefix(draft.variantCount.rawValue - 1)
- .map { $0 }
- var variants: [CommentVariant] = [
- CommentVariant(
- body: base,
- score: 92,
- reasoning: "Best match for \(draft.intent.title) intent and \(draft.tone.title.lowercased()) tone."
- ),
- ]
- for (index, intent) in altIntents.enumerated() {
- var altDraft = draft
- altDraft.intent = intent
- let body = makePrimaryComment(draft: altDraft, subreddit: subreddit, postTitle: postTitle)
- variants.append(
- CommentVariant(
- body: body,
- score: 88 - index * 4,
- reasoning: "Alternative \(intent.title.lowercased()) angle for r/\(subreddit)."
- )
- )
- }
- return Array(variants.prefix(draft.variantCount.rawValue))
- }
- private func adjustLength(_ text: String, length: CommentLength) -> String {
- switch length {
- case .short:
- let sentences = text.components(separatedBy: ". ")
- return sentences.prefix(2).joined(separator: ". ") + (sentences.count > 1 ? "." : "")
- case .medium:
- return text
- case .long:
- return text + "\n\nEdit: thanks for the awards kind strangers — didn't expect this to blow up."
- }
- }
- }
|