Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3366204
Add feature roadmap design document
DavidLambauer Apr 14, 2026
8e25f93
Add Phase 1 implementation plan
DavidLambauer Apr 14, 2026
0505daf
fix: add explicit Magento module dependencies, remove mirror repo (#22)
DavidLambauer Apr 14, 2026
41871dd
fix: correct temperature label, add range comments to advanced fields…
DavidLambauer Apr 14, 2026
ba77c7f
style: apply PSR-12 code style fixes (#23)
DavidLambauer Apr 14, 2026
9175b7f
feat: add attribute column select renderer for dynamic rows (#27)
DavidLambauer Apr 14, 2026
68eb53c
feat: add dynamic rows field array block for product attributes (#27)
DavidLambauer Apr 14, 2026
636d716
feat: add default dynamic row values for all 5 enrichable attributes …
DavidLambauer Apr 14, 2026
6ba267f
feat: replace static product fields with dynamic rows config (#27)
DavidLambauer Apr 14, 2026
57f87c4
feat: update Config to read attribute prompts from dynamic rows (#27)
DavidLambauer Apr 14, 2026
f06f09d
feat: Enricher reads attribute list from dynamic config (#27)
DavidLambauer Apr 14, 2026
b054e03
feat: add data patch to migrate old product prompts to dynamic rows (…
DavidLambauer Apr 14, 2026
f79ee52
Add Phase 2 implementation plan
DavidLambauer Apr 14, 2026
08c751f
feat: add storeId to Request DTO for store-scoped enrichment (#12)
DavidLambauer Apr 14, 2026
061cce9
feat: Publisher passes storeId to queue messages (#12)
DavidLambauer Apr 14, 2026
9df0899
feat: Consumer sets store scope from queue message (#12)
DavidLambauer Apr 14, 2026
0be6417
feat: SaveAfter observer passes storeId to publisher (#12)
DavidLambauer Apr 14, 2026
573dd99
feat: MassEnrich passes current store scope to publisher (#12)
DavidLambauer Apr 14, 2026
8022545
feat: add enrichment log DB table schema (#48)
DavidLambauer Apr 14, 2026
cfa472d
feat: locale-aware system prompt prepends language instruction (#12)
DavidLambauer Apr 14, 2026
e402540
feat: add Magento_Store to module sequence (#12)
DavidLambauer Apr 14, 2026
fa83a07
feat: add EnrichmentLog model, resource model, and collection (#48)
DavidLambauer Apr 14, 2026
81eb98c
feat: add EnrichmentLogger service for tracking AI-generated attribut…
DavidLambauer Apr 14, 2026
4f74fbf
feat: detect manual edits to enriched attributes, mark as modified (#48)
DavidLambauer Apr 14, 2026
da13f65
feat: Enricher logs enrichment status after successful AI generation …
DavidLambauer Apr 14, 2026
7c09815
feat: add enrichment status UI modifier for product edit form (#48)
DavidLambauer Apr 14, 2026
d635571
fix: update EnricherTest for new EnrichmentLogger constructor param
DavidLambauer Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

$finder = PhpCsFixer\Finder::create()
->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);
8 changes: 7 additions & 1 deletion Api/RequestInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
interface RequestInterface
{
/**
* Retrieve products id.
* Retrieve product id.
* @return int
*/
public function getId(): int;
Expand All @@ -16,4 +16,10 @@ public function getId(): int;
* @return bool
*/
public function getOverwrite(): bool;

/**
* Retrieve store id.
* @return int
*/
public function getStoreId(): int;
}
68 changes: 68 additions & 0 deletions Block/Adminhtml/Form/Field/AttributeColumn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);

namespace MageOS\CatalogDataAI\Block\Adminhtml\Form\Field;

use Magento\Catalog\Api\ProductAttributeRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\View\Element\Context;
use Magento\Framework\View\Element\Html\Select;

class AttributeColumn extends Select
{
private array $attributeOptions = [];

public function __construct(
private readonly ProductAttributeRepositoryInterface $attributeRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
?Context $context = null,
array $data = []
) {
if ($context !== null) {
parent::__construct($context, $data);
}
}

public function getOptions(): array
{
if (empty($this->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();
}
}
62 changes: 62 additions & 0 deletions Block/Adminhtml/Form/Field/ProductAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);

namespace MageOS\CatalogDataAI\Block\Adminhtml\Form\Field;

use Magento\Config\Block\System\Config\Form\Field\FieldArray\AbstractFieldArray;
use Magento\Framework\DataObject;
use Magento\Framework\Exception\LocalizedException;

class ProductAttributes extends AbstractFieldArray
{
private ?AttributeColumn $attributeRenderer = null;

protected function _prepareToRender(): void
{
$this->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;
}
}
13 changes: 8 additions & 5 deletions Controller/Adminhtml/Product/MassEnrich.php
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
<?php

declare(strict_types=1);

namespace MageOS\CatalogDataAI\Controller\Adminhtml\Product;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\View\Result\Redirect;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Backend\App\Action;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Framework\App\Action\HttpPostActionInterface as HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\Ui\Component\MassAction\Filter;
use MageOS\CatalogDataAI\Model\Config;
use Magento\Store\Model\StoreManagerInterface;
use MageOS\CatalogDataAI\Model\Product\Publisher;

class MassEnrich extends Action implements HttpPostActionInterface
Expand All @@ -26,6 +28,7 @@ public function __construct(
private readonly Config $config,
private readonly Publisher $publisher,
private readonly ProductRepositoryInterface $productRepository,
private readonly StoreManagerInterface $storeManager,
) {
parent::__construct($context);
}
Expand All @@ -41,11 +44,12 @@ public function execute(): Redirect
$collection = $this->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++;
}

Expand All @@ -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.')
);
Expand Down
3 changes: 1 addition & 2 deletions Controller/Adminhtml/Product/MassEnrichSafe.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
<?php

declare(strict_types=1);

namespace MageOS\CatalogDataAI\Controller\Adminhtml\Product;

use MageOS\CatalogDataAI\Controller\Adminhtml\Product\MassEnrich;

class MassEnrichSafe extends MassEnrich
{
protected $overwrite = false;
Expand Down
70 changes: 61 additions & 9 deletions Model/Config.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<?php

declare(strict_types=1);

namespace MageOS\CatalogDataAI\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Catalog\Model\Product;
use Magento\Framework\App\Config\ScopeConfigInterface;

class Config
{
Expand All @@ -20,9 +21,25 @@ class Config
public const XML_PATH_OPENAI_API_ADVANCED_FREQUENCY_PENALTY = 'catalog_ai/advanced/frequency_penalty';
public const XML_PATH_OPENAI_API_ADVANCED_PRESENCE_PENALTY = 'catalog_ai/advanced/presence_penalty';

private const LOCALE_LANGUAGE_MAP = [
'af' => '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
{
Expand Down Expand Up @@ -71,24 +88,59 @@ 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
{
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
Expand Down
4 changes: 2 additions & 2 deletions Model/Config/Source/OpenAIModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
19 changes: 19 additions & 0 deletions Model/EnrichmentLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);

namespace MageOS\CatalogDataAI\Model;

use Magento\Framework\Model\AbstractModel;
use MageOS\CatalogDataAI\Model\ResourceModel\EnrichmentLog as EnrichmentLogResource;

class EnrichmentLog extends AbstractModel
{
public const STATUS_GENERATED = 'generated';
public const STATUS_PENDING_REVIEW = 'pending_review';
public const STATUS_MODIFIED = 'modified';

protected function _construct(): void
{
$this->_init(EnrichmentLogResource::class);
}
}
Loading