|
@@ -37,7 +37,7 @@ enum TitleOptimizationError: LocalizedError {
|
|
|
|
|
|
|
|
struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
let apiKey: String
|
|
let apiKey: String
|
|
|
- var model: String = "gpt-4o-mini"
|
|
|
|
|
|
|
+ var model: String = AIRequestConfiguration.model
|
|
|
|
|
|
|
|
func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
|
|
func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
|
|
|
guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
|
|
guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
|
|
@@ -55,23 +55,22 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
|
|
|
|
|
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
|
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
|
|
request.httpMethod = "POST"
|
|
request.httpMethod = "POST"
|
|
|
- request.timeoutInterval = 60
|
|
|
|
|
|
|
+ request.timeoutInterval = AIRequestConfiguration.requestTimeout
|
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
|
|
|
|
|
+ let variantCount = draft.variantCount.rawValue
|
|
|
let body: [String: Any] = [
|
|
let body: [String: Any] = [
|
|
|
"model": model,
|
|
"model": model,
|
|
|
- "temperature": 0.85,
|
|
|
|
|
|
|
+ "temperature": 0.6,
|
|
|
|
|
+ "max_tokens": AIRequestConfiguration.titleOptimizerMaxTokens(variantCount: variantCount),
|
|
|
"response_format": ["type": "json_object"],
|
|
"response_format": ["type": "json_object"],
|
|
|
"messages": [
|
|
"messages": [
|
|
|
[
|
|
[
|
|
|
"role": "system",
|
|
"role": "system",
|
|
|
"content": """
|
|
"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.
|
|
|
|
|
|
|
+ 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],
|
|
["role": "user", "content": prompt],
|
|
@@ -83,7 +82,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
let response: URLResponse
|
|
let response: URLResponse
|
|
|
|
|
|
|
|
do {
|
|
do {
|
|
|
- (data, response) = try await NetworkClient.data(for: request)
|
|
|
|
|
|
|
+ (data, response) = try await NetworkClient.aiData(for: request)
|
|
|
} catch let error as URLError {
|
|
} catch let error as URLError {
|
|
|
throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
|
|
throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
|
|
|
}
|
|
}
|
|
@@ -94,7 +93,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return try parseResponse(data)
|
|
|
|
|
|
|
+ return try parseResponse(data, draft: draft)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
|
|
private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
|
|
@@ -115,7 +114,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
return lines.joined(separator: "\n")
|
|
return lines.joined(separator: "\n")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func parseResponse(_ data: Data) throws -> TitleOptimizationResult {
|
|
|
|
|
|
|
+ private func parseResponse(_ data: Data, draft: TitleDraft) throws -> TitleOptimizationResult {
|
|
|
struct APIEnvelope: Decodable {
|
|
struct APIEnvelope: Decodable {
|
|
|
struct Choice: Decodable {
|
|
struct Choice: Decodable {
|
|
|
struct Message: Decodable {
|
|
struct Message: Decodable {
|
|
@@ -126,46 +125,22 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
let choices: [Choice]
|
|
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 {
|
|
struct VariantPayload: Decodable {
|
|
|
let title: String
|
|
let title: String
|
|
|
let score: Int
|
|
let score: Int
|
|
|
- let reasoning: String
|
|
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
enum CodingKeys: String, CodingKey {
|
|
|
- case title, score, reasoning
|
|
|
|
|
|
|
+ case title, score
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
init(from decoder: Decoder) throws {
|
|
init(from decoder: Decoder) throws {
|
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
title = try container.decode(String.self, forKey: .title)
|
|
title = try container.decode(String.self, forKey: .title)
|
|
|
score = try container.decode(FlexibleInt.self, forKey: .score).value
|
|
score = try container.decode(FlexibleInt.self, forKey: .score).value
|
|
|
- reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
struct Payload: Decodable {
|
|
struct Payload: Decodable {
|
|
|
- let analysis: AnalysisPayload
|
|
|
|
|
let variants: [VariantPayload]
|
|
let variants: [VariantPayload]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -185,19 +160,15 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
throw TitleOptimizationError.invalidResponse
|
|
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 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 {
|
|
let variants = payload.variants.map {
|
|
|
TitleVariant(
|
|
TitleVariant(
|
|
|
title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
|
|
title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
|
|
|
score: clampScore($0.score),
|
|
score: clampScore($0.score),
|
|
|
- reasoning: $0.reasoning
|
|
|
|
|
|
|
+ reasoning: defaultReasoning
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -223,13 +194,11 @@ struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
throw TitleOptimizationError.emptyTitle
|
|
throw TitleOptimizationError.emptyTitle
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- try await Task.sleep(for: .milliseconds(900))
|
|
|
|
|
-
|
|
|
|
|
let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
|
|
let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
|
|
|
let topic = draft.topic.trimmingCharacters(in: .whitespaces)
|
|
let topic = draft.topic.trimmingCharacters(in: .whitespaces)
|
|
|
let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
|
|
let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
|
|
|
- let analysis = analyzeTitle(original, goal: draft.titleGoal)
|
|
|
|
|
|
|
+ let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal)
|
|
|
let variants = makeVariants(
|
|
let variants = makeVariants(
|
|
|
original: original,
|
|
original: original,
|
|
|
topic: topic,
|
|
topic: topic,
|
|
@@ -242,97 +211,6 @@ struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
|
|
|
return TitleOptimizationResult(analysis: analysis, variants: variants)
|
|
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(
|
|
private func makeVariants(
|
|
|
original: String,
|
|
original: String,
|
|
|
topic: String,
|
|
topic: String,
|