diff --git a/Api/AiClientInterface.php b/Api/AiClientInterface.php index edc8bda..0e1012b 100644 --- a/Api/AiClientInterface.php +++ b/Api/AiClientInterface.php @@ -18,4 +18,21 @@ interface AiClientInterface * @return string|null */ public function generate(string $systemPrompt, string $userPrompt): ?string; + + /** + * Generate content for multiple attributes in a single API call. + * + * Returns an empty array on failure (JSON parse error, missing keys, etc.) + * to signal the caller should fall back to individual generate() calls. + * + * @param string $systemPrompt + * @param string $productContext Formatted product attribute data + * @param array $attributePrompts [attribute_code => parsed_prompt] + * @return array [attribute_code => generated_value] + */ + public function generateBatch( + string $systemPrompt, + string $productContext, + array $attributePrompts + ): array; } diff --git a/Api/ProductContextBuilderInterface.php b/Api/ProductContextBuilderInterface.php new file mode 100644 index 0000000..6a48ae8 --- /dev/null +++ b/Api/ProductContextBuilderInterface.php @@ -0,0 +1,18 @@ +scopeConfig->getValue(self::XML_PATH_CONTEXT_VALUE_MAX_LENGTH); + } + /** * Parse the serialized attribute_prompts config into [attribute_code => prompt]. * diff --git a/Model/OpenAiClient.php b/Model/OpenAiClient.php index 39d83f2..bf69135 100644 --- a/Model/OpenAiClient.php +++ b/Model/OpenAiClient.php @@ -56,6 +56,93 @@ public function generate(string $systemPrompt, string $userPrompt): ?string return $result?->message?->content; } + /** + * @param array $attributePrompts + * @return array + */ + public function buildBatchSchema(array $attributePrompts): array + { + $properties = []; + foreach ($attributePrompts as $code => $prompt) { + $properties[$code] = ['type' => 'string', 'description' => $prompt]; + } + + return [ + 'type' => 'object', + 'properties' => $properties, + 'required' => array_keys($attributePrompts), + 'additionalProperties' => false, + ]; + } + + /** + * @param string $productContext + * @param array $attributePrompts + * @return string + */ + public function buildBatchPrompt(string $productContext, array $attributePrompts): string + { + $prompt = "Product information:\n" . $productContext . "\n\n" + . "Generate content for each of the following product attributes:\n"; + + foreach ($attributePrompts as $code => $instruction) { + $prompt .= "\n{$code}: {$instruction}"; + } + + return $prompt; + } + + /** + * @param string|null $json + * @param array $requestedKeys + * @return array + */ + public function parseBatchResponse(?string $json, array $requestedKeys): array + { + $decoded = json_decode($json ?? '', true); + + if (!is_array($decoded)) { + return []; + } + + return array_intersect_key($decoded, $requestedKeys); + } + + /** + * @inheritdoc + */ + public function generateBatch( + string $systemPrompt, + string $productContext, + array $attributePrompts + ): array { + $response = $this->getClient()->chat()->create([ + 'model' => $this->config->getApiModel(), + 'temperature' => $this->config->getTemperature(), + 'frequency_penalty' => $this->config->getFrequencyPenalty(), + 'presence_penalty' => $this->config->getPresencePenalty(), + 'max_completion_tokens' => $this->config->getApiMaxTokens() * count($attributePrompts), + 'response_format' => [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'product_enrichment', + 'strict' => true, + 'schema' => $this->buildBatchSchema($attributePrompts), + ], + ], + 'messages' => [ + ['role' => 'developer', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $this->buildBatchPrompt($productContext, $attributePrompts)], + ], + ]); + + $this->backoff($response->meta()); + + $content = $response->choices[0]?->message?->content; + + return $this->parseBatchResponse($content, $attributePrompts); + } + private function getClient(): Client { if (!isset($this->client)) { diff --git a/Model/Product/Enricher.php b/Model/Product/Enricher.php index 7eba746..fa1bc75 100644 --- a/Model/Product/Enricher.php +++ b/Model/Product/Enricher.php @@ -1,4 +1,5 @@ config->getConfiguredAttributes($storeId); } - /** - * @todo move to parser class/pool - */ public function parsePrompt(string $prompt, Product $product): string { return preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) use ($product) { @@ -40,89 +32,165 @@ public function parsePrompt(string $prompt, Product $product): string }, $prompt); } - public function enrichAttribute(Product $product, string $attributeCode): void + public function execute(Product $product): void { - if (!$product->getData('mageos_catalogai_overwrite') && $product->getData($attributeCode)) { + $pending = $this->collectPending($product); + if (empty($pending)) { return; } - $prompt = $this->config->getProductPrompt($attributeCode, (int) $product->getStoreId()); - if (!$prompt) { - return; - } + $results = $this->generate($product, $pending); - $parsedPrompt = $this->parsePrompt($prompt, $product); - $storeId = (int) $product->getStoreId(); + $this->record($product, $results, $pending); + } - if ($this->config->isCacheEnabled()) { - $hash = $this->hashGenerator->generate( - $parsedPrompt, - (string) $this->config->getSystemPrompt(), - $attributeCode, - $storeId - ); + /** + * Check cache and collect attributes that need AI generation. + * Applies cached values directly to the product as a side effect. + * + * @return array + */ + private function collectPending(Product $product): array + { + $storeId = (int) $product->getStoreId(); + $systemPrompt = (string) $this->config->getSystemPrompt(); + $pending = []; - $existing = $this->enrichmentRecorder->findByHash($hash, $attributeCode, $storeId); - if ($existing !== null) { - $status = $existing->getStatus(); - if ($status === EnrichmentInterface::STATUS_APPROVED || $status === EnrichmentInterface::STATUS_APPLIED) { - $value = $existing->getAppliedValue() ?? $existing->getGeneratedValue(); - $product->setData($attributeCode, $value); - } - return; + foreach ($this->getAttributes($storeId) as $code) { + if (!$product->getData('mageos_catalogai_overwrite') && $product->getData($code)) { + continue; } - $generatedValue = $this->aiClient->generate((string) $this->config->getSystemPrompt(), $parsedPrompt); - if ($generatedValue === null) { - return; + $prompt = $this->config->getProductPrompt($code, $storeId); + if (!$prompt) { + continue; } - $productId = (int) $product->getId(); - if ($productId > 0) { - $this->enrichmentRecorder->record( - $productId, - $storeId, - $attributeCode, - $hash, - $parsedPrompt, - $generatedValue - ); - } else { - $product->setData('mageos_catalogai_deferred_enrichments', array_merge( - $product->getData('mageos_catalogai_deferred_enrichments') ?? [], - [[ - 'attribute_code' => $attributeCode, - 'prompt_hash' => $hash, - 'parsed_prompt' => $parsedPrompt, - 'generated_value' => $generatedValue, - 'store_id' => $storeId, - ]] - )); + $parsedPrompt = $this->parsePrompt($prompt, $product); + + if (!$this->config->isCacheEnabled()) { + $pending[$code] = ['prompt' => $parsedPrompt, 'hash' => null]; + continue; } - if (!$this->config->isApprovalRequired()) { - $product->setData($attributeCode, $generatedValue); + $hash = $this->hashGenerator->generate($parsedPrompt, $systemPrompt, $code, $storeId); + $existing = $this->enrichmentRecorder->findByHash($hash, $code, $storeId); + + if ($existing !== null) { + $this->applyCachedValue($product, $code, $existing); + continue; } - return; + + $pending[$code] = ['prompt' => $parsedPrompt, 'hash' => $hash]; } - $generatedValue = $this->aiClient->generate((string) $this->config->getSystemPrompt(), $parsedPrompt); - if ($generatedValue !== null) { - $product->setData($attributeCode, $generatedValue); + return $pending; + } + + private function applyCachedValue( + Product $product, + string $code, + EnrichmentInterface $enrichment + ): void { + $status = $enrichment->getStatus(); + if ($status === EnrichmentInterface::STATUS_APPROVED + || $status === EnrichmentInterface::STATUS_APPLIED + ) { + $product->setData( + $code, + $enrichment->getAppliedValue() ?? $enrichment->getGeneratedValue() + ); } } - public function execute(Product $product): void + /** + * Call AI for all pending attributes in a single batch. + * Falls back to individual calls if batch returns empty. + * + * @param array $pending + * @return array + */ + private function generate(Product $product, array $pending): array + { + $systemPrompt = (string) $this->config->getSystemPrompt(); + $prompts = array_map(fn(array $item) => $item['prompt'], $pending); + + $results = $this->aiClient->generateBatch( + $systemPrompt, + $this->contextBuilder->build($product), + $prompts + ); + + if (!empty($results)) { + return $results; + } + + // Batch failed — fall back to individual calls + $results = []; + foreach ($prompts as $code => $prompt) { + $value = $this->aiClient->generate($systemPrompt, $prompt); + if ($value !== null) { + $results[$code] = $value; + } + } + + return $results; + } + + /** + * Record enrichments and apply values to the product. + * + * @param array $results + * @param array $pending + */ + private function record(Product $product, array $results, array $pending): void { - foreach ($this->getAttributes((int) $product->getStoreId()) as $attributeCode) { - try { - $this->enrichAttribute($product, $attributeCode); - } catch (ErrorException $e) { - // try it one more time just in case we failed to catch the limit in backoff - sleep(60); - $this->enrichAttribute($product, $attributeCode); + $storeId = (int) $product->getStoreId(); + + foreach ($results as $code => $value) { + if (!isset($pending[$code])) { + continue; + } + + $hash = $pending[$code]['hash']; + if ($hash !== null) { + $this->recordEnrichment( + $product, $code, $hash, $pending[$code]['prompt'], $value, $storeId + ); + } + + if (!$this->config->isApprovalRequired()) { + $product->setData($code, $value); } } - //@TODO: throw exception? + } + + private function recordEnrichment( + Product $product, + string $code, + string $hash, + string $parsedPrompt, + string $value, + int $storeId + ): void { + $productId = (int) $product->getId(); + + if ($productId > 0) { + $this->enrichmentRecorder->record( + $productId, $storeId, $code, $hash, $parsedPrompt, $value + ); + return; + } + + // Deferred: product not yet saved, persist after save via observer + $deferred = $product->getData('mageos_catalogai_deferred_enrichments') ?? []; + $deferred[] = [ + 'attribute_code' => $code, + 'prompt_hash' => $hash, + 'parsed_prompt' => $parsedPrompt, + 'generated_value' => $value, + 'store_id' => $storeId, + ]; + $product->setData('mageos_catalogai_deferred_enrichments', $deferred); } } diff --git a/Model/Product/ProductContextBuilder.php b/Model/Product/ProductContextBuilder.php new file mode 100644 index 0000000..0a7cf26 --- /dev/null +++ b/Model/Product/ProductContextBuilder.php @@ -0,0 +1,42 @@ +config->getContextValueMaxLength(); + $lines = []; + + foreach ($product->getAttributes() as $attribute) { + if (!$attribute->getIsVisibleOnFront() && !$attribute->getIsSearchable()) { + continue; + } + + $value = $product->getDataUsingMethod($attribute->getAttributeCode()); + if ($value === null || $value === '' || is_array($value)) { + continue; + } + + $value = (string) $value; + if ($maxLength > 0 && mb_strlen($value) > $maxLength) { + $value = mb_substr($value, 0, $maxLength) . '...'; + } + + $lines[] = ($attribute->getStoreLabel() ?: $attribute->getAttributeCode()) . ': ' . $value; + } + + return implode("\n", $lines); + } +} diff --git a/Test/Unit/Model/OpenAiClientTest.php b/Test/Unit/Model/OpenAiClientTest.php index faf6862..6f88b14 100644 --- a/Test/Unit/Model/OpenAiClientTest.php +++ b/Test/Unit/Model/OpenAiClientTest.php @@ -50,4 +50,75 @@ public function testStrToSecondsEmptyString(): void $this->assertSame(0, $result); } + + public function testBuildBatchSchemaCreatesRequiredStringProperties(): void + { + $prompts = [ + 'description' => 'Write a description', + 'meta_title' => 'Write a meta title', + ]; + + $schema = $this->client->buildBatchSchema($prompts); + + $this->assertSame('object', $schema['type']); + $this->assertFalse($schema['additionalProperties']); + $this->assertSame(['description', 'meta_title'], $schema['required']); + $this->assertSame('string', $schema['properties']['description']['type']); + $this->assertSame('Write a description', $schema['properties']['description']['description']); + $this->assertSame('string', $schema['properties']['meta_title']['type']); + } + + public function testBuildBatchPromptCombinesContextAndInstructions(): void + { + $context = "Name: Widget\nSKU: ABC"; + $prompts = [ + 'description' => 'Describe this product', + 'meta_title' => 'Write a title', + ]; + + $result = $this->client->buildBatchPrompt($context, $prompts); + + $this->assertStringContainsString('Product information:', $result); + $this->assertStringContainsString("Name: Widget\nSKU: ABC", $result); + $this->assertStringContainsString('description: Describe this product', $result); + $this->assertStringContainsString('meta_title: Write a title', $result); + } + + public function testParseBatchResponseValidJson(): void + { + $json = '{"description": "A great widget", "meta_title": "Widget Title"}'; + $requested = ['description' => 'prompt1', 'meta_title' => 'prompt2']; + + $result = $this->client->parseBatchResponse($json, $requested); + + $this->assertSame([ + 'description' => 'A great widget', + 'meta_title' => 'Widget Title', + ], $result); + } + + public function testParseBatchResponseFiltersUnrequestedKeys(): void + { + $json = '{"description": "text", "meta_title": "title", "extra": "ignored"}'; + $requested = ['description' => 'prompt1', 'meta_title' => 'prompt2']; + + $result = $this->client->parseBatchResponse($json, $requested); + + $this->assertArrayNotHasKey('extra', $result); + $this->assertCount(2, $result); + } + + public function testParseBatchResponseInvalidJsonReturnsEmpty(): void + { + $result = $this->client->parseBatchResponse('not json', ['description' => 'p']); + + $this->assertSame([], $result); + } + + public function testParseBatchResponseNullReturnsEmpty(): void + { + $result = $this->client->parseBatchResponse(null, ['description' => 'p']); + + $this->assertSame([], $result); + } } diff --git a/Test/Unit/Model/Product/EnricherTest.php b/Test/Unit/Model/Product/EnricherTest.php index 85604b0..73b4183 100644 --- a/Test/Unit/Model/Product/EnricherTest.php +++ b/Test/Unit/Model/Product/EnricherTest.php @@ -1,21 +1,17 @@ config = $this->createMock(Config::class); $this->hashGenerator = $this->createMock(HashGenerator::class); $this->enrichmentRecorder = $this->createMock(EnrichmentRecorder::class); + $this->contextBuilder = $this->createMock(ProductContextBuilderInterface::class); $this->enricher = new Enricher( $this->aiClient, $this->config, $this->hashGenerator, - $this->enrichmentRecorder + $this->enrichmentRecorder, + $this->contextBuilder ); } - // --- parsePrompt tests --- + // --- parsePrompt tests (unchanged public method) --- public function testParsePromptReplacesPlaceholders(): void { $product = $this->createProductMock(['name' => 'Widget']); - $result = $this->enricher->parsePrompt('Describe {{name}}', $product); - - $this->assertSame('Describe Widget', $result); + $this->assertSame('Describe Widget', $this->enricher->parsePrompt('Describe {{name}}', $product)); } public function testParsePromptNullAttributeReturnsEmptyString(): void { $product = $this->createProductMock([]); - $result = $this->enricher->parsePrompt('{{missing}}', $product); - - $this->assertSame('', $result); + $this->assertSame('', $this->enricher->parsePrompt('{{missing}}', $product)); } - public function testParsePromptNoPlaceholdersUnchanged(): void - { - $product = $this->createProductMock([]); - $result = $this->enricher->parsePrompt('Static prompt', $product); - - $this->assertSame('Static prompt', $result); - } + // --- getAttributes --- - public function testParsePromptMultipleSamePlaceholder(): void + public function testGetAttributesDelegatesToConfig(): void { - $product = $this->createProductMock(['name' => 'Widget']); - $result = $this->enricher->parsePrompt('{{name}} is {{name}}', $product); + $this->config->expects($this->once()) + ->method('getConfiguredAttributes') + ->with(5) + ->willReturn(['description']); - $this->assertSame('Widget is Widget', $result); + $this->assertSame(['description'], $this->enricher->getAttributes(5)); } - // --- enrichAttribute guard clause tests --- + // --- execute: nothing to do --- - public function testEnrichAttributeSkipsExistingValueWithoutOverwrite(): void + public function testExecuteNoAttributesConfigured(): void { - $product = $this->createProductMock([ - 'description' => 'Existing description', - 'mageos_catalogai_overwrite' => false, - ]); + $product = $this->createProductMock([], storeId: 1); + $this->config->method('getConfiguredAttributes')->willReturn([]); - $this->config->expects($this->never())->method('getProductPrompt'); - $this->hashGenerator->expects($this->never())->method('generate'); + $this->aiClient->expects($this->never())->method('generateBatch'); + $this->aiClient->expects($this->never())->method('generate'); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); } - public function testEnrichAttributeSkipsWhenNoPromptConfigured(): void + public function testExecuteAllAttributesHaveValues(): void { - $product = $this->createProductMock(['store_id' => 1], storeId: 1); - - $this->config->expects($this->once()) - ->method('getProductPrompt') - ->with('description', 1) - ->willReturn(null); + $product = $this->createProductMock( + ['description' => 'Existing', 'meta_title' => 'Existing Title'], + storeId: 1 + ); + $this->config->method('getConfiguredAttributes')->willReturn(['description', 'meta_title']); - $this->hashGenerator->expects($this->never())->method('generate'); + $this->aiClient->expects($this->never())->method('generateBatch'); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); } - // --- enrichAttribute cache hit tests --- + // --- execute: full batch (cache disabled) --- - public function testEnrichAttributeCacheHitApprovedSetsValue(): void + public function testExecuteBatchCacheDisabled(): void { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 123, trackSetData: true); - - $this->setUpCacheHitScenario('Describe Widget', 'somehash', 1); - - $enrichment = $this->createMock(EnrichmentInterface::class); - $enrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_APPROVED); - $enrichment->method('getGeneratedValue')->willReturn('AI text'); - $enrichment->method('getAppliedValue')->willReturn(null); - - $this->enrichmentRecorder->method('findByHash') - ->with('somehash', 'description', 1) - ->willReturn($enrichment); - - $this->enricher->enrichAttribute($product, 'description'); - - $this->assertContains(['key' => 'description', 'value' => 'AI text'], $this->getProductSetDataCalls()); - } + $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - public function testEnrichAttributeCacheHitAppliedUsesAppliedValue(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 123, trackSetData: true); + $this->config->method('getConfiguredAttributes')->with(1)->willReturn(['description', 'meta_title']); + $this->config->method('getProductPrompt')->willReturnMap([ + ['description', 1, 'Describe {{name}}'], + ['meta_title', 1, 'Title for {{name}}'], + ]); + $this->config->method('isCacheEnabled')->willReturn(false); + $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); - $this->setUpCacheHitScenario('Describe Widget', 'somehash', 1); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $enrichment = $this->createMock(EnrichmentInterface::class); - $enrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_APPLIED); - $enrichment->method('getGeneratedValue')->willReturn('original'); - $enrichment->method('getAppliedValue')->willReturn('edited'); + $this->aiClient->expects($this->once()) + ->method('generateBatch') + ->with('system', 'Name: Widget', [ + 'description' => 'Describe Widget', + 'meta_title' => 'Title for Widget', + ]) + ->willReturn([ + 'description' => 'AI description', + 'meta_title' => 'AI title', + ]); - $this->enrichmentRecorder->method('findByHash') - ->with('somehash', 'description', 1) - ->willReturn($enrichment); + $this->aiClient->expects($this->never())->method('generate'); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); - $this->assertContains(['key' => 'description', 'value' => 'edited'], $this->getProductSetDataCalls()); + $calls = $this->getProductSetDataCalls(); + $this->assertContains(['key' => 'description', 'value' => 'AI description'], $calls); + $this->assertContains(['key' => 'meta_title', 'value' => 'AI title'], $calls); } - public function testEnrichAttributeCacheHitPendingSkips(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 123, trackSetData: true); - - $this->setUpCacheHitScenario('Describe Widget', 'somehash', 1); + // --- execute: full batch (cache enabled, all miss) --- - $enrichment = $this->createMock(EnrichmentInterface::class); - $enrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_PENDING); - - $this->enrichmentRecorder->method('findByHash') - ->with('somehash', 'description', 1) - ->willReturn($enrichment); - - $this->enricher->enrichAttribute($product, 'description'); - - $descriptionCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); - $this->assertEmpty($descriptionCalls); - } - - public function testEnrichAttributeCacheHitDeniedSkips(): void + public function testExecuteBatchCacheEnabledAllMiss(): void { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 123, trackSetData: true); + $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->setUpCacheHitScenario('Describe Widget', 'somehash', 1); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); + $this->config->method('isCacheEnabled')->willReturn(true); + $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); - $enrichment = $this->createMock(EnrichmentInterface::class); - $enrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_DENIED); + $this->hashGenerator->method('generate')->willReturn('hash1'); + $this->enrichmentRecorder->method('findByHash')->willReturn(null); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $this->enrichmentRecorder->method('findByHash') - ->with('somehash', 'description', 1) - ->willReturn($enrichment); + $this->aiClient->method('generateBatch')->willReturn(['description' => 'AI text']); - $this->enricher->enrichAttribute($product, 'description'); + $this->enrichmentRecorder->expects($this->once()) + ->method('record') + ->with(42, 1, 'description', 'hash1', 'Describe Widget', 'AI text'); + + $this->enricher->execute($product); - $descriptionCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); - $this->assertEmpty($descriptionCalls); + $this->assertContains(['key' => 'description', 'value' => 'AI text'], $this->getProductSetDataCalls()); } - // --- enrichAttribute with overwrite --- + // --- execute: partial cache hit --- - public function testEnrichAttributeProcessesWhenOverwriteSet(): void + public function testExecutePartialCacheHit(): void { - $product = $this->createProductMock( - ['name' => 'Widget', 'description' => 'Existing', 'mageos_catalogai_overwrite' => true], - storeId: 1, - id: 123, - trackSetData: true - ); + $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->config->method('getProductPrompt') - ->with('description', 1) - ->willReturn('Describe {{name}}'); + $this->config->method('getConfiguredAttributes')->willReturn(['description', 'meta_title']); + $this->config->method('getProductPrompt')->willReturnMap([ + ['description', 1, 'Describe {{name}}'], + ['meta_title', 1, 'Title for {{name}}'], + ]); $this->config->method('isCacheEnabled')->willReturn(true); $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); - $this->hashGenerator->expects($this->once()) - ->method('generate') - ->with('Describe Widget', 'system', 'description', 1) - ->willReturn('somehash'); - - $enrichment = $this->createMock(EnrichmentInterface::class); - $enrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_APPROVED); - $enrichment->method('getGeneratedValue')->willReturn('AI text'); - $enrichment->method('getAppliedValue')->willReturn(null); - - $this->enrichmentRecorder->method('findByHash')->willReturn($enrichment); + $this->hashGenerator->method('generate') + ->willReturnMap([ + ['Describe Widget', 'system', 'description', 1, 'hash_desc'], + ['Title for Widget', 'system', 'meta_title', 1, 'hash_meta'], + ]); - $this->enricher->enrichAttribute($product, 'description'); - } + // description is cached (approved), meta_title is not + $cachedEnrichment = $this->createMock(EnrichmentInterface::class); + $cachedEnrichment->method('getStatus')->willReturn(EnrichmentInterface::STATUS_APPROVED); + $cachedEnrichment->method('getGeneratedValue')->willReturn('Cached description'); + $cachedEnrichment->method('getAppliedValue')->willReturn(null); - // --- enrichAttribute cache miss tests --- + $this->enrichmentRecorder->method('findByHash') + ->willReturnMap([ + ['hash_desc', 'description', 1, $cachedEnrichment], + ['hash_meta', 'meta_title', 1, null], + ]); - public function testCacheMissRecordsEnrichmentForExistingProduct(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $this->setUpCacheMissScenario('Describe Widget', 'hash1', 1, 'Generated text'); + // Batch should only contain meta_title + $this->aiClient->expects($this->once()) + ->method('generateBatch') + ->with('system', 'Name: Widget', ['meta_title' => 'Title for Widget']) + ->willReturn(['meta_title' => 'AI title']); - $this->enrichmentRecorder->expects($this->once()) - ->method('record') - ->with(42, 1, 'description', 'hash1', 'Describe Widget', 'Generated text'); + $this->enricher->execute($product); - $this->enricher->enrichAttribute($product, 'description'); + $calls = $this->getProductSetDataCalls(); + $this->assertContains(['key' => 'description', 'value' => 'Cached description'], $calls); + $this->assertContains(['key' => 'meta_title', 'value' => 'AI title'], $calls); } - public function testCacheMissStoresDeferredEnrichmentForNewProduct(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 0, trackSetData: true); + // --- execute: full cache hit --- - $this->setUpCacheMissScenario('Describe Widget', 'hash1', 1, 'Generated text'); - - $this->enrichmentRecorder->expects($this->never())->method('record'); + public function testExecuteFullCacheHitNoApiCalls(): void + { + $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->enricher->enrichAttribute($product, 'description'); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); + $this->config->method('isCacheEnabled')->willReturn(true); + $this->config->method('getSystemPrompt')->willReturn('system'); - $deferredCalls = array_filter( - $this->getProductSetDataCalls(), - fn($c) => $c['key'] === 'mageos_catalogai_deferred_enrichments' - ); - $this->assertNotEmpty($deferredCalls); + $this->hashGenerator->method('generate')->willReturn('hash1'); - $deferred = array_values($deferredCalls)[0]['value']; - $this->assertSame('description', $deferred[0]['attribute_code']); - $this->assertSame('hash1', $deferred[0]['prompt_hash']); - $this->assertSame('Generated text', $deferred[0]['generated_value']); - } + $cached = $this->createMock(EnrichmentInterface::class); + $cached->method('getStatus')->willReturn(EnrichmentInterface::STATUS_APPLIED); + $cached->method('getGeneratedValue')->willReturn('original'); + $cached->method('getAppliedValue')->willReturn('edited'); - public function testCacheMissSetsValueWhenApprovalNotRequired(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); + $this->enrichmentRecorder->method('findByHash')->willReturn($cached); - $this->setUpCacheMissScenario('Describe Widget', 'hash1', 1, 'Generated text'); - $this->config->method('isApprovalRequired')->willReturn(false); + $this->aiClient->expects($this->never())->method('generateBatch'); + $this->aiClient->expects($this->never())->method('generate'); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); - $this->assertContains( - ['key' => 'description', 'value' => 'Generated text'], - $this->getProductSetDataCalls() - ); + $this->assertContains(['key' => 'description', 'value' => 'edited'], $this->getProductSetDataCalls()); } - public function testCacheMissSkipsSettingValueWhenApprovalRequired(): void + // --- execute: cache hit with pending/denied status --- + + public function testExecuteCacheHitPendingDoesNotApply(): void { $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->setUpCacheMissScenario('Describe Widget', 'hash1', 1, 'Generated text'); - $this->config->method('isApprovalRequired')->willReturn(true); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); + $this->config->method('isCacheEnabled')->willReturn(true); + $this->config->method('getSystemPrompt')->willReturn('system'); - $this->enricher->enrichAttribute($product, 'description'); + $this->hashGenerator->method('generate')->willReturn('hash1'); - $descriptionCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); - $this->assertEmpty($descriptionCalls); - } + $pending = $this->createMock(EnrichmentInterface::class); + $pending->method('getStatus')->willReturn(EnrichmentInterface::STATUS_PENDING); - public function testCacheMissApiReturnsNullEarlyReturn(): void - { - $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - - $this->setUpCacheMissScenario('Describe Widget', 'hash1', 1, null); + $this->enrichmentRecorder->method('findByHash')->willReturn($pending); - $this->enrichmentRecorder->expects($this->never())->method('record'); + $this->aiClient->expects($this->never())->method('generateBatch'); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); - $descriptionCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); - $this->assertEmpty($descriptionCalls); + $descCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); + $this->assertEmpty($descCalls); } - // --- enrichAttribute cache disabled tests --- + // --- execute: batch fallback --- - public function testCacheDisabledApiSuccessSetsProductData(): void + public function testExecuteFallsBackToIndividualCallsOnBatchFailure(): void { $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->config->method('getProductPrompt') - ->with('description', 1) - ->willReturn('Describe {{name}}'); + $this->config->method('getConfiguredAttributes')->willReturn(['description', 'meta_title']); + $this->config->method('getProductPrompt')->willReturnMap([ + ['description', 1, 'Describe {{name}}'], + ['meta_title', 1, 'Title for {{name}}'], + ]); $this->config->method('isCacheEnabled')->willReturn(false); $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $this->aiClient->expects($this->once()) + // Batch returns empty = failure + $this->aiClient->method('generateBatch')->willReturn([]); + + // Fallback to individual calls + $this->aiClient->expects($this->exactly(2)) ->method('generate') - ->with('system', 'Describe Widget') - ->willReturn('AI output'); + ->willReturnMap([ + ['system', 'Describe Widget', 'Fallback description'], + ['system', 'Title for Widget', 'Fallback title'], + ]); - $this->enricher->enrichAttribute($product, 'description'); + $this->enricher->execute($product); - $this->assertContains( - ['key' => 'description', 'value' => 'AI output'], - $this->getProductSetDataCalls() - ); + $calls = $this->getProductSetDataCalls(); + $this->assertContains(['key' => 'description', 'value' => 'Fallback description'], $calls); + $this->assertContains(['key' => 'meta_title', 'value' => 'Fallback title'], $calls); } - public function testCacheDisabledApiReturnsNullDoesNothing(): void + // --- execute: approval required --- + + public function testExecuteApprovalRequiredDoesNotSetProductValues(): void { $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 42, trackSetData: true); - $this->config->method('getProductPrompt') - ->with('description', 1) - ->willReturn('Describe {{name}}'); - $this->config->method('isCacheEnabled')->willReturn(false); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); + $this->config->method('isCacheEnabled')->willReturn(true); $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(true); - $this->aiClient->expects($this->once()) - ->method('generate') - ->willReturn(null); + $this->hashGenerator->method('generate')->willReturn('hash1'); + $this->enrichmentRecorder->method('findByHash')->willReturn(null); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $this->enricher->enrichAttribute($product, 'description'); + $this->aiClient->method('generateBatch')->willReturn(['description' => 'AI text']); - $descriptionCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); - $this->assertEmpty($descriptionCalls); + $this->enrichmentRecorder->expects($this->once())->method('record'); + + $this->enricher->execute($product); + + $descCalls = array_filter($this->getProductSetDataCalls(), fn($c) => $c['key'] === 'description'); + $this->assertEmpty($descCalls); } - // --- execute tests --- + // --- execute: deferred enrichment for new products --- - public function testExecuteIteratesAllConfiguredAttributes(): void + public function testExecuteNewProductStoresDeferredEnrichment(): void { - $product = $this->createProductMock( - ['name' => 'Widget', 'store_id' => 1], - storeId: 1, - id: 42, - trackSetData: true - ); - - $this->config->method('getConfiguredAttributes') - ->with(1) - ->willReturn(['description', 'short_description']); + $product = $this->createProductMock(['name' => 'Widget'], storeId: 1, id: 0, trackSetData: true); - $this->config->method('getProductPrompt') - ->willReturnMap([ - ['description', 1, 'Describe {{name}}'], - ['short_description', 1, 'Short {{name}}'], - ]); - $this->config->method('isCacheEnabled')->willReturn(false); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); + $this->config->method('isCacheEnabled')->willReturn(true); $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); - $this->aiClient->expects($this->exactly(2)) - ->method('generate') - ->willReturn('AI text'); + $this->hashGenerator->method('generate')->willReturn('hash1'); + $this->enrichmentRecorder->method('findByHash')->willReturn(null); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); + + $this->aiClient->method('generateBatch')->willReturn(['description' => 'AI text']); + + $this->enrichmentRecorder->expects($this->never())->method('record'); $this->enricher->execute($product); - $this->assertContains(['key' => 'description', 'value' => 'AI text'], $this->getProductSetDataCalls()); - $this->assertContains(['key' => 'short_description', 'value' => 'AI text'], $this->getProductSetDataCalls()); + $deferredCalls = array_filter( + $this->getProductSetDataCalls(), + fn($c) => $c['key'] === 'mageos_catalogai_deferred_enrichments' + ); + $this->assertNotEmpty($deferredCalls); + + $deferred = array_values($deferredCalls)[0]['value']; + $this->assertSame('description', $deferred[0]['attribute_code']); + $this->assertSame('hash1', $deferred[0]['prompt_hash']); + $this->assertSame('AI text', $deferred[0]['generated_value']); } - public function testExecuteRetriesOnErrorException(): void + // --- execute: overwrite flag --- + + public function testExecuteOverwriteProcessesExistingValues(): void { $product = $this->createProductMock( - ['name' => 'Widget', 'store_id' => 1], + ['name' => 'Widget', 'description' => 'Old text', 'mageos_catalogai_overwrite' => true], storeId: 1, id: 42, trackSetData: true ); - $this->config->method('getConfiguredAttributes') - ->with(1) - ->willReturn(['description']); - - $this->config->method('getProductPrompt') - ->with('description', 1) - ->willReturn('Describe {{name}}'); + $this->config->method('getConfiguredAttributes')->willReturn(['description']); + $this->config->method('getProductPrompt')->willReturn('Describe {{name}}'); $this->config->method('isCacheEnabled')->willReturn(false); $this->config->method('getSystemPrompt')->willReturn('system'); + $this->config->method('isApprovalRequired')->willReturn(false); + $this->contextBuilder->method('build')->willReturn('Name: Widget'); - $errorException = (new \ReflectionClass(ErrorException::class))->newInstanceWithoutConstructor(); - - $this->aiClient->expects($this->exactly(2)) - ->method('generate') - ->willReturnOnConsecutiveCalls( - $this->throwException($errorException), - 'AI text' - ); + $this->aiClient->expects($this->once()) + ->method('generateBatch') + ->willReturn(['description' => 'New AI text']); $this->enricher->execute($product); - $this->assertContains(['key' => 'description', 'value' => 'AI text'], $this->getProductSetDataCalls()); - } - - // --- getAttributes --- - - public function testGetAttributesDelegatesToConfig(): void - { - $this->config->expects($this->once()) - ->method('getConfiguredAttributes') - ->with(null) - ->willReturn(['name', 'description']); - - $result = $this->enricher->getAttributes(); - - $this->assertSame(['name', 'description'], $result); - } - - public function testGetAttributesPassesStoreId(): void - { - $this->config->expects($this->once()) - ->method('getConfiguredAttributes') - ->with(5) - ->willReturn(['short_description']); - - $result = $this->enricher->getAttributes(5); - - $this->assertSame(['short_description'], $result); - } - - // --- Helpers --- - - private function setUpCacheHitScenario( - string $expectedParsedPrompt, - string $hash, - int $storeId - ): void { - $this->config->method('getProductPrompt') - ->with('description', $storeId) - ->willReturn('Describe {{name}}'); - $this->config->method('isCacheEnabled')->willReturn(true); - $this->config->method('getSystemPrompt')->willReturn('system'); - - $this->hashGenerator->method('generate') - ->with($expectedParsedPrompt, 'system', 'description', $storeId) - ->willReturn($hash); - } - - private function setUpCacheMissScenario( - string $expectedParsedPrompt, - string $hash, - int $storeId, - ?string $apiReturn - ): void { - $this->config->method('getProductPrompt') - ->with('description', $storeId) - ->willReturn('Describe {{name}}'); - $this->config->method('isCacheEnabled')->willReturn(true); - $this->config->method('getSystemPrompt')->willReturn('system'); - - $this->hashGenerator->method('generate') - ->with($expectedParsedPrompt, 'system', 'description', $storeId) - ->willReturn($hash); - - $this->enrichmentRecorder->method('findByHash') - ->with($hash, 'description', $storeId) - ->willReturn(null); - - $this->aiClient->method('generate') - ->with('system', $expectedParsedPrompt) - ->willReturn($apiReturn); + $this->assertContains( + ['key' => 'description', 'value' => 'New AI text'], + $this->getProductSetDataCalls() + ); } } diff --git a/Test/Unit/Model/Product/ProductContextBuilderTest.php b/Test/Unit/Model/Product/ProductContextBuilderTest.php new file mode 100644 index 0000000..5cc1343 --- /dev/null +++ b/Test/Unit/Model/Product/ProductContextBuilderTest.php @@ -0,0 +1,221 @@ +config = $this->createMock(Config::class); + $this->builder = new ProductContextBuilder($this->config); + } + + public function testBuildIncludesVisibleOnFrontAttributes(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attribute = $this->createAttributeMock('color', 'Color', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attribute], + ['color' => 'Red'] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Color: Red', $result); + } + + public function testBuildIncludesSearchableAttributes(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attribute = $this->createAttributeMock('sku', 'SKU', searchable: true); + $product = $this->createProductMockWithAttributes( + [$attribute], + ['sku' => 'ABC-123'] + ); + + $result = $this->builder->build($product); + + $this->assertSame('SKU: ABC-123', $result); + } + + public function testBuildExcludesNonVisibleNonSearchableAttributes(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $visible = $this->createAttributeMock('name', 'Name', visibleOnFront: true); + $hidden = $this->createAttributeMock('internal_code', 'Internal Code'); + $product = $this->createProductMockWithAttributes( + [$visible, $hidden], + ['name' => 'Widget', 'internal_code' => 'X99'] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Name: Widget', $result); + } + + public function testBuildSkipsEmptyValues(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attr1 = $this->createAttributeMock('name', 'Name', visibleOnFront: true); + $attr2 = $this->createAttributeMock('color', 'Color', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr1, $attr2], + ['name' => 'Widget', 'color' => ''] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Name: Widget', $result); + } + + public function testBuildSkipsNullValues(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attr = $this->createAttributeMock('color', 'Color', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr], + ['color' => null] + ); + + $result = $this->builder->build($product); + + $this->assertSame('', $result); + } + + public function testBuildSkipsArrayValues(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attr1 = $this->createAttributeMock('name', 'Name', visibleOnFront: true); + $attr2 = $this->createAttributeMock('media_gallery', 'Gallery', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr1, $attr2], + ['name' => 'Widget', 'media_gallery' => ['img1.jpg', 'img2.jpg']] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Name: Widget', $result); + } + + public function testBuildTruncatesLongValues(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(10); + + $attr = $this->createAttributeMock('description', 'Description', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr], + ['description' => 'This is a very long description text'] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Description: This is a ...', $result); + } + + public function testBuildDisablesTruncationWhenZero(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(0); + + $longValue = str_repeat('x', 5000); + $attr = $this->createAttributeMock('description', 'Description', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr], + ['description' => $longValue] + ); + + $result = $this->builder->build($product); + + $this->assertSame('Description: ' . $longValue, $result); + } + + public function testBuildFallsBackToAttributeCodeWhenNoLabel(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attr = $this->createAttributeMock('custom_attr', '', visibleOnFront: true); + $product = $this->createProductMockWithAttributes( + [$attr], + ['custom_attr' => 'value'] + ); + + $result = $this->builder->build($product); + + $this->assertSame('custom_attr: value', $result); + } + + public function testBuildMultipleAttributesJoinedByNewline(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $attr1 = $this->createAttributeMock('name', 'Name', visibleOnFront: true); + $attr2 = $this->createAttributeMock('color', 'Color', searchable: true); + $product = $this->createProductMockWithAttributes( + [$attr1, $attr2], + ['name' => 'Widget', 'color' => 'Blue'] + ); + + $result = $this->builder->build($product); + + $this->assertSame("Name: Widget\nColor: Blue", $result); + } + + public function testBuildReturnsEmptyStringForProductWithNoQualifyingAttributes(): void + { + $this->config->method('getContextValueMaxLength')->willReturn(500); + + $product = $this->createProductMockWithAttributes([], []); + + $result = $this->builder->build($product); + + $this->assertSame('', $result); + } + + // --- Helpers --- + + private function createAttributeMock( + string $code, + string $label, + bool $visibleOnFront = false, + bool $searchable = false + ): Attribute&MockObject { + $attr = $this->createMock(Attribute::class); + $attr->method('getAttributeCode')->willReturn($code); + $attr->method('getStoreLabel')->willReturn($label); + $attr->method('getIsVisibleOnFront')->willReturn($visibleOnFront); + $attr->method('getIsSearchable')->willReturn($searchable); + return $attr; + } + + private function createProductMockWithAttributes( + array $attributes, + array $data + ): Product&MockObject { + $product = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + + $product->method('getAttributes')->willReturn($attributes); + $product->method('getDataUsingMethod') + ->willReturnCallback(fn(string $key) => $data[$key] ?? null); + + return $product; + } +} diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 1fd631a..8b73aeb 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -77,6 +77,11 @@ + + + + validate-zero-or-greater validate-digits + diff --git a/etc/config.xml b/etc/config.xml index 35c00c4..c70129e 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -28,6 +28,7 @@ 0 0 0 + 500 diff --git a/etc/di.xml b/etc/di.xml index 8a59eb9..5b78a92 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -4,6 +4,8 @@ +