Просмотр исходного кода

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 месяц назад
Родитель
Сommit
a5867e37c8

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

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

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

@@ -136,6 +136,17 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
             let body: String
             let body: String
             let score: Int
             let score: Int
             let reasoning: String
             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 {
         struct Payload: Decodable {
@@ -144,11 +155,12 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
         }
         }
 
 
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
         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
             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 body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
         let variants = payload.variants.map {
         let variants = payload.variants.map {
             CommentVariant(
             CommentVariant(

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

@@ -127,12 +127,36 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
             let engagementScore: Int
             let engagementScore: Int
             let clarityScore: Int
             let clarityScore: Int
             let suggestions: [String]
             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
             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 {
         struct Payload: Decodable {
@@ -141,11 +165,17 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         }
         }
 
 
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
         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
             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 {
         guard !payload.variants.isEmpty else {
             throw TitleOptimizationError.invalidResponse
             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 {
     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 {
     var activeTitle: String {
@@ -80,23 +91,28 @@ final class TitleOptimizerViewModel {
     }
     }
 
 
     func optimizeTitles() async {
     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
         isOptimizing = true
         errorMessage = nil
         errorMessage = nil
         successMessage = nil
         successMessage = nil
+        defer { isOptimizing = false }
 
 
         do {
         do {
             let result = try await optimizationService.optimizeTitles(from: draft)
             let result = try await optimizationService.optimizeTitles(from: draft)
             analysis = result.analysis
             analysis = result.analysis
             variants = result.variants
             variants = result.variants
             selectedVariantID = result.variants.first?.id
             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 {
         } catch {
             errorMessage = error.localizedDescription
             errorMessage = error.localizedDescription
         }
         }
-
-        isOptimizing = false
     }
     }
 
 
     func copyToClipboard() {
     func copyToClipboard() {

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

@@ -16,24 +16,32 @@ struct TitleOptimizerView: View {
             header
             header
             messageBanners
             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)
         .background(AppTheme.background)
@@ -100,7 +108,10 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
 
             Button {
             Button {
-                guard requirePremiumAccess() else { return }
+                guard requirePremiumAccess() else {
+                    viewModel.errorMessage = "Premium is required to generate AI title suggestions."
+                    return
+                }
                 Task { await viewModel.optimizeTitles() }
                 Task { await viewModel.optimizeTitles() }
             } label: {
             } label: {
                 HStack(spacing: 6) {
                 HStack(spacing: 6) {
@@ -122,7 +133,7 @@ struct TitleOptimizerView: View {
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .clipShape(RoundedRectangle(cornerRadius: 8))
             }
             }
             .buttonStyle(AppPrimaryButtonStyle())
             .buttonStyle(AppPrimaryButtonStyle())
-            .disabled(!viewModel.canOptimize)
+            .disabled(viewModel.isOptimizing)
         }
         }
     }
     }
 
 
@@ -165,18 +176,7 @@ struct TitleOptimizerView: View {
     // MARK: - Optimize
     // MARK: - Optimize
 
 
     private var optimizeContent: some View {
     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 {
     private var optimizeColumns: some View {
@@ -185,6 +185,7 @@ struct TitleOptimizerView: View {
                 configurationPanel
                 configurationPanel
             }
             }
             .frame(width: Layout.columnWidth)
             .frame(width: Layout.columnWidth)
+            .layoutPriority(1)
 
 
             Rectangle()
             Rectangle()
                 .fill(AppTheme.border)
                 .fill(AppTheme.border)
@@ -194,7 +195,8 @@ struct TitleOptimizerView: View {
             optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
             optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
                 editorPanel
                 editorPanel
             }
             }
-            .frame(maxWidth: .infinity)
+            .frame(minWidth: 0, maxWidth: .infinity)
+            .layoutPriority(0)
         }
         }
     }
     }
 
 
@@ -318,6 +320,7 @@ struct TitleOptimizerView: View {
             }
             }
 
 
             suggestionsSection
             suggestionsSection
+                .id("title-suggestions")
         }
         }
     }
     }
 
 
@@ -467,6 +470,8 @@ struct TitleOptimizerView: View {
             metadataDivider
             metadataDivider
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataDivider
             metadataDivider
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
+            metadataDivider
             metadataRow(label: "Active", value: viewModel.activeTitle)
             metadataRow(label: "Active", value: viewModel.activeTitle)
         }
         }
         .padding(14)
         .padding(14)
@@ -538,6 +543,8 @@ struct TitleOptimizerView: View {
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
             metadataDivider
             metadataDivider
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
+            metadataDivider
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
 
 
             if let score = viewModel.analysis?.overallScore {
             if let score = viewModel.analysis?.overallScore {
                 metadataDivider
                 metadataDivider