diff --git a/Modules/Sources/Networking/Remote/AIRequestPrompts.swift b/Modules/Sources/Networking/Remote/AIRequestPrompts.swift new file mode 100644 index 00000000000..ef812eddf93 --- /dev/null +++ b/Modules/Sources/Networking/Remote/AIRequestPrompts.swift @@ -0,0 +1,55 @@ + +struct AIRequestPrompts { + // Prompt instructions + static let identifyLanguage = [ + "What is the ISO language code of the language used in the below text?", + "Do not include any explanations and only provide the ISO language code in your response.", + "Text: ```%@" + ].joined(separator: "\n") + + static let inputComponents = [ + "You are a WooCommerce SEO and marketing expert, perform in-depth research about the product " + + "using the provided name, keywords, and tone, and give your response in the below JSON format.", + "keywords: ```%@```", + "tone: ```%@```" + ].joined(separator: "\n") + + static let jsonFormatInstructions = + "Your response should be in JSON format and don't send anything extra. " + + "Don't include the word JSON in your response:" + + "\n%@" + + // Product Generation + static let productNameTemplate = "name: ```%@```" + + // Tags + static let defaultTagsPrompt = "Suggest an array of the best matching tags for this product." + static let existingTagsPrompt = + "Given the list of available tags ```%@```, " + + "suggest an array of the best matching tags for this product. You can suggest new tags as well." + + // Categories + static let defaultCategoriesPrompt = "Suggest an array of the best matching categories for this product." + static let existingCategoriesPrompt = + "Given the list of available categories ```%@```, " + + "suggest an array of the best matching categories for this product. You can suggest new categories as well." + + // Shipping + static let weightPrompt = "Guess and provide only the number in %@" + static let lengthPrompt = "Guess and provide only the number in %@" + static let widthPrompt = "Guess and provide only the number in %@" + static let heightPrompt = "Guess and provide only the number in %@" + + //JSON Response Format + static let namesFormat = "An array of strings, containing three different names of the product, written in the language with ISO code ```%@```" + static let descriptionsFormat = + "An array of strings, each containing three different product descriptions of around 100 words long each in a ```%@``` tone, " + + "written in the language with ISO code ```%@```" + static let shortDescriptionsFormat = + "An array of strings, each containing three different short descriptions of the product in a ```%@``` tone, " + + "written in the language with ISO code ```%@```" + static let virtualFormat = "A boolean value that shows whether the product is virtual or physical" + static let priceFormat = + "Guess the price in %@, do not include the currency symbol, " + + "only provide the price as a number" +} diff --git a/Modules/Sources/Networking/Remote/GenerativeContentRemote.swift b/Modules/Sources/Networking/Remote/GenerativeContentRemote.swift index fff77e64224..5ec7e2b1e64 100644 --- a/Modules/Sources/Networking/Remote/GenerativeContentRemote.swift +++ b/Modules/Sources/Networking/Remote/GenerativeContentRemote.swift @@ -189,11 +189,7 @@ private extension GenerativeContentRemote { string: String, feature: GenerativeContentRemoteFeature, token: JWToken) async throws -> String { - let prompt = [ - "What is the ISO language code of the language used in the below text?" + - "Do not include any explanations and only provide the ISO language code in your response.", - "Text: ```\(string)```" - ].joined(separator: "\n") + let prompt = String(format: AIRequestPrompts.identifyLanguage, string) let parameters: [String: Any] = [ParameterKey.token: token.token, ParameterKey.question: prompt, ParameterKey.stream: ParameterValue.stream, @@ -219,17 +215,12 @@ private extension GenerativeContentRemote { categories: [ProductCategory], tags: [ProductTag], token: JWToken) async throws -> AIProduct { - var inputComponents = [ - "You are a WooCommerce SEO and marketing expert, perform in-depth research about the product " + - "using the provided name, keywords and tone, and give your response in the below JSON format.", - "keywords: ```\(keywords)```", - "tone: ```\(tone)```", - ] + var inputComponents = [String(format: AIRequestPrompts.inputComponents, keywords, tone)] // Name will be added only if `productName` is available. // TODO: this code related to `productName` can be removed after releasing the new product creation with AI flow. Github issue: 13108 if let productName = productName, !productName.isEmpty { - inputComponents.insert("name: ```\(productName)```", at: 1) + inputComponents.insert(String(format: AIRequestPrompts.productNameTemplate, productName), at: 1) } let input = inputComponents.joined(separator: "\n") @@ -237,55 +228,47 @@ private extension GenerativeContentRemote { let jsonResponseFormatDict: [String: Any] = { let tagsPrompt: String = { guard !tags.isEmpty else { - return "Suggest an array of the best matching tags for this product." + return AIRequestPrompts.defaultTagsPrompt } - return "Given the list of available tags ```\(tags.map { $0.name }.joined(separator: ", "))```, " + - "suggest an array of the best matching tags for this product. You can suggest new tags as well." + return String(format: AIRequestPrompts.existingTagsPrompt, tags.map { $0.name }.joined(separator: ", ")) }() let categoriesPrompt: String = { guard !categories.isEmpty else { - return "Suggest an array of the best matching categories for this product." + return AIRequestPrompts.defaultCategoriesPrompt } - return "Given the list of available categories ```\(categories.map { $0.name }.joined(separator: ", "))```, " + - "suggest an array of the best matching categories for this product. You can suggest new categories as well." + return String(format: AIRequestPrompts.existingCategoriesPrompt, categories.map { $0.name }.joined(separator: ", ")) }() let shippingPrompt = { var dict = [String: String]() if let weightUnit { - dict["weight"] = "Guess and provide only the number in \(weightUnit)" + dict["weight"] = String(format: AIRequestPrompts.weightPrompt, weightUnit) } if let dimensionUnit { - dict["length"] = "Guess and provide only the number in \(dimensionUnit)" - dict["width"] = "Guess and provide only the number in \(dimensionUnit)" - dict["height"] = "Guess and provide only the number in \(dimensionUnit)" + dict["length"] = String(format: AIRequestPrompts.lengthPrompt, dimensionUnit) + dict["width"] = String(format: AIRequestPrompts.widthPrompt, dimensionUnit) + dict["height"] = String(format: AIRequestPrompts.heightPrompt, dimensionUnit) } return dict }() // swiftlint:disable line_length - return ["names": "An array of strings, containing three different names of the product, written in the language with ISO code ```\(language)```", - "descriptions": "An array of strings, each containing three different product descriptions of around 100 words long each in a ```\(tone)``` tone, " - + "written in the language with ISO code ```\(language)```", - "short_descriptions": "An array of strings, each containing three different short descriptions of the product in a ```\(tone)``` tone, " - + "written in the language with ISO code ```\(language)```", - "virtual": "A boolean value that shows whether the product is virtual or physical", + return ["names": String(format: AIRequestPrompts.namesFormat, language), + "descriptions": String(format: AIRequestPrompts.descriptionsFormat, tone, language), + "short_descriptions": String(format: AIRequestPrompts.shortDescriptionsFormat, tone, language), + "virtual": AIRequestPrompts.virtualFormat, "shipping": shippingPrompt, - "price": "Guess the price in \(currencySymbol), do not include the currency symbol, " - + "only provide the price as a number", + "price": String(format: AIRequestPrompts.priceFormat, currencySymbol), "tags": tagsPrompt, "categories": categoriesPrompt] }() - let expectedJsonFormat = - "Your response should be in JSON format and don't send anything extra. " + - "Don't include the word JSON in your response:" + - "\n" + - (jsonResponseFormatDict.toJSONEncoded() ?? "") + let expectedJsonFormat = String(format: AIRequestPrompts.jsonFormatInstructions, + jsonResponseFormatDict.toJSONEncoded() ?? "") let prompt = input + "\n" + expectedJsonFormat diff --git a/Modules/Tests/NetworkingTests/Remote/AIRequestPromptsTests.swift b/Modules/Tests/NetworkingTests/Remote/AIRequestPromptsTests.swift new file mode 100644 index 00000000000..fa2ec668013 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Remote/AIRequestPromptsTests.swift @@ -0,0 +1,400 @@ +import XCTest +@testable import Networking + +final class AIRequestPromptsTests: XCTestCase { + + // MARK: - Basic Prompt Instructions Tests + + func test_identifyLanguage_formatsCorrectly() { + // Given + let testString = "Hello world, this is a test string" + + // When + let formattedPrompt = String(format: AIRequestPrompts.identifyLanguage, testString) + + // Then + let expectedPrompt = """ + What is the ISO language code of the language used in the below text? + Do not include any explanations and only provide the ISO language code in your response. + Text: ```\(testString) + """ + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_inputComponents_formatsCorrectly() { + // Given + let keywords = "smartphone, technology, mobile" + let tone = "professional" + + // When + let formattedPrompt = String(format: AIRequestPrompts.inputComponents, keywords, tone) + + // Then + // swiftlint:disable line_length + let expectedPrompt = """ + You are a WooCommerce SEO and marketing expert, perform in-depth research about the product using the provided name, keywords, and tone, and give your response in the below JSON format. + keywords: ```\(keywords)``` + tone: ```\(tone)``` + """ + // swiftlint:enable line_length + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_jsonFormatInstructions_formatsCorrectly() { + // Given + let jsonSchema = """ + { + "name": "Product name here", + "description": "Product description here" + } + """ + + // When + let formattedPrompt = String(format: AIRequestPrompts.jsonFormatInstructions, jsonSchema) + + // Then + let expectedPrompt = """ + Your response should be in JSON format and don't send anything extra. Don't include the word JSON in your response: + \(jsonSchema) + """ + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - Product Generation Tests + + func test_productNameTemplate_formatsCorrectly() { + // Given + let productName = "iPhone 15 Pro" + + // When + let formattedPrompt = String(format: AIRequestPrompts.productNameTemplate, productName) + + // Then + let expectedPrompt = "name: ```\(productName)```" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - Tags Tests + + func test_defaultTagsPrompt_returnsCorrectString() { + // When + let prompt = AIRequestPrompts.defaultTagsPrompt + + // Then + let expectedPrompt = "Suggest an array of the best matching tags for this product." + + XCTAssertEqual(prompt, expectedPrompt) + } + + func test_existingTagsPrompt_formatsCorrectly() { + // Given + let existingTags = "electronics, smartphone, apple, premium" + + // When + let formattedPrompt = String(format: AIRequestPrompts.existingTagsPrompt, existingTags) + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "Given the list of available tags ```\(existingTags)```, suggest an array of the best matching tags for this product. You can suggest new tags as well." + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - Categories Tests + + func test_defaultCategoriesPrompt_returnsCorrectString() { + // When + let prompt = AIRequestPrompts.defaultCategoriesPrompt + + // Then + let expectedPrompt = "Suggest an array of the best matching categories for this product." + + XCTAssertEqual(prompt, expectedPrompt) + } + + func test_existingCategoriesPrompt_formatsCorrectly() { + // Given + let existingCategories = "Electronics, Mobile Phones, Accessories" + + // When + let formattedPrompt = String(format: AIRequestPrompts.existingCategoriesPrompt, existingCategories) + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "Given the list of available categories ```\(existingCategories)```, suggest an array of the best matching categories for this product. You can suggest new categories as well." + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - Shipping Tests + + func test_weightPrompt_formatsCorrectly() { + // Given + let weightUnit = "kg" + + // When + let formattedPrompt = String(format: AIRequestPrompts.weightPrompt, weightUnit) + + // Then + let expectedPrompt = "Guess and provide only the number in \(weightUnit)" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_lengthPrompt_formatsCorrectly() { + // Given + let dimensionUnit = "cm" + + // When + let formattedPrompt = String(format: AIRequestPrompts.lengthPrompt, dimensionUnit) + + // Then + let expectedPrompt = "Guess and provide only the number in \(dimensionUnit)" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_widthPrompt_formatsCorrectly() { + // Given + let dimensionUnit = "cm" + + // When + let formattedPrompt = String(format: AIRequestPrompts.widthPrompt, dimensionUnit) + + // Then + let expectedPrompt = "Guess and provide only the number in \(dimensionUnit)" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_heightPrompt_formatsCorrectly() { + // Given + let dimensionUnit = "cm" + + // When + let formattedPrompt = String(format: AIRequestPrompts.heightPrompt, dimensionUnit) + + // Then + let expectedPrompt = "Guess and provide only the number in \(dimensionUnit)" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - JSON Response Format Tests + + func test_namesFormat_formatsCorrectly() { + // Given + let language = "en" + + // When + let formattedPrompt = String(format: AIRequestPrompts.namesFormat, language) + + // Then + let expectedPrompt = "An array of strings, containing three different names of the product, written in the language with ISO code ```\(language)```" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_descriptionsFormat_formatsCorrectly() { + // Given + let tone = "professional" + let language = "en" + + // When + let formattedPrompt = String(format: AIRequestPrompts.descriptionsFormat, tone, language) + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "An array of strings, each containing three different product descriptions of around 100 words long each in a ```\(tone)``` tone, written in the language with ISO code ```\(language)```" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_shortDescriptionsFormat_formatsCorrectly() { + // Given + let tone = "casual" + let language = "es" + + // When + let formattedPrompt = String(format: AIRequestPrompts.shortDescriptionsFormat, tone, language) + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "An array of strings, each containing three different short descriptions of the product in a ```\(tone)``` tone, written in the language with ISO code ```\(language)```" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + func test_virtualFormat_returnsCorrectString() { + // When + let prompt = AIRequestPrompts.virtualFormat + + // Then + let expectedPrompt = "A boolean value that shows whether the product is virtual or physical" + + XCTAssertEqual(prompt, expectedPrompt) + } + + func test_priceFormat_formatsCorrectly() { + // Given + let currencySymbol = "$" + + // When + let formattedPrompt = String(format: AIRequestPrompts.priceFormat, currencySymbol) + + // Then + let expectedPrompt = "Guess the price in \(currencySymbol), do not include the currency symbol, only provide the price as a number" + + XCTAssertEqual(formattedPrompt, expectedPrompt) + } + + // MARK: - Integration Tests (as used in GenerativeContentRemote) + + func test_generateAIProduct_tagsPromptIntegration_withEmptyTags() { + // Given + let tags: [String] = [] + + // When + let tagsPrompt: String = { + guard !tags.isEmpty else { + return AIRequestPrompts.defaultTagsPrompt + } + return String(format: AIRequestPrompts.existingTagsPrompt, tags.joined(separator: ", ")) + }() + + // Then + XCTAssertEqual(tagsPrompt, "Suggest an array of the best matching tags for this product.") + } + + func test_generateAIProduct_tagsPromptIntegration_withExistingTags() { + // Given + let tags = ["electronics", "smartphone", "premium"] + + // When + let tagsPrompt: String = { + guard !tags.isEmpty else { + return AIRequestPrompts.defaultTagsPrompt + } + return String(format: AIRequestPrompts.existingTagsPrompt, tags.joined(separator: ", ")) + }() + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "Given the list of available tags ```electronics, smartphone, premium```, suggest an array of the best matching tags for this product. You can suggest new tags as well." + XCTAssertEqual(tagsPrompt, expectedPrompt) + } + + func test_generateAIProduct_categoriesPromptIntegration_withEmptyCategories() { + // Given + let categories: [String] = [] + + // When + let categoriesPrompt: String = { + guard !categories.isEmpty else { + return AIRequestPrompts.defaultCategoriesPrompt + } + return String(format: AIRequestPrompts.existingCategoriesPrompt, categories.joined(separator: ", ")) + }() + + // Then + XCTAssertEqual(categoriesPrompt, "Suggest an array of the best matching categories for this product.") + } + + func test_generateAIProduct_categoriesPromptIntegration_withExistingCategories() { + // Given + let categories = ["Electronics", "Mobile Phones", "Apple"] + + // When + let categoriesPrompt: String = { + guard !categories.isEmpty else { + return AIRequestPrompts.defaultCategoriesPrompt + } + return String(format: AIRequestPrompts.existingCategoriesPrompt, categories.joined(separator: ", ")) + }() + + // Then + // swiftlint:disable:next line_length + let expectedPrompt = "Given the list of available categories ```Electronics, Mobile Phones, Apple```, suggest an array of the best matching categories for this product. You can suggest new categories as well." + XCTAssertEqual(categoriesPrompt, expectedPrompt) + } + + func test_generateAIProduct_shippingPromptIntegration() { + // Given + let weightUnit: String? = "kg" + let dimensionUnit: String? = "cm" + + // When + let shippingPrompt = { + var dict = [String: String]() + if let weightUnit = weightUnit { + dict["weight"] = String(format: AIRequestPrompts.weightPrompt, weightUnit) + } + + if let dimensionUnit = dimensionUnit { + dict["length"] = String(format: AIRequestPrompts.lengthPrompt, dimensionUnit) + dict["width"] = String(format: AIRequestPrompts.widthPrompt, dimensionUnit) + dict["height"] = String(format: AIRequestPrompts.heightPrompt, dimensionUnit) + } + return dict + }() + + // Then + let expectedDict = [ + "weight": "Guess and provide only the number in kg", + "length": "Guess and provide only the number in cm", + "width": "Guess and provide only the number in cm", + "height": "Guess and provide only the number in cm" + ] + + XCTAssertEqual(shippingPrompt, expectedDict) + } + + func test_generateAIProduct_fullJSONResponseFormatDictIntegration() { + // Given + let language = "en" + let tone = "professional" + let currencySymbol = "$" + let tagsPrompt = AIRequestPrompts.defaultTagsPrompt + let categoriesPrompt = AIRequestPrompts.defaultCategoriesPrompt + let shippingPrompt = [ + "weight": String(format: AIRequestPrompts.weightPrompt, "kg"), + "length": String(format: AIRequestPrompts.lengthPrompt, "cm") + ] + + // When + let jsonResponseFormatDict: [String: Any] = [ + "names": String(format: AIRequestPrompts.namesFormat, language), + "descriptions": String(format: AIRequestPrompts.descriptionsFormat, tone, language), + "short_descriptions": String(format: AIRequestPrompts.shortDescriptionsFormat, tone, language), + "virtual": AIRequestPrompts.virtualFormat, + "shipping": shippingPrompt, + "price": String(format: AIRequestPrompts.priceFormat, currencySymbol), + "tags": tagsPrompt, + "categories": categoriesPrompt + ] + + // Then + // swiftlint:disable:next line_length + XCTAssertEqual(jsonResponseFormatDict["names"] as? String, "An array of strings, containing three different names of the product, written in the language with ISO code ```en```") + // swiftlint:disable:next line_length + XCTAssertEqual(jsonResponseFormatDict["descriptions"] as? String, "An array of strings, each containing three different product descriptions of around 100 words long each in a ```professional``` tone, written in the language with ISO code ```en```") + // swiftlint:disable:next line_length + XCTAssertEqual(jsonResponseFormatDict["short_descriptions"] as? String, "An array of strings, each containing three different short descriptions of the product in a ```professional``` tone, written in the language with ISO code ```en```") + XCTAssertEqual(jsonResponseFormatDict["virtual"] as? String, "A boolean value that shows whether the product is virtual or physical") + XCTAssertEqual(jsonResponseFormatDict["price"] as? String, "Guess the price in $, do not include the currency symbol, only provide the price as a number") + XCTAssertEqual(jsonResponseFormatDict["tags"] as? String, "Suggest an array of the best matching tags for this product.") + XCTAssertEqual(jsonResponseFormatDict["categories"] as? String, "Suggest an array of the best matching categories for this product.") + + // Verify shipping dictionary + let actualShippingDict = jsonResponseFormatDict["shipping"] as? [String: String] + let expectedShippingDict = [ + "weight": "Guess and provide only the number in kg", + "length": "Guess and provide only the number in cm" + ] + XCTAssertEqual(actualShippingDict, expectedShippingDict) + } +}