| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- import Foundation
- protocol TitleOptimizationServiceProtocol: Sendable {
- func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult
- }
- enum TitleOptimizationServiceFactory {
- static func make(usesLiveAI: Bool) -> any TitleOptimizationServiceProtocol {
- if usesLiveAI, 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 = AIRequestConfiguration.model
- 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 = AIRequestConfiguration.requestTimeout
- request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let variantCount = draft.variantCount.rawValue
- let body: [String: Any] = [
- "model": model,
- "temperature": 0.6,
- "max_tokens": AIRequestConfiguration.titleOptimizerMaxTokens(variantCount: variantCount),
- "response_format": ["type": "json_object"],
- "messages": [
- [
- "role": "system",
- "content": """
- Optimize Reddit titles. JSON only: {"variants":[{"title":"...","score":85}]}.
- Return exactly \(variantCount) variants. Titles under 300 chars. Match subreddit, goal, and tone.
- """,
- ],
- ["role": "user", "content": prompt],
- ],
- ]
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await NetworkClient.aiData(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, draft: draft)
- }
- 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, draft: TitleDraft) throws -> TitleOptimizationResult {
- struct APIEnvelope: Decodable {
- struct Choice: Decodable {
- struct Message: Decodable {
- let content: String
- }
- let message: Message
- }
- let choices: [Choice]
- }
- struct VariantPayload: Decodable {
- let title: String
- let score: Int
- enum CodingKeys: String, CodingKey {
- case title, score
- }
- 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
- }
- }
- struct Payload: Decodable {
- 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 original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
- let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal)
- let defaultReasoning = "\(draft.titleGoal.title) hook"
- let variants = payload.variants.map {
- TitleVariant(
- title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
- score: clampScore($0.score),
- reasoning: defaultReasoning
- )
- }
- 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
- }
- let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
- let topic = draft.topic.trimmingCharacters(in: .whitespaces)
- let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
- let analysis = TitleAnalysisEngine.analyze(title: 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 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 }
- }
- }
|