Quellcode durchsuchen

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 vor 1 Monat
Ursprung
Commit
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