|
|
@@ -4,17 +4,173 @@ protocol TitleOptimizationServiceProtocol: Sendable {
|
|
|
func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult
|
|
|
}
|
|
|
|
|
|
+enum TitleOptimizationServiceFactory {
|
|
|
+ static func makeDefault() -> any TitleOptimizationServiceProtocol {
|
|
|
+ if 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): message
|
|
|
+ case .invalidResponse: "The AI returned an unexpected response. Please try again."
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 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.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, response) = try await URLSession.shared.data(for: request)
|
|
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
+ throw TitleOptimizationError.apiUnavailable("Couldn't reach the AI service.")
|
|
|
+ }
|
|
|
+
|
|
|
+ if http.statusCode == 401 {
|
|
|
+ throw TitleOptimizationError.apiUnavailable("Invalid OpenAI API key.")
|
|
|
+ }
|
|
|
+ guard (200 ... 299).contains(http.statusCode) else {
|
|
|
+ let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
|
|
|
+ throw TitleOptimizationError.apiUnavailable("AI request failed: \(message)")
|
|
|
+ }
|
|
|
+
|
|
|
+ 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]
|
|
|
+ }
|
|
|
+
|
|
|
+ struct VariantPayload: Decodable {
|
|
|
+ let title: String
|
|
|
+ let score: Int
|
|
|
+ let reasoning: String
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.data(using: .utf8) else {
|
|
|
+ throw TitleOptimizationError.invalidResponse
|
|
|
}
|
|
|
+
|
|
|
+ let payload = try JSONDecoder().decode(Payload.self, from: content)
|
|
|
+ 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))
|
|
|
}
|
|
|
}
|
|
|
|