diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..ee8dcd4 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in(__DIR__) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'single_quote' => true, + 'no_trailing_whitespace' => true, + 'no_whitespace_in_blank_line' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + ]) + ->setFinder($finder); diff --git a/Api/RequestInterface.php b/Api/RequestInterface.php index d3e0dda..538b413 100644 --- a/Api/RequestInterface.php +++ b/Api/RequestInterface.php @@ -6,7 +6,7 @@ interface RequestInterface { /** - * Retrieve products id. + * Retrieve product id. * @return int */ public function getId(): int; @@ -16,4 +16,10 @@ public function getId(): int; * @return bool */ public function getOverwrite(): bool; + + /** + * Retrieve store id. + * @return int + */ + public function getStoreId(): int; } diff --git a/Block/Adminhtml/Form/Field/AttributeColumn.php b/Block/Adminhtml/Form/Field/AttributeColumn.php new file mode 100644 index 0000000..2166bb0 --- /dev/null +++ b/Block/Adminhtml/Form/Field/AttributeColumn.php @@ -0,0 +1,68 @@ +attributeOptions)) { + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter('frontend_input', ['text', 'textarea'], 'in') + ->create(); + + $attributes = $this->attributeRepository->getList($searchCriteria); + + foreach ($attributes->getItems() as $attribute) { + $this->attributeOptions[$attribute->getAttributeCode()] = $attribute->getDefaultFrontendLabel() + ?? $attribute->getAttributeCode(); + } + + asort($this->attributeOptions); + } + + return $this->attributeOptions; + } + + public function setInputName(string $value): self + { + return $this->setName($value); + } + + public function setInputId(string $value): self + { + return $this->setId($value); + } + + public function _toHtml(): string + { + if (!$this->getOptions()) { + $this->setOptions($this->getOptions()); + } + + foreach ($this->getOptions() as $code => $label) { + $this->addOption($code, $label); + } + + return parent::_toHtml(); + } +} diff --git a/Block/Adminhtml/Form/Field/ProductAttributes.php b/Block/Adminhtml/Form/Field/ProductAttributes.php new file mode 100644 index 0000000..a03c7cc --- /dev/null +++ b/Block/Adminhtml/Form/Field/ProductAttributes.php @@ -0,0 +1,62 @@ +addColumn('attribute', [ + 'label' => __('Attribute'), + 'renderer' => $this->getAttributeRenderer(), + 'class' => 'required-entry', + ]); + $this->addColumn('prompt', [ + 'label' => __('Prompt'), + 'class' => 'required-entry', + ]); + $this->addColumn('enabled', [ + 'label' => __('Enabled'), + 'class' => 'required-entry', + ]); + + $this->_addAfter = false; + $this->_addButtonLabel = __('Add Attribute'); + } + + protected function _prepareArrayRow(DataObject $row): void + { + $options = []; + $attribute = $row->getData('attribute'); + + if ($attribute !== null) { + $key = 'option_' . $this->getAttributeRenderer()->calcOptionHash($attribute); + $options[$key] = 'selected="selected"'; + } + + $row->setData('option_extra_attrs', $options); + } + + /** + * @throws LocalizedException + */ + private function getAttributeRenderer(): AttributeColumn + { + if ($this->attributeRenderer === null) { + $this->attributeRenderer = $this->getLayout()->createBlock( + AttributeColumn::class, + '', + ['data' => ['is_render_to_js_template' => true]] + ); + } + + return $this->attributeRenderer; + } +} diff --git a/Controller/Adminhtml/Product/MassEnrich.php b/Controller/Adminhtml/Product/MassEnrich.php index 5008cd0..ad8560d 100644 --- a/Controller/Adminhtml/Product/MassEnrich.php +++ b/Controller/Adminhtml/Product/MassEnrich.php @@ -1,12 +1,13 @@ filter->getCollection($this->collectionFactory->create()); $productEnriched = 0; - if($this->config->isEnabled()) { + $storeId = (int)$this->storeManager->getStore()->getId(); + if ($this->config->isEnabled()) { /** @var Product $product */ foreach ($collection->getItems() as $product) { //@TODO: we hit rate limit, change to batching the request - $this->publisher->execute($product->getId(), $this->overwrite); + $this->publisher->execute($product->getId(), $this->overwrite, $storeId); $productEnriched++; } @@ -54,8 +58,7 @@ public function execute(): Redirect __('A total of %1 record(s) are scheduled to get data enriched.', $productEnriched) ); } - } - else { + } else { $this->messageManager->addErrorMessage( __('Data enrichment is disabled. Please enable it in the configuration.') ); diff --git a/Controller/Adminhtml/Product/MassEnrichSafe.php b/Controller/Adminhtml/Product/MassEnrichSafe.php index fcac098..d59d3ec 100644 --- a/Controller/Adminhtml/Product/MassEnrichSafe.php +++ b/Controller/Adminhtml/Product/MassEnrichSafe.php @@ -1,10 +1,9 @@ 'Afrikaans', 'ar' => 'Arabic', 'bg' => 'Bulgarian', 'bn' => 'Bengali', + 'ca' => 'Catalan', 'cs' => 'Czech', 'cy' => 'Welsh', 'da' => 'Danish', + 'de' => 'German', 'el' => 'Greek', 'en' => 'English', 'es' => 'Spanish', + 'et' => 'Estonian', 'fa' => 'Persian', 'fi' => 'Finnish', 'fr' => 'French', + 'gl' => 'Galician', 'he' => 'Hebrew', 'hi' => 'Hindi', 'hr' => 'Croatian', + 'hu' => 'Hungarian', 'id' => 'Indonesian', 'it' => 'Italian', 'ja' => 'Japanese', + 'ka' => 'Georgian', 'ko' => 'Korean', 'lt' => 'Lithuanian', 'lv' => 'Latvian', + 'mk' => 'Macedonian', 'ms' => 'Malay', 'nb' => 'Norwegian', 'nl' => 'Dutch', + 'pl' => 'Polish', 'pt' => 'Portuguese', 'ro' => 'Romanian', 'ru' => 'Russian', + 'sk' => 'Slovak', 'sl' => 'Slovenian', 'sq' => 'Albanian', 'sr' => 'Serbian', + 'sv' => 'Swedish', 'th' => 'Thai', 'tr' => 'Turkish', 'uk' => 'Ukrainian', + 'vi' => 'Vietnamese', 'zh' => 'Chinese', + ]; + public function __construct( private readonly ScopeConfigInterface $scopeConfig - ) {} + ) { + } public function isEnabled(): bool { @@ -71,12 +88,31 @@ public function getApiMaxTokens(): int ); } - public function getProductPrompt(string $attributeCode): mixed + public function getEnrichableAttributes(): array { - $path = 'catalog_ai/product/' . $attributeCode; - return $this->scopeConfig->getValue( - $path + $rows = $this->scopeConfig->getValue( + 'catalog_ai/product/attribute_prompts' ); + + if (!is_array($rows)) { + return []; + } + + $attributes = []; + foreach ($rows as $row) { + if (isset($row['attribute'], $row['prompt'], $row['enabled']) && (int)$row['enabled'] === 1) { + $attributes[$row['attribute']] = $row['prompt']; + } + } + + return $attributes; + } + + public function getProductPrompt(string $attributeCode): ?string + { + $attributes = $this->getEnrichableAttributes(); + + return $attributes[$attributeCode] ?? null; } public function canEnrich(Product $product): bool @@ -84,11 +120,27 @@ public function canEnrich(Product $product): bool return $this->isEnabled() && $this->getApiKey() && $product->isObjectNew(); } - public function getSystemPrompt(): mixed + public function getSystemPrompt(): string { - return $this->scopeConfig->getValue( - self::XML_PATH_OPENAI_API_ADVANCED_SYSTEM_PROMPT + $basePrompt = (string)$this->scopeConfig->getValue( + self::XML_PATH_OPENAI_API_ADVANCED_SYSTEM_PROMPT, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); + + $locale = $this->scopeConfig->getValue( + 'general/locale/code', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + + if ($locale) { + $langCode = substr($locale, 0, 2); + $language = self::LOCALE_LANGUAGE_MAP[$langCode] ?? null; + if ($language && $langCode !== 'en') { + $basePrompt = 'Respond in ' . $language . '. ' . $basePrompt; + } + } + + return $basePrompt; } public function getTemperature(): float diff --git a/Model/Config/Source/OpenAIModel.php b/Model/Config/Source/OpenAIModel.php index f87c65d..1b03bdc 100644 --- a/Model/Config/Source/OpenAIModel.php +++ b/Model/Config/Source/OpenAIModel.php @@ -2,11 +2,11 @@ namespace MageOS\CatalogDataAI\Model\Config\Source; +use Exception; use Magento\Framework\Data\OptionSourceInterface; +use MageOS\CatalogDataAI\Model\Config as ModuleConfig; use OpenAI; use OpenAI\Client as OpenAIClient; -use MageOS\CatalogDataAI\Model\Config as ModuleConfig; -use Exception; class OpenAIModel implements OptionSourceInterface { diff --git a/Model/EnrichmentLog.php b/Model/EnrichmentLog.php new file mode 100644 index 0000000..f120ddd --- /dev/null +++ b/Model/EnrichmentLog.php @@ -0,0 +1,19 @@ +_init(EnrichmentLogResource::class); + } +} diff --git a/Model/Product/Consumer.php b/Model/Product/Consumer.php index d0204e3..23a2c9f 100644 --- a/Model/Product/Consumer.php +++ b/Model/Product/Consumer.php @@ -6,29 +6,25 @@ use Magento\Catalog\Model\ProductRepository; use Magento\Store\Model\StoreManagerInterface; -/** - * Class Consumer - * @package Gaiterjones\RabbitMQ\MessageQueues\Product - */ class Consumer { - /** - * Consumer constructor. - */ public function __construct( - private readonly Enricher $enricher, - private readonly ProductRepository $productRepository, + private readonly Enricher $enricher, + private readonly ProductRepository $productRepository, private readonly StoreManagerInterface $storeManager - ) {} + ) { + } public function execute(Request $request): void { - // @TODO: enrich for all stores if different value or language - $this->storeManager->setCurrentStore(0); - $product = $this->productRepository->getById($request->getId()); + $this->storeManager->setCurrentStore($request->getStoreId()); + $product = $this->productRepository->getById( + $request->getId(), + false, + $request->getStoreId() + ); $product->setData('mageos_catalogai_overwrite', $request->getOverwrite()); $this->enricher->execute($product); $this->productRepository->save($product); } - } diff --git a/Model/Product/Enricher.php b/Model/Product/Enricher.php index 9395a65..fdf8219 100644 --- a/Model/Product/Enricher.php +++ b/Model/Product/Enricher.php @@ -1,4 +1,5 @@ config->getEnrichableAttributes()); } /** @@ -56,10 +53,10 @@ public function parsePrompt(string $prompt, Product $product): string public function enrichAttribute(Product $product, string $attributeCode): void { - if(!$product->getData('mageos_catalogai_overwrite') && $product->getData($attributeCode)){ + if (!$product->getData('mageos_catalogai_overwrite') && $product->getData($attributeCode)) { return; } - if($prompt = $this->config->getProductPrompt($attributeCode)) { + if ($prompt = $this->config->getProductPrompt($attributeCode)) { $prompt = $this->parsePrompt($prompt, $product); @@ -82,8 +79,13 @@ public function enrichAttribute(Product $product, string $attributeCode): void ]); // @TODO: no exception? - if($result = $response->choices[0]) { + if ($result = $response->choices[0]) { $product->setData($attributeCode, $result->message?->content); + $this->enrichmentLogger->log( + (int)$product->getId(), + $attributeCode, + (int)$product->getStoreId() + ); } $this->backoff($response->meta()); } @@ -91,12 +93,12 @@ public function enrichAttribute(Product $product, string $attributeCode): void public function backoff(MetaInformation $meta): void { - if($meta->requestLimit->remaining < 1) { + if ($meta->requestLimit->remaining < 1) { sleep($this->strToSeconds($meta->requestLimit->reset)); } // 1 token ~= 0.75 word // do not use config value - if($meta->tokenLimit->remaining < 1000) { + if ($meta->tokenLimit->remaining < 1000) { sleep($this->strToSeconds($meta->tokenLimit->reset)); } } diff --git a/Model/Product/EnrichmentLogger.php b/Model/Product/EnrichmentLogger.php new file mode 100644 index 0000000..8c5b0dd --- /dev/null +++ b/Model/Product/EnrichmentLogger.php @@ -0,0 +1,79 @@ +find($entityId, $attributeCode, $storeId); + + if ($existing->getId()) { + $existing->setData('status', EnrichmentLog::STATUS_GENERATED); + $this->resource->save($existing); + } else { + $log = $this->logFactory->create(); + $log->setData([ + 'entity_id' => $entityId, + 'attribute_code' => $attributeCode, + 'store_id' => $storeId, + 'status' => EnrichmentLog::STATUS_GENERATED, + ]); + $this->resource->save($log); + } + } + + public function markModified(int $entityId, string $attributeCode, int $storeId): void + { + $existing = $this->find($entityId, $attributeCode, $storeId); + + if ($existing->getId() && $existing->getData('status') !== EnrichmentLog::STATUS_MODIFIED) { + $existing->setData('status', EnrichmentLog::STATUS_MODIFIED); + $this->resource->save($existing); + } + } + + public function getStatus(int $entityId, string $attributeCode, int $storeId): ?string + { + $existing = $this->find($entityId, $attributeCode, $storeId); + + return $existing->getId() ? $existing->getData('status') : null; + } + + public function getStatusesForProduct(int $entityId, int $storeId): array + { + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('entity_id', $entityId); + $collection->addFieldToFilter('store_id', $storeId); + + $statuses = []; + foreach ($collection as $item) { + $statuses[$item->getData('attribute_code')] = $item->getData('status'); + } + + return $statuses; + } + + private function find(int $entityId, string $attributeCode, int $storeId): EnrichmentLog + { + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('entity_id', $entityId); + $collection->addFieldToFilter('attribute_code', $attributeCode); + $collection->addFieldToFilter('store_id', $storeId); + + return $collection->getFirstItem(); + } +} diff --git a/Model/Product/Publisher.php b/Model/Product/Publisher.php index 4beadf8..6ed8093 100644 --- a/Model/Product/Publisher.php +++ b/Model/Product/Publisher.php @@ -1,14 +1,14 @@ requestFactory->create([ 'id' => (int)$productId, - 'overwrite' => $overwrite + 'overwrite' => $overwrite, + 'storeId' => $storeId, ]); $this->publisher->publish(self::TOPIC_NAME, $request); } diff --git a/Model/Product/Request.php b/Model/Product/Request.php index d7e3fef..41742de 100644 --- a/Model/Product/Request.php +++ b/Model/Product/Request.php @@ -12,23 +12,23 @@ class Request implements RequestInterface { public function __construct( private readonly int $id, - private readonly bool $overwrite + private readonly bool $overwrite, + private readonly int $storeId = 0 ) { } - /** - * @inheritDoc - */ public function getId(): int { return $this->id; } - /** - * @inheritDoc - */ public function getOverwrite(): bool { return $this->overwrite; } + + public function getStoreId(): int + { + return $this->storeId; + } } diff --git a/Model/ResourceModel/EnrichmentLog.php b/Model/ResourceModel/EnrichmentLog.php new file mode 100644 index 0000000..2041bc0 --- /dev/null +++ b/Model/ResourceModel/EnrichmentLog.php @@ -0,0 +1,14 @@ +_init('mageos_catalogai_enrichment_log', 'log_id'); + } +} diff --git a/Model/ResourceModel/EnrichmentLog/Collection.php b/Model/ResourceModel/EnrichmentLog/Collection.php new file mode 100644 index 0000000..d4e2ea1 --- /dev/null +++ b/Model/ResourceModel/EnrichmentLog/Collection.php @@ -0,0 +1,16 @@ +_init(EnrichmentLog::class, EnrichmentLogResource::class); + } +} diff --git a/Observer/Product/SaveAfter.php b/Observer/Product/SaveAfter.php index 6a853cd..e8e5494 100644 --- a/Observer/Product/SaveAfter.php +++ b/Observer/Product/SaveAfter.php @@ -1,11 +1,12 @@ getProduct(); - if($this->config->canEnrich($product) && $this->config->isAsync()) { - $this->publisher->execute($product->getId(), false); + if ($this->config->canEnrich($product) && $this->config->isAsync()) { + $this->publisher->execute( + $product->getId(), + false, + (int)$product->getStoreId() + ); } } } diff --git a/Observer/Product/SaveBefore.php b/Observer/Product/SaveBefore.php index e8743c9..8683b57 100644 --- a/Observer/Product/SaveBefore.php +++ b/Observer/Product/SaveBefore.php @@ -4,25 +4,51 @@ namespace MageOS\CatalogDataAI\Observer\Product; use Magento\Catalog\Model\Product; -use Magento\Framework\Event\ObserverInterface; use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; use MageOS\CatalogDataAI\Model\Config; use MageOS\CatalogDataAI\Model\Product\Enricher; +use MageOS\CatalogDataAI\Model\Product\EnrichmentLogger; class SaveBefore implements ObserverInterface { public function __construct( private readonly Config $config, - private readonly Enricher $enricher - ) {} + private readonly Enricher $enricher, + private readonly EnrichmentLogger $enrichmentLogger + ) { + } public function execute(Observer $observer): void { /** @var Product $product */ $product = $observer->getProduct(); - if($this->config->canEnrich($product) && !$this->config->isAsync()) { + if ($this->config->canEnrich($product) && !$this->config->isAsync()) { $this->enricher->execute($product); + return; + } + + if (!$product->isObjectNew() && $product->getId()) { + $this->detectManualEdits($product); + } + } + + private function detectManualEdits(Product $product): void + { + $storeId = (int)$product->getStoreId(); + $enrichableAttributes = array_keys($this->config->getEnrichableAttributes()); + + foreach ($enrichableAttributes as $attributeCode) { + if (!$product->dataHasChangedFor($attributeCode)) { + continue; + } + + $this->enrichmentLogger->markModified( + (int)$product->getId(), + $attributeCode, + $storeId + ); } } } diff --git a/Setup/Patch/Data/MigrateProductPromptsToDynamicRows.php b/Setup/Patch/Data/MigrateProductPromptsToDynamicRows.php new file mode 100644 index 0000000..dd36048 --- /dev/null +++ b/Setup/Patch/Data/MigrateProductPromptsToDynamicRows.php @@ -0,0 +1,70 @@ +scopeConfig->getValue($oldPath); + + if ($value !== null && $value !== '') { + $hasExistingConfig = true; + $rows[$attributeCode] = [ + 'attribute' => $attributeCode, + 'prompt' => $value, + 'enabled' => '1', + ]; + } + } + + if ($hasExistingConfig) { + $this->configWriter->save(self::NEW_PATH, $this->json->serialize($rows)); + + // Clean up old paths + foreach (self::OLD_ATTRIBUTE_PATHS as $attributeCode) { + $this->configWriter->delete('catalog_ai/product/' . $attributeCode); + } + } + + return $this; + } + + public static function getDependencies(): array + { + return []; + } + + public function getAliases(): array + { + return []; + } +} diff --git a/Test/Unit/Block/Adminhtml/Form/Field/AttributeColumnTest.php b/Test/Unit/Block/Adminhtml/Form/Field/AttributeColumnTest.php new file mode 100644 index 0000000..8f13792 --- /dev/null +++ b/Test/Unit/Block/Adminhtml/Form/Field/AttributeColumnTest.php @@ -0,0 +1,46 @@ +createMock(ProductAttributeInterface::class); + $attribute1->method('getAttributeCode')->willReturn('description'); + $attribute1->method('getDefaultFrontendLabel')->willReturn('Description'); + + $attribute2 = $this->createMock(ProductAttributeInterface::class); + $attribute2->method('getAttributeCode')->willReturn('short_description'); + $attribute2->method('getDefaultFrontendLabel')->willReturn('Short Description'); + + $searchResults = $this->createMock(SearchResultsInterface::class); + $searchResults->method('getItems')->willReturn([$attribute1, $attribute2]); + + $searchCriteria = $this->createMock(SearchCriteria::class); + + $searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class); + $searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $searchCriteriaBuilder->method('create')->willReturn($searchCriteria); + + $attributeRepository = $this->createMock(ProductAttributeRepositoryInterface::class); + $attributeRepository->method('getList')->willReturn($searchResults); + + $column = new AttributeColumn($attributeRepository, $searchCriteriaBuilder); + $options = $column->getOptions(); + + $this->assertArrayHasKey('description', $options); + $this->assertArrayHasKey('short_description', $options); + $this->assertEquals('Description', $options['description']); + $this->assertEquals('Short Description', $options['short_description']); + } +} diff --git a/Test/Unit/Model/ConfigTest.php b/Test/Unit/Model/ConfigTest.php new file mode 100644 index 0000000..809a54d --- /dev/null +++ b/Test/Unit/Model/ConfigTest.php @@ -0,0 +1,97 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->config = new Config($this->scopeConfig); + } + + public function test_get_enrichable_attributes_returns_enabled_attributes(): void + { + $serializedData = [ + 'row1' => ['attribute' => 'description', 'prompt' => 'describe {{name}}', 'enabled' => '1'], + 'row2' => ['attribute' => 'meta_title', 'prompt' => 'title for {{name}}', 'enabled' => '0'], + 'row3' => ['attribute' => 'short_description', 'prompt' => 'short desc for {{name}}', 'enabled' => '1'], + ]; + + $this->scopeConfig->method('getValue') + ->willReturnMap([ + ['catalog_ai/product/attribute_prompts', ScopeInterface::SCOPE_STORE, null, $serializedData], + ]); + + $result = $this->config->getEnrichableAttributes(); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('short_description', $result); + $this->assertArrayNotHasKey('meta_title', $result); + } + + public function test_get_enrichable_attributes_returns_empty_when_null(): void + { + $this->scopeConfig->method('getValue') + ->willReturnMap([ + ['catalog_ai/product/attribute_prompts', ScopeInterface::SCOPE_STORE, null, null], + ]); + + $result = $this->config->getEnrichableAttributes(); + + $this->assertEmpty($result); + } + + public function test_get_product_prompt_returns_prompt_for_attribute(): void + { + $serializedData = [ + 'row1' => ['attribute' => 'description', 'prompt' => 'describe {{name}}', 'enabled' => '1'], + 'row2' => ['attribute' => 'meta_title', 'prompt' => 'title for {{name}}', 'enabled' => '1'], + ]; + + $this->scopeConfig->method('getValue') + ->willReturnMap([ + ['catalog_ai/product/attribute_prompts', ScopeInterface::SCOPE_STORE, null, $serializedData], + ]); + + $this->assertEquals('describe {{name}}', $this->config->getProductPrompt('description')); + $this->assertNull($this->config->getProductPrompt('nonexistent')); + } + + public function test_get_system_prompt_includes_locale_language(): void + { + $this->scopeConfig->method('getValue') + ->willReturnMap([ + [Config::XML_PATH_OPENAI_API_ADVANCED_SYSTEM_PROMPT, ScopeInterface::SCOPE_STORE, null, 'Be a content generator.'], + ['general/locale/code', ScopeInterface::SCOPE_STORE, null, 'fr_FR'], + ]); + + $result = $this->config->getSystemPrompt(); + + $this->assertStringContainsString('Respond in French', $result); + $this->assertStringContainsString('Be a content generator.', $result); + } + + public function test_get_system_prompt_skips_locale_for_english(): void + { + $this->scopeConfig->method('getValue') + ->willReturnMap([ + [Config::XML_PATH_OPENAI_API_ADVANCED_SYSTEM_PROMPT, ScopeInterface::SCOPE_STORE, null, 'Be a content generator.'], + ['general/locale/code', ScopeInterface::SCOPE_STORE, null, 'en_US'], + ]); + + $result = $this->config->getSystemPrompt(); + + $this->assertEquals('Be a content generator.', $result); + } +} diff --git a/Test/Unit/Model/Product/EnricherTest.php b/Test/Unit/Model/Product/EnricherTest.php new file mode 100644 index 0000000..81b0f71 --- /dev/null +++ b/Test/Unit/Model/Product/EnricherTest.php @@ -0,0 +1,48 @@ +createMock(Config::class); + $config->method('getEnrichableAttributes')->willReturn([ + 'description' => 'describe {{name}}', + 'custom_field' => 'generate {{name}} custom', + ]); + + $factory = $this->createMock(Factory::class); + $logger = $this->createMock(EnrichmentLogger::class); + $enricher = new Enricher($factory, $config, $logger); + + $this->assertEquals(['description', 'custom_field'], $enricher->getAttributes()); + } + + public function test_parse_prompt_replaces_placeholders_with_product_data(): void + { + $config = $this->createMock(Config::class); + $factory = $this->createMock(Factory::class); + $logger = $this->createMock(EnrichmentLogger::class); + $enricher = new Enricher($factory, $config, $logger); + + $product = $this->createMock(Product::class); + $product->method('getData') + ->willReturnMap([ + ['name', null, 'Cool Widget'], + ['price', null, '29.99'], + ]); + + $result = $enricher->parsePrompt('describe {{name}} at {{price}}', $product); + + $this->assertEquals('describe Cool Widget at 29.99', $result); + } +} diff --git a/Test/Unit/Model/Product/EnrichmentLoggerTest.php b/Test/Unit/Model/Product/EnrichmentLoggerTest.php new file mode 100644 index 0000000..5f9fb7d --- /dev/null +++ b/Test/Unit/Model/Product/EnrichmentLoggerTest.php @@ -0,0 +1,45 @@ +createMock(Collection::class); + $collection->method('addFieldToFilter')->willReturnSelf(); + $collection->method('getFirstItem')->willReturn($this->createConfiguredMock( + EnrichmentLog::class, + ['getId' => null] + )); + + $collectionFactory = $this->createMock(CollectionFactory::class); + $collectionFactory->method('create')->willReturn($collection); + + $newLog = $this->createMock(EnrichmentLog::class); + $newLog->expects($this->once())->method('setData')->with($this->callback( + fn(array $data) => $data['entity_id'] === 42 + && $data['attribute_code'] === 'description' + && $data['store_id'] === 1 + && $data['status'] === EnrichmentLog::STATUS_GENERATED + )); + + $logFactory = $this->createMock(EnrichmentLogFactory::class); + $logFactory->method('create')->willReturn($newLog); + + $resource = $this->createMock(EnrichmentLogResource::class); + $resource->expects($this->once())->method('save')->with($newLog); + + $logger = new EnrichmentLogger($collectionFactory, $logFactory, $resource); + $logger->log(42, 'description', 1); + } +} diff --git a/Test/Unit/Model/Product/RequestTest.php b/Test/Unit/Model/Product/RequestTest.php new file mode 100644 index 0000000..0f68c9c --- /dev/null +++ b/Test/Unit/Model/Product/RequestTest.php @@ -0,0 +1,26 @@ +assertEquals(42, $request->getId()); + $this->assertTrue($request->getOverwrite()); + $this->assertEquals(3, $request->getStoreId()); + } + + public function test_store_id_defaults_to_zero(): void + { + $request = new Request(42, false); + + $this->assertEquals(0, $request->getStoreId()); + } +} diff --git a/Ui/DataProvider/Product/Form/Modifier/EnrichmentStatus.php b/Ui/DataProvider/Product/Form/Modifier/EnrichmentStatus.php new file mode 100644 index 0000000..328683e --- /dev/null +++ b/Ui/DataProvider/Product/Form/Modifier/EnrichmentStatus.php @@ -0,0 +1,50 @@ +config->getEnrichableAttributes()); + + foreach ($enrichableAttributes as $attributeCode) { + $containerPath = $this->arrayManager->findPath( + $attributeCode, + $meta, + null, + 'children' + ); + + if ($containerPath) { + $meta = $this->arrayManager->merge( + $containerPath . '/arguments/data/config', + $meta, + [ + 'additionalClasses' => 'mageos-catalogai-enriched-field', + ] + ); + } + } + + return $meta; + } +} diff --git a/composer.json b/composer.json index 1f8dc6f..4d2a942 100644 --- a/composer.json +++ b/composer.json @@ -12,15 +12,15 @@ "homepage": "https://www.sunmerce.com/" } ], - "repositories": [ - { - "type": "composer", - "url": "https://mirror.mage-os.org" - } - ], "require": { "php": "^8.1", - "openai-php/client": "*" + "openai-php/client": "*", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-ui": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-store": "*" }, "require-dev": { "phpunit/phpunit": "^9.5" diff --git a/docs/plans/2026-04-14-feature-roadmap-design.md b/docs/plans/2026-04-14-feature-roadmap-design.md new file mode 100644 index 0000000..dafb2f1 --- /dev/null +++ b/docs/plans/2026-04-14-feature-roadmap-design.md @@ -0,0 +1,133 @@ +# Feature Roadmap Design — MageOS_CatalogDataAI + +**Date:** 2026-04-14 +**Approach:** Quick Wins, Then Depth +**BC policy:** Pragmatic — data patches for migration, old paths can break, documented in release notes + +--- + +## Phase 1: Housekeeping + Configurable Attributes + +**Issues:** #23, #22, #25, #27 + +### Housekeeping + +- Run PHP-CS-Fixer with PSR-12/Magento rules across all files +- Fix `system.xml` temperature field: change label from "System Prompt" to "Temperature", add `` tags to all advanced fields explaining valid ranges +- Fix `composer.json`: add explicit Magento module dependencies (`magento/framework`, `magento/module-catalog`, `magento/module-ui`, `magento/module-backend`), remove the mirror repository entry, keep `phpunit` in require-dev + +### Configurable Attributes + +Replace the hardcoded attribute list and static `system.xml` fields with a dynamic row configuration. + +- **New admin config**: Replace the 5 fixed textarea fields under "Product Fields Auto-Generation" with a dynamic row table with columns: + - **Attribute** — dropdown of product text/textarea attributes + - **Prompt** — textarea for the prompt template + - **Enabled** — yes/no toggle per attribute +- **Data patch**: Migrate existing `catalog_ai/product/*` config values into the new dynamic row format +- **Enricher refactor**: `getAttributes()` reads from the dynamic config instead of returning a hardcoded array. `enrichAttribute()` stays the same — it already takes an attribute code and looks up the prompt from config +- **Config model**: `getProductPrompt()` adapts to read from the serialized dynamic row data instead of individual config paths +- **Default rows**: Pre-populate the same 5 attributes (short_description, description, meta_title, meta_keyword, meta_description) with their current default prompts + +--- + +## Phase 2: Multi-Store/Locale + Enrichment Status Flags + +**Issues:** #12, #48 + +### Multi-Store/Locale + +- **Locale-aware prompts**: Detect the store's locale (`general/locale/code`) and prepend "Respond in {locale language}" to the system prompt when enriching for a store +- **Store-scoped enrichment**: `Publisher`/`Request` DTO gets a `storeId` field. `Consumer` sets the correct store scope before enriching so config values resolve per-store. Mass actions enrich for the selected store scope +- **Data patch**: Add `storeId` to the `Request` queue message format with default of `0` for backward compatibility with in-flight messages + +### Enrichment Status Flags + +- **New DB table**: `mageos_catalogai_enrichment_log` with columns: `entity_id` (product ID), `attribute_code`, `store_id`, `status` (enum: `generated`, `pending_review`, `modified`), `generated_at`, `updated_at` +- **Write on enrich**: When `Enricher` successfully sets an attribute value, write/update a log row with status `generated` +- **Detect manual edits**: In `SaveBefore` observer, if an enriched attribute's value changed and it wasn't the enricher doing it, flip status to `modified` +- **Admin UI indicator**: On the product edit form, add a small badge/note next to enriched fields showing their status via a UI component modifier +- No blocking review workflow yet — that's Phase 4. This phase just tracks and displays status. + +--- + +## Phase 3: Prompt Rules Engine + Config Migration + +**Issues:** #32, #43 + +### Prompt Rules Engine + +- **New admin grid**: "AI Prompt Rules" grid. Each rule has: + - **Name** — human-readable label + - **Attribute** — which attribute this prompt targets + - **Store scope(s)** — multiselect, default: all + - **Conditions** — product conditions using Magento's existing condition engine (same as catalog price rules) + - **Prompt** — template with `{{variable}}` support + - **Priority** — integer, highest wins + - **Enabled** — yes/no +- **Rule resolution**: Collect all matching enabled rules for attribute+store, sort by priority, use highest. Fall back to default prompt from Phase 1's dynamic rows if no rule matches. +- **Preview/test**: On the rule edit form, "Test" button — enter a SKU, see the resolved prompt + API response +- **Storage**: New DB tables for rules and conditions (entities, not config) +- **Phase 1 compatibility**: Dynamic row config becomes "default prompts" — rules layer on top, no migration needed + +### Config Migration + +- **New section**: `ai_integration` under the Services tab, matching the translation module's structure +- **Group**: "Data Enrichment" group within that section +- **Data patch**: Migrate all `catalog_ai/*` config values to the new path +- **No legacy path support** — document in release notes + +--- + +## Phase 4: Review/Approval Workflow + +**Issues:** #28 + +### Enrichment Review Grid + +- **Admin grid**: "AI Enrichment Review" under the AI Integration section. Columns: product name/SKU, attribute, store, status, generated content (truncated), generated date. Filterable by status, store, attribute. +- **Inline editing**: Admin clicks into a row to see full content, edit, approve, or reject +- **Statuses expand**: Phase 2 statuses gain `approved` and `rejected`. Rejected clears or reverts the attribute value. +- **Original value backup**: Before enrichment overwrites, store the previous value in the log table for revert on rejection +- **Review mode config**: Toggle "Require review before publish". When enabled: + - Enricher writes to log with `pending_review` but does NOT set on the product + - Admin approves in grid -> content written to product and saved +- **Bulk grid actions**: Approve selected, reject selected, re-generate selected + +### Content Deduplication + +- **Hash tracking**: Compute hash of resolved prompt (after variable substitution), store in log alongside generated output +- **Cache hit**: Before calling OpenAI, check for existing log entry with same prompt hash + attribute + store. Reuse if found. +- **Cache invalidation**: Prompt or source data changes -> hash won't match -> fresh API call. No manual cache management. + +--- + +## Phase 5: Structured Attribute Extraction + +**Issues:** #7 + +### Attribute Extraction + +- **New enrichment type**: "Attribute Extraction" alongside "Content Generation". Separate config/rules — prompt engineering is fundamentally different (extract/classify vs. create). +- **Attribute mapping config**: Admin defines extraction rules: + - **Source attribute(s)** — where to read from (e.g., description) + - **Target attribute** — attribute to fill (dropdown, multiselect, boolean, text, decimal) + - **Extraction prompt** — instructions for the AI + - **Value mapping** — for dropdowns/multiselects: map AI output strings to option IDs, supports fuzzy matching + - **Conditions** — reuse Phase 3 conditions engine +- **Structured output**: Use OpenAI's JSON mode (`response_format: { type: "json_object" }`) for reliable structured responses +- **Validation**: Validate extracted value against attribute constraints before writing. Log failures. +- **Review integration**: Extracted values go through Phase 4 review flow, defaulting to `pending_review` status + +--- + +## Decision Log + +| Decision | Rationale | +|---|---| +| Keep per-attribute API calls (not unified) | Per-attribute prompts give more control, especially with the rules engine where different attributes may need completely different prompts per store/category/language | +| Pragmatic BC, no legacy config paths | Avoids carrying dead weight; data patches handle migration cleanly | +| Dynamic rows before rules engine | Dynamic rows is a quick win that unblocks configurable attributes; rules engine layers on top later | +| Config migration paired with rules engine | Both touch prompt storage/admin structure — one release reshuffles the admin experience | +| Review workflow as Phase 4 | Needs the enrichment log from Phase 2 and benefits from the rules engine in Phase 3 | +| Structured extraction last | Most experimental feature, benefits from all prior infrastructure (rules, review, configurable attributes) | diff --git a/docs/plans/2026-04-14-phase1-implementation.md b/docs/plans/2026-04-14-phase1-implementation.md new file mode 100644 index 0000000..4618949 --- /dev/null +++ b/docs/plans/2026-04-14-phase1-implementation.md @@ -0,0 +1,879 @@ +# Phase 1: Housekeeping + Configurable Attributes — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix code style, composer deps, and system.xml label issues, then replace the hardcoded 5-attribute enrichment list with a dynamic row configuration that lets admins enrich any text attribute. + +**Architecture:** The dynamic rows feature replaces the static `catalog_ai/product/*` config fields with a single serialized field using Magento's `AbstractFieldArray` + `ArraySerialized` backend model. A custom select renderer provides an attribute dropdown. The `Config` model and `Enricher` are updated to read from the new serialized format. A data patch migrates existing config. + +**Tech Stack:** PHP 8.1+, Magento 2.4.x, PHPUnit 9.5 + +--- + +### Task 1: Fix composer.json + +**Files:** +- Modify: `composer.json` + +**Step 1: Update composer.json** + +Replace the entire content of `composer.json` with: + +```json +{ + "name": "mage-os/module-catalog-data-ai", + "description": "Generate product descriptions and similar content with the help of AI.", + "type": "magento2-module", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Sun", + "email": "ryansun81@gmail.com", + "homepage": "https://www.sunmerce.com/" + } + ], + "require": { + "php": "^8.1", + "openai-php/client": "*", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-ui": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-store": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "MageOS\\CatalogDataAI\\": "" + } + } +} +``` + +Changes from current: +- Removed `repositories` block (the mage-os mirror is not needed for the package itself) +- Added explicit Magento module dependencies: `magento/framework`, `magento/module-catalog`, `magento/module-ui`, `magento/module-backend`, `magento/module-config`, `magento/module-store` + +**Step 2: Commit** + +```bash +git add composer.json +git commit -m "fix: add explicit Magento module dependencies, remove mirror repo (#22)" +``` + +--- + +### Task 2: Fix system.xml labels and comments + +**Files:** +- Modify: `etc/adminhtml/system.xml:62-75` + +**Step 1: Fix the temperature label and add comments to advanced fields** + +In `etc/adminhtml/system.xml`, replace the `advanced` group (lines 61-76) with: + +```xml + + + + + + + + + + + + + + + + + + + +``` + +Key changes: +- Temperature `