| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- import Foundation
- protocol TitleOptimizationServiceProtocol: Sendable {
- func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult
- }
- enum TitleOptimizationServiceFactory {
- static func make(hasPremiumAccess: Bool) -> any TitleOptimizationServiceProtocol {
- if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
- return OpenAITitleOptimizationService(apiKey: apiKey)
- }
- return MockTitleOptimizationService()
- }
- }
- enum TitleOptimizationError: LocalizedError {
- case emptyTopic
- case emptySubreddit
- case emptyTitle
- case apiUnavailable(String)
- case invalidResponse
- var errorDescription: String? {
- switch self {
- case .emptyTopic: "Enter a topic or keywords to guide title generation."
- case .emptySubreddit: "Enter a subreddit to tailor titles for that community."
- case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch."
- case .apiUnavailable(let message):
- UserFacingError.sanitized(message) ?? UserFacingError.generic
- case .invalidResponse:
- UserFacingError.generic
- }
- }
- }
- // MARK: - OpenAI
- struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
- let apiKey: String
- var model: String = "gpt-4o-mini"
- func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
- guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw TitleOptimizationError.emptySubreddit
- }
- guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw TitleOptimizationError.emptyTopic
- }
- guard !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw TitleOptimizationError.emptyTitle
- }
- 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.timeoutInterval = 60
- 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 optimize Reddit post titles. Respond with JSON only using keys:
- analysis (object with overallScore, lengthScore, engagementScore, clarityScore as integers 0-100, \
- and suggestions as string array),
- variants (array of exactly \(draft.variantCount.rawValue) objects with title, score 0-100, reasoning strings).
- Titles must be under 300 characters. Match subreddit culture and the requested goal/tone.
- """,
- ],
- ["role": "user", "content": prompt],
- ],
- ]
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await NetworkClient.data(for: request)
- } catch let error as URLError {
- throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
- }
- if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
- throw TitleOptimizationError.apiUnavailable(
- OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
- )
- }
- return try parseResponse(data)
- }
- private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
- var lines = [
- "Subreddit: r/\(subreddit)",
- "Topic: \(draft.topic.trimmingCharacters(in: .whitespaces))",
- "Original title: \(draft.originalTitle.trimmingCharacters(in: .whitespaces))",
- "Goal: \(draft.titleGoal.title) — \(draft.titleGoal.subtitle)",
- "Tone: \(draft.tone.title)",
- "Post type: \(draft.postType.title)",
- ]
- if draft.isNSFW { lines.append("Tag: NSFW") }
- if draft.isSpoiler { lines.append("Tag: Spoiler") }
- if draft.isOC { lines.append("Tag: Original Content") }
- if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
- return lines.joined(separator: "\n")
- }
- private func parseResponse(_ data: Data) throws -> TitleOptimizationResult {
- struct APIEnvelope: Decodable {
- struct Choice: Decodable {
- struct Message: Decodable {
- let content: String
- }
- let message: Message
- }
- let choices: [Choice]
- }
- struct AnalysisPayload: Decodable {
- let overallScore: Int
- let lengthScore: Int
- let engagementScore: Int
- let clarityScore: Int
- let suggestions: [String]
- enum CodingKeys: String, CodingKey {
- case overallScore, lengthScore, engagementScore, clarityScore, suggestions
- }
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- overallScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .overallScore)?.value ?? 70
- lengthScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .lengthScore)?.value ?? 70
- engagementScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .engagementScore)?.value ?? 70
- clarityScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .clarityScore)?.value ?? 70
- suggestions = try container.decodeIfPresent([String].self, forKey: .suggestions) ?? []
- }
- }
- struct VariantPayload: Decodable {
- let title: String
- let score: Int
- let reasoning: String
- enum CodingKeys: String, CodingKey {
- case title, score, reasoning
- }
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- title = try container.decode(String.self, forKey: .title)
- score = try container.decode(FlexibleInt.self, forKey: .score).value
- reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
- }
- }
- struct Payload: Decodable {
- let analysis: AnalysisPayload
- let variants: [VariantPayload]
- }
- let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
- guard let content = envelope.choices.first?.message.content,
- let payloadData = AIJSONDecoding.payloadData(from: content) else {
- throw TitleOptimizationError.invalidResponse
- }
- let payload: Payload
- do {
- payload = try JSONDecoder().decode(Payload.self, from: payloadData)
- } catch {
- throw TitleOptimizationError.invalidResponse
- }
- guard !payload.variants.isEmpty else {
- throw TitleOptimizationError.invalidResponse
- }
- let analysis = TitleAnalysis(
- overallScore: clampScore(payload.analysis.overallScore),
- lengthScore: clampScore(payload.analysis.lengthScore),
- engagementScore: clampScore(payload.analysis.engagementScore),
- clarityScore: clampScore(payload.analysis.clarityScore),
- suggestions: payload.analysis.suggestions
- )
- let variants = payload.variants.map {
- TitleVariant(
- title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
- score: clampScore($0.score),
- reasoning: $0.reasoning
- )
- }
- return TitleOptimizationResult(analysis: analysis, variants: variants)
- }
- private func clampScore(_ value: Int) -> Int {
- min(100, max(0, value))
- }
- }
- struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
- func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
- guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw TitleOptimizationError.emptySubreddit
- }
- guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw TitleOptimizationError.emptyTopic
- }
- let hasTitle = !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty
- if !hasTitle {
- throw TitleOptimizationError.emptyTitle
- }
- try await Task.sleep(for: .milliseconds(900))
- let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
- let topic = draft.topic.trimmingCharacters(in: .whitespaces)
- let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
- let analysis = analyzeTitle(original, goal: draft.titleGoal)
- let variants = makeVariants(
- original: original,
- topic: topic,
- subreddit: subreddit,
- tone: draft.tone,
- goal: draft.titleGoal,
- count: draft.variantCount.rawValue
- )
- return TitleOptimizationResult(analysis: analysis, variants: variants)
- }
- private func analyzeTitle(_ title: String, goal: TitleGoal) -> TitleAnalysis {
- let length = title.count
- let lengthScore = scoreLength(length)
- let engagementScore = scoreEngagement(title, goal: goal)
- let clarityScore = scoreClarity(title)
- let overall = (lengthScore + engagementScore + clarityScore) / 3
- let suggestions = makeSuggestions(title: title, length: length, goal: goal)
- return TitleAnalysis(
- overallScore: overall,
- lengthScore: lengthScore,
- engagementScore: engagementScore,
- clarityScore: clarityScore,
- suggestions: suggestions
- )
- }
- private func scoreLength(_ length: Int) -> Int {
- switch length {
- case 0: 0
- case 1...50: 95
- case 51...80: 85
- case 81...120: 70
- case 121...200: 50
- case 201...300: 30
- default: 10
- }
- }
- private func scoreEngagement(_ title: String, goal: TitleGoal) -> Int {
- var score = 50
- let lower = title.lowercased()
- if title.contains("?") { score += 10 }
- if title.range(of: #"\d+"#, options: .regularExpression) != nil { score += 8 }
- if lower.contains("how") || lower.contains("why") || lower.contains("what") { score += 6 }
- if lower.contains("you") || lower.contains("your") { score += 5 }
- if title == title.uppercased() && title.count > 5 { score -= 15 }
- switch goal {
- case .question where !title.contains("?"): score -= 10
- case .listicle where title.range(of: #"\d+"#, options: .regularExpression) == nil: score -= 8
- case .curiosity where !lower.contains("secret") && !lower.contains("nobody") && !lower.contains("actually"): score -= 3
- default: break
- }
- return min(100, max(0, score))
- }
- private func scoreClarity(_ title: String) -> Int {
- var score = 70
- let words = title.split(separator: " ").count
- if words >= 4 && words <= 12 { score += 15 }
- if words < 3 { score -= 20 }
- if words > 20 { score -= 15 }
- if title.contains(" ") { score -= 5 }
- if title.hasPrefix(" ") || title.hasSuffix(" ") { score -= 10 }
- return min(100, max(0, score))
- }
- private func makeSuggestions(title: String, length: Int, goal: TitleGoal) -> [String] {
- var tips: [String] = []
- if length > 120 {
- tips.append("Shorten to under 80 characters for better mobile visibility.")
- } else if length < 20 {
- tips.append("Add more context — very short titles can feel vague in feeds.")
- }
- if !title.contains("?") && goal == .question {
- tips.append("Reframe as a question to invite comments.")
- }
- if title.range(of: #"\d+"#, options: .regularExpression) == nil && goal == .listicle {
- tips.append("Include a number (e.g. \"5 tips\") for listicle-style engagement.")
- }
- let lower = title.lowercased()
- if goal == .curiosity && !lower.contains("?") {
- tips.append("Add a curiosity hook — hint at a surprising outcome.")
- }
- if tips.isEmpty {
- tips.append("Strong base title — try variants with different hooks below.")
- }
- return tips
- }
- private func makeVariants(
- original: String,
- topic: String,
- subreddit: String,
- tone: PostTone,
- goal: TitleGoal,
- count: Int
- ) -> [TitleVariant] {
- let templates = variantTemplates(
- original: original,
- topic: topic,
- subreddit: subreddit,
- tone: tone,
- goal: goal
- )
- return Array(templates.prefix(count)).enumerated().map { index, template in
- TitleVariant(
- title: template.title,
- score: template.score - index * 2,
- reasoning: template.reasoning
- )
- }
- }
- private func variantTemplates(
- original: String,
- topic: String,
- subreddit: String,
- tone: PostTone,
- goal: TitleGoal
- ) -> [(title: String, score: Int, reasoning: String)] {
- let base = original.isEmpty ? topic : original
- var results: [(title: String, score: Int, reasoning: String)] = []
- switch goal {
- case .curiosity:
- results = [
- ("The \(topic) trick nobody on r/\(subreddit) talks about", 92, "Curiosity gap with community reference"),
- ("I tried \(base) for 30 days — here's what actually happened", 89, "Personal experiment hook"),
- ("Why \(topic) is quietly changing everything (and most people miss it)", 87, "Insider knowledge angle"),
- ("\(base) — am I the only one who noticed this?", 84, "Relatability + intrigue"),
- ("What r/\(subreddit) taught me about \(topic) that I wish I knew sooner", 82, "Community wisdom framing"),
- ("The uncomfortable truth about \(topic)", 80, "Bold curiosity opener"),
- ("\(base): a deep dive you didn't know you needed", 78, "Value promise"),
- ("This \(topic) approach changed my mind completely", 76, "Transformation narrative"),
- ]
- case .question:
- results = [
- ("What's your experience with \(topic)?", 91, "Direct community question"),
- ("Am I wrong about \(base)?", 88, "Invites debate and replies"),
- ("How do you approach \(topic) on r/\(subreddit)?", 86, "Subreddit-specific ask"),
- ("Is \(topic) actually worth it in 2026?", 84, "Timely relevance question"),
- ("Anyone else struggling with \(base)?", 82, "Shared struggle hook"),
- ("What's the best \(topic) advice you've ever received?", 80, "Experience-sharing prompt"),
- ("\(base) — what am I missing here?", 78, "Humble knowledge seeker"),
- ("Hot take or common knowledge: \(topic)?", 76, "Opinion solicitation"),
- ]
- case .listicle:
- results = [
- ("7 \(topic) tips that actually work on r/\(subreddit)", 93, "Numbered list with community anchor"),
- ("5 mistakes everyone makes with \(base)", 90, "Problem-awareness listicle"),
- ("3 reasons \(topic) is underrated right now", 87, "Concise numbered hook"),
- ("10 things I learned about \(topic) the hard way", 85, "Lessons-learned format"),
- ("The top 5 \(topic) resources you need in 2026", 83, "Resource roundup angle"),
- ("\(base): 4 key takeaways from my experience", 81, "Summary-style list"),
- ("6 signs you're ready for \(topic)", 79, "Checklist engagement"),
- ("8 \(topic) hacks r/\(subreddit) swears by", 77, "Community-endorsed list"),
- ]
- case .emotional:
- results = [
- ("\(base) hit me harder than I expected", 91, "Emotional vulnerability"),
- ("I wasn't ready for what \(topic) taught me", 88, "Personal revelation"),
- ("This \(topic) moment changed everything for me", 86, "Transformation story"),
- ("Honestly, \(base) has been on my mind lately", 84, "Authentic confession"),
- ("The \(topic) journey nobody prepares you for", 82, "Empathy-driven framing"),
- ("Why \(base) still keeps me up at night", 80, "Emotional intensity"),
- ("\(topic) — a love letter to r/\(subreddit)", 78, "Community appreciation"),
- ("I finally understand \(base) and it's bittersweet", 76, "Reflective tone"),
- ]
- case .direct:
- results = [
- ("\(base) — a complete guide for r/\(subreddit)", 92, "Clear value proposition"),
- ("Everything you need to know about \(topic)", 89, "Comprehensive promise"),
- ("\(topic): what works, what doesn't, and why", 87, "Balanced direct framing"),
- ("My honest take on \(base)", 85, "Straightforward opinion"),
- ("\(topic) explained simply", 83, "Accessibility focus"),
- ("The definitive \(base) breakdown", 81, "Authority positioning"),
- ("\(topic) for beginners — start here", 79, "Audience targeting"),
- ("\(base): facts, not hype", 77, "No-nonsense angle"),
- ]
- case .provocative:
- results = [
- ("Unpopular opinion: \(base) is overrated", 93, "Classic debate starter"),
- ("\(topic) is a scam and here's why", 90, "Strong contrarian hook"),
- ("Hot take: r/\(subreddit) is wrong about \(topic)", 87, "Community challenge"),
- ("Stop doing \(base) — seriously", 85, "Command attention"),
- ("\(topic) culture has gone too far", 83, "Cultural critique"),
- ("I'll say it: \(base) isn't that impressive", 81, "Mild controversy"),
- ("Why everyone is sleeping on the real \(topic) issue", 79, "Contrarian insight"),
- ("\(base) — fight me", 77, "Maximum engagement bait"),
- ]
- }
- if tone == .humorous {
- results = results.map { variant in
- (title: variant.title + " 😅", score: variant.score - 3, reasoning: variant.reasoning + " (humorous tone)")
- }
- } else if tone == .professional {
- results = results.map { variant in
- (title: "[Analysis] " + variant.title, score: variant.score - 2, reasoning: variant.reasoning + " (professional tone)")
- }
- }
- return results.sorted { $0.score > $1.score }
- }
- }
|