TitleOptimizationService.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import Foundation
  2. protocol TitleOptimizationServiceProtocol: Sendable {
  3. func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult
  4. }
  5. enum TitleOptimizationServiceFactory {
  6. static func make(hasPremiumAccess: Bool) -> any TitleOptimizationServiceProtocol {
  7. if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
  8. return OpenAITitleOptimizationService(apiKey: apiKey)
  9. }
  10. return MockTitleOptimizationService()
  11. }
  12. }
  13. enum TitleOptimizationError: LocalizedError {
  14. case emptyTopic
  15. case emptySubreddit
  16. case emptyTitle
  17. case apiUnavailable(String)
  18. case invalidResponse
  19. var errorDescription: String? {
  20. switch self {
  21. case .emptyTopic: "Enter a topic or keywords to guide title generation."
  22. case .emptySubreddit: "Enter a subreddit to tailor titles for that community."
  23. case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch."
  24. case .apiUnavailable(let message):
  25. UserFacingError.sanitized(message) ?? UserFacingError.generic
  26. case .invalidResponse:
  27. UserFacingError.generic
  28. }
  29. }
  30. }
  31. // MARK: - OpenAI
  32. struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
  33. let apiKey: String
  34. var model: String = "gpt-4o-mini"
  35. func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
  36. guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
  37. throw TitleOptimizationError.emptySubreddit
  38. }
  39. guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
  40. throw TitleOptimizationError.emptyTopic
  41. }
  42. guard !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
  43. throw TitleOptimizationError.emptyTitle
  44. }
  45. let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  46. let prompt = buildPrompt(draft: draft, subreddit: subreddit)
  47. var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
  48. request.httpMethod = "POST"
  49. request.timeoutInterval = 60
  50. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  51. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  52. let body: [String: Any] = [
  53. "model": model,
  54. "temperature": 0.85,
  55. "response_format": ["type": "json_object"],
  56. "messages": [
  57. [
  58. "role": "system",
  59. "content": """
  60. You optimize Reddit post titles. Respond with JSON only using keys:
  61. analysis (object with overallScore, lengthScore, engagementScore, clarityScore as integers 0-100, \
  62. and suggestions as string array),
  63. variants (array of exactly \(draft.variantCount.rawValue) objects with title, score 0-100, reasoning strings).
  64. Titles must be under 300 characters. Match subreddit culture and the requested goal/tone.
  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 TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
  77. }
  78. if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
  79. throw TitleOptimizationError.apiUnavailable(
  80. OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
  81. )
  82. }
  83. return try parseResponse(data)
  84. }
  85. private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
  86. var lines = [
  87. "Subreddit: r/\(subreddit)",
  88. "Topic: \(draft.topic.trimmingCharacters(in: .whitespaces))",
  89. "Original title: \(draft.originalTitle.trimmingCharacters(in: .whitespaces))",
  90. "Goal: \(draft.titleGoal.title) — \(draft.titleGoal.subtitle)",
  91. "Tone: \(draft.tone.title)",
  92. "Post type: \(draft.postType.title)",
  93. ]
  94. if draft.isNSFW { lines.append("Tag: NSFW") }
  95. if draft.isSpoiler { lines.append("Tag: Spoiler") }
  96. if draft.isOC { lines.append("Tag: Original Content") }
  97. if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
  98. return lines.joined(separator: "\n")
  99. }
  100. private func parseResponse(_ data: Data) throws -> TitleOptimizationResult {
  101. struct APIEnvelope: Decodable {
  102. struct Choice: Decodable {
  103. struct Message: Decodable {
  104. let content: String
  105. }
  106. let message: Message
  107. }
  108. let choices: [Choice]
  109. }
  110. struct AnalysisPayload: Decodable {
  111. let overallScore: Int
  112. let lengthScore: Int
  113. let engagementScore: Int
  114. let clarityScore: Int
  115. let suggestions: [String]
  116. enum CodingKeys: String, CodingKey {
  117. case overallScore, lengthScore, engagementScore, clarityScore, suggestions
  118. }
  119. init(from decoder: Decoder) throws {
  120. let container = try decoder.container(keyedBy: CodingKeys.self)
  121. overallScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .overallScore)?.value ?? 70
  122. lengthScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .lengthScore)?.value ?? 70
  123. engagementScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .engagementScore)?.value ?? 70
  124. clarityScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .clarityScore)?.value ?? 70
  125. suggestions = try container.decodeIfPresent([String].self, forKey: .suggestions) ?? []
  126. }
  127. }
  128. struct VariantPayload: Decodable {
  129. let title: String
  130. let score: Int
  131. let reasoning: String
  132. enum CodingKeys: String, CodingKey {
  133. case title, score, reasoning
  134. }
  135. init(from decoder: Decoder) throws {
  136. let container = try decoder.container(keyedBy: CodingKeys.self)
  137. title = try container.decode(String.self, forKey: .title)
  138. score = try container.decode(FlexibleInt.self, forKey: .score).value
  139. reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
  140. }
  141. }
  142. struct Payload: Decodable {
  143. let analysis: AnalysisPayload
  144. let variants: [VariantPayload]
  145. }
  146. let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
  147. guard let content = envelope.choices.first?.message.content,
  148. let payloadData = AIJSONDecoding.payloadData(from: content) else {
  149. throw TitleOptimizationError.invalidResponse
  150. }
  151. let payload: Payload
  152. do {
  153. payload = try JSONDecoder().decode(Payload.self, from: payloadData)
  154. } catch {
  155. throw TitleOptimizationError.invalidResponse
  156. }
  157. guard !payload.variants.isEmpty else {
  158. throw TitleOptimizationError.invalidResponse
  159. }
  160. let analysis = TitleAnalysis(
  161. overallScore: clampScore(payload.analysis.overallScore),
  162. lengthScore: clampScore(payload.analysis.lengthScore),
  163. engagementScore: clampScore(payload.analysis.engagementScore),
  164. clarityScore: clampScore(payload.analysis.clarityScore),
  165. suggestions: payload.analysis.suggestions
  166. )
  167. let variants = payload.variants.map {
  168. TitleVariant(
  169. title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
  170. score: clampScore($0.score),
  171. reasoning: $0.reasoning
  172. )
  173. }
  174. return TitleOptimizationResult(analysis: analysis, variants: variants)
  175. }
  176. private func clampScore(_ value: Int) -> Int {
  177. min(100, max(0, value))
  178. }
  179. }
  180. struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
  181. func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
  182. guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
  183. throw TitleOptimizationError.emptySubreddit
  184. }
  185. guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
  186. throw TitleOptimizationError.emptyTopic
  187. }
  188. let hasTitle = !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty
  189. if !hasTitle {
  190. throw TitleOptimizationError.emptyTitle
  191. }
  192. try await Task.sleep(for: .milliseconds(900))
  193. let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
  194. let topic = draft.topic.trimmingCharacters(in: .whitespaces)
  195. let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
  196. let analysis = analyzeTitle(original, goal: draft.titleGoal)
  197. let variants = makeVariants(
  198. original: original,
  199. topic: topic,
  200. subreddit: subreddit,
  201. tone: draft.tone,
  202. goal: draft.titleGoal,
  203. count: draft.variantCount.rawValue
  204. )
  205. return TitleOptimizationResult(analysis: analysis, variants: variants)
  206. }
  207. private func analyzeTitle(_ title: String, goal: TitleGoal) -> TitleAnalysis {
  208. let length = title.count
  209. let lengthScore = scoreLength(length)
  210. let engagementScore = scoreEngagement(title, goal: goal)
  211. let clarityScore = scoreClarity(title)
  212. let overall = (lengthScore + engagementScore + clarityScore) / 3
  213. let suggestions = makeSuggestions(title: title, length: length, goal: goal)
  214. return TitleAnalysis(
  215. overallScore: overall,
  216. lengthScore: lengthScore,
  217. engagementScore: engagementScore,
  218. clarityScore: clarityScore,
  219. suggestions: suggestions
  220. )
  221. }
  222. private func scoreLength(_ length: Int) -> Int {
  223. switch length {
  224. case 0: 0
  225. case 1...50: 95
  226. case 51...80: 85
  227. case 81...120: 70
  228. case 121...200: 50
  229. case 201...300: 30
  230. default: 10
  231. }
  232. }
  233. private func scoreEngagement(_ title: String, goal: TitleGoal) -> Int {
  234. var score = 50
  235. let lower = title.lowercased()
  236. if title.contains("?") { score += 10 }
  237. if title.range(of: #"\d+"#, options: .regularExpression) != nil { score += 8 }
  238. if lower.contains("how") || lower.contains("why") || lower.contains("what") { score += 6 }
  239. if lower.contains("you") || lower.contains("your") { score += 5 }
  240. if title == title.uppercased() && title.count > 5 { score -= 15 }
  241. switch goal {
  242. case .question where !title.contains("?"): score -= 10
  243. case .listicle where title.range(of: #"\d+"#, options: .regularExpression) == nil: score -= 8
  244. case .curiosity where !lower.contains("secret") && !lower.contains("nobody") && !lower.contains("actually"): score -= 3
  245. default: break
  246. }
  247. return min(100, max(0, score))
  248. }
  249. private func scoreClarity(_ title: String) -> Int {
  250. var score = 70
  251. let words = title.split(separator: " ").count
  252. if words >= 4 && words <= 12 { score += 15 }
  253. if words < 3 { score -= 20 }
  254. if words > 20 { score -= 15 }
  255. if title.contains(" ") { score -= 5 }
  256. if title.hasPrefix(" ") || title.hasSuffix(" ") { score -= 10 }
  257. return min(100, max(0, score))
  258. }
  259. private func makeSuggestions(title: String, length: Int, goal: TitleGoal) -> [String] {
  260. var tips: [String] = []
  261. if length > 120 {
  262. tips.append("Shorten to under 80 characters for better mobile visibility.")
  263. } else if length < 20 {
  264. tips.append("Add more context — very short titles can feel vague in feeds.")
  265. }
  266. if !title.contains("?") && goal == .question {
  267. tips.append("Reframe as a question to invite comments.")
  268. }
  269. if title.range(of: #"\d+"#, options: .regularExpression) == nil && goal == .listicle {
  270. tips.append("Include a number (e.g. \"5 tips\") for listicle-style engagement.")
  271. }
  272. let lower = title.lowercased()
  273. if goal == .curiosity && !lower.contains("?") {
  274. tips.append("Add a curiosity hook — hint at a surprising outcome.")
  275. }
  276. if tips.isEmpty {
  277. tips.append("Strong base title — try variants with different hooks below.")
  278. }
  279. return tips
  280. }
  281. private func makeVariants(
  282. original: String,
  283. topic: String,
  284. subreddit: String,
  285. tone: PostTone,
  286. goal: TitleGoal,
  287. count: Int
  288. ) -> [TitleVariant] {
  289. let templates = variantTemplates(
  290. original: original,
  291. topic: topic,
  292. subreddit: subreddit,
  293. tone: tone,
  294. goal: goal
  295. )
  296. return Array(templates.prefix(count)).enumerated().map { index, template in
  297. TitleVariant(
  298. title: template.title,
  299. score: template.score - index * 2,
  300. reasoning: template.reasoning
  301. )
  302. }
  303. }
  304. private func variantTemplates(
  305. original: String,
  306. topic: String,
  307. subreddit: String,
  308. tone: PostTone,
  309. goal: TitleGoal
  310. ) -> [(title: String, score: Int, reasoning: String)] {
  311. let base = original.isEmpty ? topic : original
  312. var results: [(title: String, score: Int, reasoning: String)] = []
  313. switch goal {
  314. case .curiosity:
  315. results = [
  316. ("The \(topic) trick nobody on r/\(subreddit) talks about", 92, "Curiosity gap with community reference"),
  317. ("I tried \(base) for 30 days — here's what actually happened", 89, "Personal experiment hook"),
  318. ("Why \(topic) is quietly changing everything (and most people miss it)", 87, "Insider knowledge angle"),
  319. ("\(base) — am I the only one who noticed this?", 84, "Relatability + intrigue"),
  320. ("What r/\(subreddit) taught me about \(topic) that I wish I knew sooner", 82, "Community wisdom framing"),
  321. ("The uncomfortable truth about \(topic)", 80, "Bold curiosity opener"),
  322. ("\(base): a deep dive you didn't know you needed", 78, "Value promise"),
  323. ("This \(topic) approach changed my mind completely", 76, "Transformation narrative"),
  324. ]
  325. case .question:
  326. results = [
  327. ("What's your experience with \(topic)?", 91, "Direct community question"),
  328. ("Am I wrong about \(base)?", 88, "Invites debate and replies"),
  329. ("How do you approach \(topic) on r/\(subreddit)?", 86, "Subreddit-specific ask"),
  330. ("Is \(topic) actually worth it in 2026?", 84, "Timely relevance question"),
  331. ("Anyone else struggling with \(base)?", 82, "Shared struggle hook"),
  332. ("What's the best \(topic) advice you've ever received?", 80, "Experience-sharing prompt"),
  333. ("\(base) — what am I missing here?", 78, "Humble knowledge seeker"),
  334. ("Hot take or common knowledge: \(topic)?", 76, "Opinion solicitation"),
  335. ]
  336. case .listicle:
  337. results = [
  338. ("7 \(topic) tips that actually work on r/\(subreddit)", 93, "Numbered list with community anchor"),
  339. ("5 mistakes everyone makes with \(base)", 90, "Problem-awareness listicle"),
  340. ("3 reasons \(topic) is underrated right now", 87, "Concise numbered hook"),
  341. ("10 things I learned about \(topic) the hard way", 85, "Lessons-learned format"),
  342. ("The top 5 \(topic) resources you need in 2026", 83, "Resource roundup angle"),
  343. ("\(base): 4 key takeaways from my experience", 81, "Summary-style list"),
  344. ("6 signs you're ready for \(topic)", 79, "Checklist engagement"),
  345. ("8 \(topic) hacks r/\(subreddit) swears by", 77, "Community-endorsed list"),
  346. ]
  347. case .emotional:
  348. results = [
  349. ("\(base) hit me harder than I expected", 91, "Emotional vulnerability"),
  350. ("I wasn't ready for what \(topic) taught me", 88, "Personal revelation"),
  351. ("This \(topic) moment changed everything for me", 86, "Transformation story"),
  352. ("Honestly, \(base) has been on my mind lately", 84, "Authentic confession"),
  353. ("The \(topic) journey nobody prepares you for", 82, "Empathy-driven framing"),
  354. ("Why \(base) still keeps me up at night", 80, "Emotional intensity"),
  355. ("\(topic) — a love letter to r/\(subreddit)", 78, "Community appreciation"),
  356. ("I finally understand \(base) and it's bittersweet", 76, "Reflective tone"),
  357. ]
  358. case .direct:
  359. results = [
  360. ("\(base) — a complete guide for r/\(subreddit)", 92, "Clear value proposition"),
  361. ("Everything you need to know about \(topic)", 89, "Comprehensive promise"),
  362. ("\(topic): what works, what doesn't, and why", 87, "Balanced direct framing"),
  363. ("My honest take on \(base)", 85, "Straightforward opinion"),
  364. ("\(topic) explained simply", 83, "Accessibility focus"),
  365. ("The definitive \(base) breakdown", 81, "Authority positioning"),
  366. ("\(topic) for beginners — start here", 79, "Audience targeting"),
  367. ("\(base): facts, not hype", 77, "No-nonsense angle"),
  368. ]
  369. case .provocative:
  370. results = [
  371. ("Unpopular opinion: \(base) is overrated", 93, "Classic debate starter"),
  372. ("\(topic) is a scam and here's why", 90, "Strong contrarian hook"),
  373. ("Hot take: r/\(subreddit) is wrong about \(topic)", 87, "Community challenge"),
  374. ("Stop doing \(base) — seriously", 85, "Command attention"),
  375. ("\(topic) culture has gone too far", 83, "Cultural critique"),
  376. ("I'll say it: \(base) isn't that impressive", 81, "Mild controversy"),
  377. ("Why everyone is sleeping on the real \(topic) issue", 79, "Contrarian insight"),
  378. ("\(base) — fight me", 77, "Maximum engagement bait"),
  379. ]
  380. }
  381. if tone == .humorous {
  382. results = results.map { variant in
  383. (title: variant.title + " 😅", score: variant.score - 3, reasoning: variant.reasoning + " (humorous tone)")
  384. }
  385. } else if tone == .professional {
  386. results = results.map { variant in
  387. (title: "[Analysis] " + variant.title, score: variant.score - 2, reasoning: variant.reasoning + " (professional tone)")
  388. }
  389. }
  390. return results.sorted { $0.score > $1.score }
  391. }
  392. }