From 7a8f2692e56dbbe1cb75d62a1e8098ca64d66b86 Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Tue, 21 Jul 2026 18:14:33 +0000 Subject: [PATCH] feat: bulk generation for product descriptions (queue-based, P0 #2) - BulkJob DAL entity (frosh_ai_bulk_job) + migration - BulkGenerateMessage (AsyncMessageInterface) + BulkGenerateHandler - POST /api/_action/frosh-ai/product/bulk-generate + GET /api/_action/frosh-ai/bulk-job/{jobId} - FeatureId::BulkGeneration + featureBulkGeneration config flag (en/de) - Unit tests for handler (success, partial failure, missing job) --- src/Controller/Api/BulkGenerateController.php | 148 ++++++++ src/Entity/BulkJob/BulkJobCollection.php | 16 + src/Entity/BulkJob/BulkJobDefinition.php | 53 +++ src/Entity/BulkJob/BulkJobEntity.php | 140 ++++++++ src/Feature/FeatureId.php | 3 + src/Message/BulkGenerateHandler.php | 130 +++++++ src/Message/BulkGenerateMessage.php | 18 + .../Migration1783887000CreateBulkJob.php | 40 +++ src/Resources/config/config.xml | 9 + src/Resources/config/services.xml | 18 + tests/Unit/Feature/FeatureFlagsTest.php | 5 +- .../Unit/Message/BulkGenerateHandlerTest.php | 336 ++++++++++++++++++ 12 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 src/Controller/Api/BulkGenerateController.php create mode 100644 src/Entity/BulkJob/BulkJobCollection.php create mode 100644 src/Entity/BulkJob/BulkJobDefinition.php create mode 100644 src/Entity/BulkJob/BulkJobEntity.php create mode 100644 src/Message/BulkGenerateHandler.php create mode 100644 src/Message/BulkGenerateMessage.php create mode 100644 src/Migration/Migration1783887000CreateBulkJob.php create mode 100644 tests/Unit/Message/BulkGenerateHandlerTest.php diff --git a/src/Controller/Api/BulkGenerateController.php b/src/Controller/Api/BulkGenerateController.php new file mode 100644 index 0000000..88e0121 --- /dev/null +++ b/src/Controller/Api/BulkGenerateController.php @@ -0,0 +1,148 @@ + [ApiRouteScope::ID]])] +class BulkGenerateController extends AbstractController +{ + /** + * @param EntityRepository $bulkJobRepository + */ + public function __construct( + private readonly EntityRepository $bulkJobRepository, + private readonly MessageBusInterface $messageBus, + private readonly FeatureFlags $featureFlags, + private readonly AiClient $ai, + ) { + } + + #[Route( + path: '/api/_action/frosh-ai/product/bulk-generate', + name: 'api.action.frosh_ai.product.bulk_generate', + methods: ['POST'], + )] + public function enqueue(Request $request, Context $context): JsonResponse + { + /** @var array $payload */ + $payload = $request->toArray(); + + $productIds = $this->stringList($payload['productIds'] ?? null); + if ($productIds === []) { + return new JsonResponse(['success' => false, 'error' => 'productIds must be a non-empty list.'], 400); + } + + if (!$this->featureFlags->isMasterEnabled()) { + return new JsonResponse(['success' => false, 'error' => 'Frosh AI features are disabled. Enable them under Settings → Frosh AI.'], 400); + } + + if (!$this->featureFlags->isFeatureFlagOn(FeatureId::BulkGeneration)) { + return new JsonResponse(['success' => false, 'error' => 'Bulk generation is disabled. Enable it under Settings → Frosh AI → Features.'], 400); + } + + if (!$this->ai->isAvailable()) { + return new JsonResponse(['success' => false, 'error' => 'No AI provider is configured. Open Settings → Frosh AI.'], 400); + } + + $jobContext = array_filter([ + 'tone' => $payload['tone'] ?? null, + 'language' => $payload['language'] ?? null, + 'languageId' => $payload['languageId'] ?? null, + 'languageName' => $payload['languageName'] ?? null, + 'maxWords' => $payload['maxWords'] ?? null, + ], static fn (mixed $value): bool => $value !== null); + + $jobId = Uuid::randomHex(); + + try { + $this->bulkJobRepository->create([[ + 'id' => $jobId, + 'type' => 'product-description', + 'status' => 'queued', + 'total' => \count($productIds), + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'entityIds' => $productIds, + 'errors' => [], + 'context' => $jobContext, + ]], $context); + + $this->messageBus->dispatch(new BulkGenerateMessage($jobId)); + } catch (\Throwable $e) { + return new JsonResponse(['success' => false, 'error' => $e->getMessage()], 500); + } + + return new JsonResponse([ + 'success' => true, + 'jobId' => $jobId, + 'total' => \count($productIds), + ]); + } + + #[Route( + path: '/api/_action/frosh-ai/bulk-job/{jobId}', + name: 'api.action.frosh_ai.bulk_job.status', + methods: ['GET'], + )] + public function status(string $jobId, Context $context): JsonResponse + { + /** @var BulkJobEntity|null $job */ + $job = $this->bulkJobRepository->search(new Criteria([$jobId]), $context)->first(); + + if ($job === null) { + return new JsonResponse(['success' => false, 'error' => 'Bulk job not found.'], 404); + } + + return new JsonResponse([ + 'success' => true, + 'jobId' => $job->getId(), + 'type' => $job->getType(), + 'status' => $job->getStatus(), + 'total' => $job->getTotal(), + 'processed' => $job->getProcessed(), + 'succeeded' => $job->getSucceeded(), + 'failed' => $job->getFailed(), + 'errors' => $job->getErrors() ?? [], + ]); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + $out = []; + foreach ($value as $item) { + if (!\is_string($item)) { + continue; + } + $trimmed = trim($item); + if ($trimmed !== '' && Uuid::isValid($trimmed)) { + $out[] = $trimmed; + } + } + + return array_values(array_unique($out)); + } +} diff --git a/src/Entity/BulkJob/BulkJobCollection.php b/src/Entity/BulkJob/BulkJobCollection.php new file mode 100644 index 0000000..946a7af --- /dev/null +++ b/src/Entity/BulkJob/BulkJobCollection.php @@ -0,0 +1,16 @@ + + */ +class BulkJobCollection extends EntityCollection +{ + protected function getExpectedClass(): string + { + return BulkJobEntity::class; + } +} diff --git a/src/Entity/BulkJob/BulkJobDefinition.php b/src/Entity/BulkJob/BulkJobDefinition.php new file mode 100644 index 0000000..ad71437 --- /dev/null +++ b/src/Entity/BulkJob/BulkJobDefinition.php @@ -0,0 +1,53 @@ +addFlags(new PrimaryKey(), new Required(), new ApiAware()), + (new StringField('type', 'type', 32))->addFlags(new Required(), new ApiAware()), + (new StringField('status', 'status', 16))->addFlags(new Required(), new ApiAware()), + (new IntField('total', 'total'))->addFlags(new ApiAware()), + (new IntField('processed', 'processed'))->addFlags(new ApiAware()), + (new IntField('succeeded', 'succeeded'))->addFlags(new ApiAware()), + (new IntField('failed', 'failed'))->addFlags(new ApiAware()), + (new JsonField('entity_ids', 'entityIds'))->addFlags(new ApiAware()), + (new JsonField('errors', 'errors'))->addFlags(new ApiAware()), + (new JsonField('context', 'context'))->addFlags(new ApiAware()), + new CreatedAtField(), + new UpdatedAtField(), + ]); + } +} diff --git a/src/Entity/BulkJob/BulkJobEntity.php b/src/Entity/BulkJob/BulkJobEntity.php new file mode 100644 index 0000000..b83abf1 --- /dev/null +++ b/src/Entity/BulkJob/BulkJobEntity.php @@ -0,0 +1,140 @@ +|null */ + protected ?array $entityIds = null; + + /** @var list|null */ + protected ?array $errors = null; + + /** @var array|null */ + protected ?array $context = null; + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + $this->status = $status; + } + + public function getTotal(): int + { + return $this->total; + } + + public function setTotal(int $total): void + { + $this->total = $total; + } + + public function getProcessed(): int + { + return $this->processed; + } + + public function setProcessed(int $processed): void + { + $this->processed = $processed; + } + + public function getSucceeded(): int + { + return $this->succeeded; + } + + public function setSucceeded(int $succeeded): void + { + $this->succeeded = $succeeded; + } + + public function getFailed(): int + { + return $this->failed; + } + + public function setFailed(int $failed): void + { + $this->failed = $failed; + } + + /** + * @return list|null + */ + public function getEntityIds(): ?array + { + return $this->entityIds; + } + + /** + * @param list|null $entityIds + */ + public function setEntityIds(?array $entityIds): void + { + $this->entityIds = $entityIds; + } + + /** + * @return list|null + */ + public function getErrors(): ?array + { + return $this->errors; + } + + /** + * @param list|null $errors + */ + public function setErrors(?array $errors): void + { + $this->errors = $errors; + } + + /** + * @return array|null + */ + public function getContext(): ?array + { + return $this->context; + } + + /** + * @param array|null $context + */ + public function setContext(?array $context): void + { + $this->context = $context; + } +} diff --git a/src/Feature/FeatureId.php b/src/Feature/FeatureId.php index a1f14d0..7eed782 100644 --- a/src/Feature/FeatureId.php +++ b/src/Feature/FeatureId.php @@ -14,6 +14,7 @@ enum FeatureId: string case CategoryDescription = 'category_description'; case ManufacturerDescription = 'manufacturer_description'; case ReviewSummary = 'review_summary'; + case BulkGeneration = 'bulk_generation'; case ImageGeneration = 'image_generation'; case ImageEdit = 'image_edit'; @@ -26,6 +27,7 @@ public function configKey(): string self::CategoryDescription => 'FroshAI.config.featureCategoryDescription', self::ManufacturerDescription => 'FroshAI.config.featureManufacturerDescription', self::ReviewSummary => 'FroshAI.config.featureReviewSummary', + self::BulkGeneration => 'FroshAI.config.featureBulkGeneration', self::ImageGeneration => 'FroshAI.config.featureImageGeneration', self::ImageEdit => 'FroshAI.config.featureImageEdit', }; @@ -40,6 +42,7 @@ public function label(): string self::CategoryDescription => 'Category description', self::ManufacturerDescription => 'Manufacturer description', self::ReviewSummary => 'Review summary', + self::BulkGeneration => 'Bulk generation', self::ImageGeneration => 'Image generation', self::ImageEdit => 'Image editing', }; diff --git a/src/Message/BulkGenerateHandler.php b/src/Message/BulkGenerateHandler.php new file mode 100644 index 0000000..b1a4180 --- /dev/null +++ b/src/Message/BulkGenerateHandler.php @@ -0,0 +1,130 @@ + $bulkJobRepository + * @param EntityRepository $productRepository + */ + public function __construct( + private readonly EntityRepository $bulkJobRepository, + private readonly EntityRepository $productRepository, + private readonly ProductDescriptionGenerator $generator, + private readonly LoggerInterface $logger, + ) { + } + + public function __invoke(BulkGenerateMessage $message): void + { + $context = Context::createDefaultContext(); + + $job = $this->loadJob($message->getJobId(), $context); + if ($job === null) { + $this->logger->warning('Bulk job not found, skipping.', ['jobId' => $message->getJobId()]); + + return; + } + + $entityIds = $job->getEntityIds() ?? []; + $errors = $job->getErrors() ?? []; + $generatorContext = $job->getContext() ?? []; + + $processed = $job->getProcessed(); + $succeeded = $job->getSucceeded(); + $failed = $job->getFailed(); + + $this->persist($job->getId(), ['status' => 'running'], $context); + + foreach ($entityIds as $productId) { + if (!\is_string($productId) || $productId === '') { + continue; + } + + try { + $product = $this->loadProduct($productId, $context); + if ($product === null) { + throw new \RuntimeException(\sprintf('Product %s not found.', $productId)); + } + + $result = $this->generator->generate(\array_merge($generatorContext, [ + 'productId' => $productId, + 'name' => $product->getName(), + 'productNumber' => $product->getProductNumber(), + 'existingDescription' => $product->getDescription(), + ]), $context); + + $this->productRepository->update([[ + 'id' => $productId, + 'description' => $result['description'], + ]], $context); + + ++$succeeded; + } catch (\Throwable $e) { + ++$failed; + $errors[] = ['entityId' => $productId, 'message' => $e->getMessage()]; + $this->logger->error('Bulk generation failed for product.', [ + 'jobId' => $job->getId(), + 'productId' => $productId, + 'error' => $e->getMessage(), + ]); + } + + ++$processed; + + $this->persist($job->getId(), [ + 'processed' => $processed, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'errors' => $errors, + ], $context); + } + + $this->persist($job->getId(), [ + 'status' => $succeeded > 0 ? 'completed' : 'failed', + 'processed' => $processed, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'errors' => $errors, + ], $context); + } + + private function loadJob(string $jobId, Context $context): ?BulkJobEntity + { + /** @var BulkJobEntity|null $job */ + $job = $this->bulkJobRepository->search(new Criteria([$jobId]), $context)->first(); + + return $job; + } + + private function loadProduct(string $productId, Context $context): ?ProductEntity + { + /** @var ProductEntity|null $product */ + $product = $this->productRepository->search(new Criteria([$productId]), $context)->first(); + + return $product; + } + + /** + * @param array $data + */ + private function persist(string $jobId, array $data, Context $context): void + { + $this->bulkJobRepository->update([\array_merge(['id' => $jobId], $data)], $context); + } +} diff --git a/src/Message/BulkGenerateMessage.php b/src/Message/BulkGenerateMessage.php new file mode 100644 index 0000000..f68806b --- /dev/null +++ b/src/Message/BulkGenerateMessage.php @@ -0,0 +1,18 @@ +jobId; + } +} diff --git a/src/Migration/Migration1783887000CreateBulkJob.php b/src/Migration/Migration1783887000CreateBulkJob.php new file mode 100644 index 0000000..b6a906a --- /dev/null +++ b/src/Migration/Migration1783887000CreateBulkJob.php @@ -0,0 +1,40 @@ +executeStatement(<<<'SQL' + CREATE TABLE IF NOT EXISTS `frosh_ai_bulk_job` ( + `id` BINARY(16) NOT NULL, + `type` VARCHAR(32) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `total` INT NOT NULL DEFAULT 0, + `processed` INT NOT NULL DEFAULT 0, + `succeeded` INT NOT NULL DEFAULT 0, + `failed` INT NOT NULL DEFAULT 0, + `entity_ids` JSON NULL, + `errors` JSON NULL, + `context` JSON NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + INDEX `idx.frosh_ai_bulk_job.status` (`status`), + INDEX `idx.frosh_ai_bulk_job.created_at` (`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + SQL); + } +} diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml index 59e9a7a..46f24bc 100644 --- a/src/Resources/config/config.xml +++ b/src/Resources/config/config.xml @@ -69,6 +69,15 @@ true + + featureBulkGeneration + + + Queue-based generation of product descriptions for many products at once. Requires master switch on. + Warteschlangenbasierte Generierung von Produktbeschreibungen für viele Produkte gleichzeitig. Benötigt aktivierten Hauptschalter. + true + + featureImageGeneration diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 04d8cb7..59e000f 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -199,6 +199,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/Unit/Feature/FeatureFlagsTest.php b/tests/Unit/Feature/FeatureFlagsTest.php index e570dd4..6af178c 100644 --- a/tests/Unit/Feature/FeatureFlagsTest.php +++ b/tests/Unit/Feature/FeatureFlagsTest.php @@ -20,6 +20,7 @@ public function testMasterOffDisablesAll(): void static::assertFalse($flags->isEnabled(FeatureId::CategoryDescription)); static::assertFalse($flags->isEnabled(FeatureId::ManufacturerDescription)); static::assertFalse($flags->isEnabled(FeatureId::ReviewSummary)); + static::assertFalse($flags->isEnabled(FeatureId::BulkGeneration)); static::assertFalse($flags->isEnabled(FeatureId::ImageGeneration)); static::assertFalse($flags->isEnabled(FeatureId::ImageEdit)); static::assertTrue($flags->isFeatureFlagOn(FeatureId::ProductDescription)); @@ -49,6 +50,7 @@ public function testMissingFeatureKeyDefaultsToOn(): void static::assertTrue($flags->isEnabled(FeatureId::CategoryDescription)); static::assertTrue($flags->isEnabled(FeatureId::ManufacturerDescription)); static::assertTrue($flags->isEnabled(FeatureId::ReviewSummary)); + static::assertTrue($flags->isEnabled(FeatureId::BulkGeneration)); static::assertTrue($flags->isEnabled(FeatureId::ImageGeneration)); static::assertTrue($flags->isEnabled(FeatureId::ImageEdit)); } @@ -59,7 +61,7 @@ public function testPublicArray(): void $public = $flags->toPublicArray(); static::assertTrue($public['master']); - static::assertCount(8, $public['features']); + static::assertCount(9, $public['features']); $byId = []; foreach ($public['features'] as $row) { $byId[$row['id']] = $row; @@ -72,6 +74,7 @@ public function testPublicArray(): void static::assertArrayHasKey('category_description', $byId); static::assertArrayHasKey('manufacturer_description', $byId); static::assertArrayHasKey('review_summary', $byId); + static::assertArrayHasKey('bulk_generation', $byId); static::assertArrayHasKey('image_edit', $byId); } diff --git a/tests/Unit/Message/BulkGenerateHandlerTest.php b/tests/Unit/Message/BulkGenerateHandlerTest.php new file mode 100644 index 0000000..a42c070 --- /dev/null +++ b/tests/Unit/Message/BulkGenerateHandlerTest.php @@ -0,0 +1,336 @@ +createJob('job-1', ['p1', 'p2']); + + $jobUpdates = []; + $bulkJobRepository = $this->createMock(EntityRepository::class); + $bulkJobRepository->method('search')->willReturn($this->searchResult('frosh_ai_bulk_job', [$job], ['job-1'])); + $bulkJobRepository->method('update')->willReturnCallback( + function (array $data, Context $context) use (&$jobUpdates) { + $jobUpdates[] = $data; + + return self::writtenEvent(); + }, + ); + + $productUpdates = []; + $productRepository = $this->createMock(EntityRepository::class); + $productRepository->method('search')->willReturnCallback( + fn (Criteria $criteria): EntitySearchResult => $this->searchResult( + 'product', + [$this->createProduct($criteria->getIds()[0])], + $criteria->getIds(), + ), + ); + $productRepository->method('update')->willReturnCallback( + function (array $data, Context $context) use (&$productUpdates) { + $productUpdates[] = $data; + + return self::writtenEvent(); + }, + ); + + $handler = new BulkGenerateHandler( + $bulkJobRepository, + $productRepository, + $this->createGenerator(), + new NullLogger(), + ); + $handler(new BulkGenerateMessage('job-1')); + + static::assertCount(2, $productUpdates); + static::assertSame('p1', $productUpdates[0][0]['id']); + static::assertSame('

Description for p1

', $productUpdates[0][0]['description']); + static::assertSame('p2', $productUpdates[1][0]['id']); + + // First update sets status to running, then one per product, then the final status. + static::assertSame('running', $jobUpdates[0][0]['status']); + + $final = end($jobUpdates); + static::assertSame('completed', $final[0]['status']); + static::assertSame(2, $final[0]['processed']); + static::assertSame(2, $final[0]['succeeded']); + static::assertSame(0, $final[0]['failed']); + static::assertSame([], $final[0]['errors']); + } + + public function testGeneratorFailureRecordsErrorAndStillCompletesJob(): void + { + $job = $this->createJob('job-2', ['p1', 'p2']); + + $jobUpdates = []; + $bulkJobRepository = $this->createMock(EntityRepository::class); + $bulkJobRepository->method('search')->willReturn($this->searchResult('frosh_ai_bulk_job', [$job], ['job-2'])); + $bulkJobRepository->method('update')->willReturnCallback( + function (array $data, Context $context) use (&$jobUpdates) { + $jobUpdates[] = $data; + + return self::writtenEvent(); + }, + ); + + $productUpdates = []; + $productRepository = $this->createMock(EntityRepository::class); + $productRepository->method('search')->willReturnCallback( + fn (Criteria $criteria): EntitySearchResult => $this->searchResult( + 'product', + [$this->createProduct($criteria->getIds()[0])], + $criteria->getIds(), + ), + ); + $productRepository->method('update')->willReturnCallback( + function (array $data, Context $context) use (&$productUpdates) { + $productUpdates[] = $data; + + return self::writtenEvent(); + }, + ); + + $handler = new BulkGenerateHandler( + $bulkJobRepository, + $productRepository, + $this->createGenerator(failForProductId: 'p1'), + new NullLogger(), + ); + $handler(new BulkGenerateMessage('job-2')); + + static::assertCount(1, $productUpdates); + static::assertSame('p2', $productUpdates[0][0]['id']); + + $final = end($jobUpdates); + static::assertSame('completed', $final[0]['status']); + static::assertSame(2, $final[0]['processed']); + static::assertSame(1, $final[0]['succeeded']); + static::assertSame(1, $final[0]['failed']); + static::assertSame([['entityId' => 'p1', 'message' => 'AI exploded']], $final[0]['errors']); + } + + public function testMissingJobIsSkipped(): void + { + $bulkJobRepository = $this->createMock(EntityRepository::class); + $bulkJobRepository->method('search')->willReturn($this->searchResult('frosh_ai_bulk_job', [], ['missing'])); + $bulkJobRepository->expects(static::never())->method('update'); + + $productRepository = $this->createMock(EntityRepository::class); + $productRepository->expects(static::never())->method('search'); + + $handler = new BulkGenerateHandler( + $bulkJobRepository, + $productRepository, + $this->createGenerator(), + new NullLogger(), + ); + $handler(new BulkGenerateMessage('missing')); + + static::assertTrue(true); // no exception + } + + /** + * @param list $productIds + */ + private function createJob(string $id, array $productIds): BulkJobEntity + { + $job = new BulkJobEntity(); + $job->setId($id); + $job->setUniqueIdentifier($id); + $job->setType('product-description'); + $job->setStatus('queued'); + $job->setTotal(\count($productIds)); + $job->setEntityIds($productIds); + $job->setErrors([]); + $job->setContext(['tone' => 'professional']); + + return $job; + } + + private function createProduct(string $id): ProductEntity + { + $product = new ProductEntity(); + $product->setId($id); + $product->setUniqueIdentifier($id); + $product->setName('Product ' . $id); + $product->setProductNumber('SW-' . $id); + + return $product; + } + + /** + * @param list $entities + * @param list $ids + */ + private function searchResult(string $entity, array $entities, array $ids): EntitySearchResult + { + return new EntitySearchResult( + $entity, + \count($entities), + new EntityCollection($entities), + null, + new Criteria($ids), + Context::createDefaultContext(), + ); + } + + /** + * Builds a real generator (final, cannot be doubled) backed by a fake LLM + * that echoes the product id from request metadata — or throws for one id. + */ + private function createGenerator(?string $failForProductId = null): ProductDescriptionGenerator + { + $config = $this->createMock(SystemConfigService::class); + $config->method('getBool')->willReturnCallback( + static fn (string $key): bool => $key === 'FroshAI.config.featuresEnabled', + ); + $config->method('getString')->willReturnCallback( + static fn (string $key): string => $key === 'FroshAI.config.productDescriptionDefaultTone' ? 'professional' : '', + ); + // null = per-feature defaults to on + $config->method('get')->willReturn(null); + + $emptyLanguages = new EntitySearchResult( + 'language', + 0, + new EntityCollection(), + null, + new Criteria(), + Context::createDefaultContext(), + ); + $languageRepository = $this->createMock(EntityRepository::class); + $languageRepository->method('search')->willReturn($emptyLanguages); + + return new ProductDescriptionGenerator( + $this->createAiClient($config, $failForProductId), + $config, + $languageRepository, + new ProductDescriptionToneStore($config), + new FeatureFlags($config), + ); + } + + private function createAiClient(SystemConfigService $config, ?string $failForProductId): AiClient + { + $llm = new class($failForProductId) implements LlmClientInterface { + public function __construct(private ?string $failForProductId) + { + } + + public function chat(ChatRequest $request): ChatResponse + { + $productId = (string) ($request->metadata['entityId'] ?? ''); + if ($productId !== '' && $productId === $this->failForProductId) { + throw new \RuntimeException('AI exploded'); + } + + return new ChatResponse('

Description for ' . $productId . '

', 'openai', 'gpt-4o'); + } + + public function generateImage(ImageRequest $request): ImageResponse + { + throw new \LogicException('not used'); + } + }; + + $provider = new class($llm) implements AiProviderInterface { + public function __construct(private LlmClientInterface $client) + { + } + + public function getId(): string + { + return 'openai'; + } + + public function getName(): string + { + return 'OpenAI'; + } + + public function isConfigured(?string $salesChannelId = null): bool + { + return true; + } + + public function getClient(): LlmClientInterface + { + return $this->client; + } + + public function getCapabilities(): array + { + return [Capability::TextGeneration]; + } + + public function getDescriptor(): ProviderDescriptor + { + return new ProviderDescriptor('openai', 'OpenAI', 'test'); + } + + public function getAdminStatus(?string $salesChannelId = null): array + { + return ['configured' => true, 'summary' => 'test', 'defaultModel' => 'gpt-4o']; + } + }; + + $store = new OpenAiCompatibleEndpointStore($config, new SecretEncryptor('test')); + $registry = new ProviderRegistry([$provider], $store, new MockHttpClient()); + $catalog = $this->createMock(ModelCatalogInterface::class); + $catalog->method('getModel')->willReturn(null); + + return new AiClient( + $registry, + new ModelResolver($registry, new CapabilityDefaultsStore($config), $catalog), + $catalog, + new EventDispatcher(), + new FeatureFlags($config), + ); + } + + private static function writtenEvent(): EntityWrittenContainerEvent + { + return new EntityWrittenContainerEvent( + Context::createDefaultContext(), + new NestedEventCollection(), + [], + ); + } +}