소스 검색

Fix Title Optimizer AI parsing and improve optimize flow reliability.

Handle flexible OpenAI JSON fields, add clearer validation and premium feedback, and stabilize the optimizer layout for local testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 개월 전
부모
커밋
a5867e37c8

+ 7 - 0
Reddit App/Managers/SubscriptionManager.swift

@@ -259,6 +259,13 @@ final class SubscriptionManager: ObservableObject {
             hasPremium = await hasActiveSubscriptionStatus()
         }
 
+        #if DEBUG
+        // Local Xcode runs should exercise AI tools without a StoreKit purchase.
+        if !hasPremium {
+            hasPremium = true
+        }
+        #endif
+
         hasPremiumAccess = hasPremium
         hasResolvedPremiumStatus = true
     }

+ 14 - 2
Reddit App/Services/CommentGenerationService.swift

@@ -136,6 +136,17 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
             let body: String
             let score: Int
             let reasoning: String
+
+            enum CodingKeys: String, CodingKey {
+                case body, score, reasoning
+            }
+
+            init(from decoder: Decoder) throws {
+                let container = try decoder.container(keyedBy: CodingKeys.self)
+                body = try container.decode(String.self, forKey: .body)
+                score = try container.decode(FlexibleInt.self, forKey: .score).value
+                reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
+            }
         }
 
         struct Payload: Decodable {
@@ -144,11 +155,12 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
         }
 
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
-        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+        guard let content = envelope.choices.first?.message.content,
+              let payloadData = AIJSONDecoding.payloadData(from: content) else {
             throw CommentGenerationError.invalidResponse
         }
 
-        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        let payload = try JSONDecoder().decode(Payload.self, from: payloadData)
         let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
         let variants = payload.variants.map {
             CommentVariant(

+ 32 - 2
Reddit App/Services/TitleOptimizationService.swift

@@ -127,12 +127,36 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
             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 {
@@ -141,11 +165,17 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         }
 
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
-        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+        guard let content = envelope.choices.first?.message.content,
+              let payloadData = AIJSONDecoding.payloadData(from: content) else {
             throw TitleOptimizationError.invalidResponse
         }
 
-        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        let payload: Payload
+        do {
+            payload = try JSONDecoder().decode(Payload.self, from: payloadData)
+        } catch {
+            throw TitleOptimizationError.apiUnavailable("Couldn't parse the AI response. Please try again.")
+        }
         guard !payload.variants.isEmpty else {
             throw TitleOptimizationError.invalidResponse
         }

+ 51 - 0
Reddit App/Utilities/AIJSONDecoding.swift

@@ -0,0 +1,51 @@
+import Foundation
+
+/// Decodes OpenAI JSON fields that may arrive as a string or string array.
+struct FlexibleStringOrArray: Decodable {
+    let value: String
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        if let string = try? container.decode(String.self) {
+            value = string
+        } else if let strings = try? container.decode([String].self) {
+            value = strings.joined(separator: " ")
+        } else {
+            value = ""
+        }
+    }
+}
+
+/// Decodes OpenAI JSON score fields that may arrive as Int, Double, or String.
+struct FlexibleInt: Decodable {
+    let value: Int
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        if let int = try? container.decode(Int.self) {
+            value = int
+        } else if let double = try? container.decode(Double.self) {
+            value = Int(double.rounded())
+        } else if let string = try? container.decode(String.self),
+                  let int = Int(string.trimmingCharacters(in: .whitespaces)) {
+            value = int
+        } else {
+            value = 0
+        }
+    }
+}
+
+enum AIJSONDecoding {
+    static func payloadData(from content: String) -> Data? {
+        var trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        if trimmed.hasPrefix("```") {
+            trimmed = trimmed
+                .replacingOccurrences(of: "```json", with: "")
+                .replacingOccurrences(of: "```", with: "")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+
+        return trimmed.data(using: .utf8)
+    }
+}

+ 24 - 8
Reddit App/ViewModels/TitleOptimizerViewModel.swift

@@ -30,10 +30,21 @@ final class TitleOptimizerViewModel {
     }
 
     var canOptimize: Bool {
-        !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty
-            && !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty
-            && !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty
-            && !isOptimizing
+        !isOptimizing && missingOptimizeFields.isEmpty
+    }
+
+    var missingOptimizeFields: [String] {
+        var missing: [String] = []
+        if draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty {
+            missing.append("subreddit")
+        }
+        if draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
+            missing.append("topic")
+        }
+        if draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty {
+            missing.append("title")
+        }
+        return missing
     }
 
     var activeTitle: String {
@@ -80,23 +91,28 @@ final class TitleOptimizerViewModel {
     }
 
     func optimizeTitles() async {
-        guard canOptimize else { return }
+        let missing = missingOptimizeFields
+        guard missing.isEmpty else {
+            errorMessage = "Fill in \(missing.joined(separator: ", ")) before optimizing."
+            successMessage = nil
+            return
+        }
 
         isOptimizing = true
         errorMessage = nil
         successMessage = nil
+        defer { isOptimizing = false }
 
         do {
             let result = try await optimizationService.optimizeTitles(from: draft)
             analysis = result.analysis
             variants = result.variants
             selectedVariantID = result.variants.first?.id
-            successMessage = "Generated \(result.variants.count) title suggestions."
+            let engine = usesLiveAI ? "OpenAI" : "local AI templates"
+            successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
         } catch {
             errorMessage = error.localizedDescription
         }
-
-        isOptimizing = false
     }
 
     func copyToClipboard() {

+ 37 - 30
Reddit App/Views/TitleOptimizerView.swift

@@ -16,24 +16,32 @@ struct TitleOptimizerView: View {
             header
             messageBanners
 
-            ScrollView {
-                VStack(alignment: .leading, spacing: 12) {
-                    tabBar
-
-                    PostPageBoundary {
-                        switch viewModel.selectedTab {
-                        case .optimize:
-                            optimizeContent
-                        case .compare:
-                            compareContent
-                        case .preview:
-                            previewContent
+            ScrollViewReader { scrollProxy in
+                ScrollView {
+                    VStack(alignment: .leading, spacing: 12) {
+                        tabBar
+
+                        PostPageBoundary {
+                            switch viewModel.selectedTab {
+                            case .optimize:
+                                optimizeContent
+                            case .compare:
+                                compareContent
+                            case .preview:
+                                previewContent
+                            }
                         }
                     }
+                    .padding(.horizontal, Layout.horizontalPadding)
+                    .padding(.top, 12)
+                    .padding(.bottom, 24)
+                }
+                .onChange(of: viewModel.variants.count) { _, count in
+                    guard count > 0, viewModel.selectedTab == .optimize else { return }
+                    withAnimation(.easeInOut(duration: 0.25)) {
+                        scrollProxy.scrollTo("title-suggestions", anchor: .top)
+                    }
                 }
-                .padding(.horizontal, Layout.horizontalPadding)
-                .padding(.top, 12)
-                .padding(.bottom, 24)
             }
         }
         .background(AppTheme.background)
@@ -100,7 +108,10 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
             Button {
-                guard requirePremiumAccess() else { return }
+                guard requirePremiumAccess() else {
+                    viewModel.errorMessage = "Premium is required to generate AI title suggestions."
+                    return
+                }
                 Task { await viewModel.optimizeTitles() }
             } label: {
                 HStack(spacing: 6) {
@@ -122,7 +133,7 @@ struct TitleOptimizerView: View {
                 .clipShape(RoundedRectangle(cornerRadius: 8))
             }
             .buttonStyle(AppPrimaryButtonStyle())
-            .disabled(!viewModel.canOptimize)
+            .disabled(viewModel.isOptimizing)
         }
     }
 
@@ -165,18 +176,7 @@ struct TitleOptimizerView: View {
     // MARK: - Optimize
 
     private var optimizeContent: some View {
-        ViewThatFits(in: .horizontal) {
-            optimizeColumns
-
-            VStack(alignment: .leading, spacing: Layout.columnSpacing) {
-                optimizeColumn(title: "Configuration", icon: "slider.horizontal.3") {
-                    configurationPanel
-                }
-                optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
-                    editorPanel
-                }
-            }
-        }
+        optimizeColumns
     }
 
     private var optimizeColumns: some View {
@@ -185,6 +185,7 @@ struct TitleOptimizerView: View {
                 configurationPanel
             }
             .frame(width: Layout.columnWidth)
+            .layoutPriority(1)
 
             Rectangle()
                 .fill(AppTheme.border)
@@ -194,7 +195,8 @@ struct TitleOptimizerView: View {
             optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
                 editorPanel
             }
-            .frame(maxWidth: .infinity)
+            .frame(minWidth: 0, maxWidth: .infinity)
+            .layoutPriority(0)
         }
     }
 
@@ -318,6 +320,7 @@ struct TitleOptimizerView: View {
             }
 
             suggestionsSection
+                .id("title-suggestions")
         }
     }
 
@@ -467,6 +470,8 @@ struct TitleOptimizerView: View {
             metadataDivider
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataDivider
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
+            metadataDivider
             metadataRow(label: "Active", value: viewModel.activeTitle)
         }
         .padding(14)
@@ -538,6 +543,8 @@ struct TitleOptimizerView: View {
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataDivider
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
+            metadataDivider
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
 
             if let score = viewModel.analysis?.overallScore {
                 metadataDivider