diff --git a/src/Controller/Api/CategoryDescriptionController.php b/src/Controller/Api/CategoryDescriptionController.php new file mode 100644 index 0000000..fc09b62 --- /dev/null +++ b/src/Controller/Api/CategoryDescriptionController.php @@ -0,0 +1,81 @@ + [ApiRouteScope::ID]])] +class CategoryDescriptionController extends AbstractController +{ + public function __construct( + private readonly CategoryDescriptionGenerator $generator, + private readonly ProductDescriptionToneStore $toneStore, + ) { + } + + #[Route( + path: '/api/_action/frosh-ai/category/generate-description', + name: 'api.action.frosh_ai.category.generate_description', + methods: ['POST'], + )] + public function generate(Request $request, Context $context): JsonResponse + { + /** @var array $payload */ + $payload = $request->toArray(); + + try { + $result = $this->generator->generate($payload, $context); + } catch (NoProviderAvailableException $e) { + return $this->providerError($e); + } catch (AiException $e) { + return new JsonResponse(['success' => false, 'error' => $e->getMessage()], 400); + } catch (\Throwable $e) { + return new JsonResponse(['success' => false, 'error' => $e->getMessage()], 500); + } + + return new JsonResponse([ + 'success' => true, + 'description' => $result['description'], + 'provider' => $result['provider'], + 'model' => $result['model'], + 'language' => $result['language'], + 'languageName' => $result['languageName'], + 'tone' => $result['tone'], + 'toneLabel' => $result['toneLabel'] ?? null, + ]); + } + + #[Route( + path: '/api/_action/frosh-ai/category/description-status', + name: 'api.action.frosh_ai.category.description_status', + methods: ['GET'], + )] + public function status(): JsonResponse + { + return new JsonResponse([ + 'enabled' => $this->generator->isEnabled(), + 'defaultTone' => $this->toneStore->defaultId(), + 'tones' => $this->toneStore->toPublicList(), + ]); + } + + private function providerError(NoProviderAvailableException $e): JsonResponse + { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage() !== '' + ? $e->getMessage() + : 'No AI provider is configured. Open Settings → Frosh AI.', + ], 400); + } +} diff --git a/src/Feature/CategoryDescription/CategoryDescriptionGenerator.php b/src/Feature/CategoryDescription/CategoryDescriptionGenerator.php new file mode 100644 index 0000000..4e085a7 --- /dev/null +++ b/src/Feature/CategoryDescription/CategoryDescriptionGenerator.php @@ -0,0 +1,313 @@ + $languageRepository + */ + public function __construct( + private readonly AiClient $ai, + private readonly SystemConfigService $systemConfigService, + private readonly EntityRepository $languageRepository, + private readonly ProductDescriptionToneStore $toneStore, + private readonly FeatureFlags $featureFlags, + ) { + } + + public function isEnabled(?string $salesChannelId = null): bool + { + return $this->featureFlags->isEnabled(FeatureId::CategoryDescription, $salesChannelId) + && $this->ai->isAvailable($salesChannelId); + } + + public function defaultTone(?string $salesChannelId = null): string + { + return $this->toneStore->defaultId($salesChannelId); + } + + /** + * @return list + */ + public function availableTones(?string $salesChannelId = null): array + { + return $this->toneStore->toPublicList($salesChannelId); + } + + /** + * @param array{ + * name?: string|null, + * parentCategories?: list|null, + * keywords?: string|null, + * existingDescription?: string|null, + * metaTitle?: string|null, + * metaDescription?: string|null, + * tone?: string|null, + * language?: string|null, + * languageId?: string|null, + * languageName?: string|null, + * maxWords?: int|null, + * categoryId?: string|null, + * } $context + * + * @return array{description: string, provider: string, model: string, language: string, languageName: string|null, tone: string, toneLabel: string} + */ + public function generate(array $context, ?Context $contextShopware = null, ?string $salesChannelId = null): array + { + if (!$this->featureFlags->isMasterEnabled($salesChannelId)) { + throw new AiException('Frosh AI features are disabled. Enable them under Settings → Frosh AI.'); + } + + if (!$this->featureFlags->isFeatureFlagOn(FeatureId::CategoryDescription, $salesChannelId)) { + throw new AiException('Category description generation is disabled. Enable it under Settings → Frosh AI → Features.'); + } + + if (!$this->ai->isAvailable($salesChannelId)) { + throw new NoProviderAvailableException(); + } + + $name = trim((string) ($context['name'] ?? '')); + if ($name === '') { + throw new AiException('Category name is required to generate a description.'); + } + + $tonePreset = $this->resolveTonePreset( + (string) ($context['tone'] ?? ''), + $salesChannelId, + ); + $resolvedLanguage = $this->resolveLanguage($context, $contextShopware ?? Context::createDefaultContext()); + + $maxWords = (int) ($context['maxWords'] ?? 180); + $maxWords = max(40, min(600, $maxWords)); + + $languageLine = $resolvedLanguage['name'] !== null && $resolvedLanguage['name'] !== '' + ? \sprintf( + 'Write the entire category description in %s (locale %s). Do not mix languages.', + $resolvedLanguage['name'], + $resolvedLanguage['locale'], + ) + : \sprintf( + 'Write the entire category description in locale %s. Do not mix languages.', + $resolvedLanguage['locale'], + ); + + // /no_think is respected by Qwen3+; other models ignore it as plain text. + $system = implode("\n", [ + '/no_think', + 'You are an e-commerce copywriter for a Shopware online store.', + $languageLine, + \sprintf('Tone preset: %s (%s).', $tonePreset->label, $tonePreset->id), + 'Tone / style instructions: ' . $tonePreset->instruction, + 'Return only HTML suitable for a category description field (use

,

    ,
  • , when helpful).', + 'Put the final HTML in the assistant message content field only — do not leave content empty.', + 'Do not use chain-of-thought, reasoning blocks, or markdown code fences.', + 'Do not invent certifications, prices, shipping promises, or claims not supported by the category data.', + 'Aim for about ' . $maxWords . ' words.', + 'Focus on benefits and concrete details from the category data.', + ]); + + $user = $this->buildUserPrompt($context, $name); + + // Reasoning models (e.g. Qwen3 on LM Studio) spend tokens on reasoning_content + // first; keep a generous completion budget so the final HTML still fits. + $maxTokens = max(1024, min(4096, $maxWords * 8 + 512)); + + $response = $this->ai->prompt($user) + ->system($system) + ->requireCapability(Capability::TextGeneration) + ->temperature(0.55) + ->maxTokens($maxTokens) + ->withMetadata([ + 'extension' => 'FroshAI', + 'feature' => self::FEATURE, + 'entity' => 'category', + 'entityId' => (string) ($context['categoryId'] ?? ''), + 'language' => $resolvedLanguage['locale'], + 'languageId' => $resolvedLanguage['id'], + 'tone' => $tonePreset->id, + ]) + ->generate(); + + $description = $this->sanitizeHtml($response->content); + if ($description === '') { + $finish = $response->finishReason->value; + $hint = $finish === 'length' + ? ' The model hit the token limit while reasoning (common with local “thinking” models). Try a non-reasoning model or raise the completion budget.' + : ' Try a non-reasoning chat model, or disable thinking mode in LM Studio / Ollama.'; + + throw new AiException( + 'The AI returned an empty description.' . $hint, + ); + } + + return [ + 'description' => $description, + 'provider' => $response->providerId, + 'model' => $response->model, + 'language' => $resolvedLanguage['locale'], + 'languageName' => $resolvedLanguage['name'], + 'tone' => $tonePreset->id, + 'toneLabel' => $tonePreset->label, + ]; + } + + private function resolveTonePreset(string $toneId, ?string $salesChannelId): TonePreset + { + $toneId = trim($toneId); + if ($toneId !== '') { + $preset = $this->toneStore->get($toneId, $salesChannelId); + if ($preset !== null) { + return $preset; + } + } + + return $this->toneStore->default($salesChannelId); + } + + /** + * @param array $context + * + * @return array{id: string|null, locale: string, name: string|null} + */ + private function resolveLanguage(array $context, Context $shopwareContext): array + { + $locale = trim((string) ($context['language'] ?? '')); + $name = trim((string) ($context['languageName'] ?? '')); + $languageId = trim((string) ($context['languageId'] ?? '')); + + if ($languageId !== '' && ($locale === '' || $name === '')) { + $criteria = new Criteria([$languageId]); + $criteria->addAssociation('translationCode'); + $criteria->addAssociation('locale'); + + /** @var LanguageEntity|null $language */ + $language = $this->languageRepository->search($criteria, $shopwareContext)->first(); + if ($language !== null) { + if ($name === '') { + $name = $language->getName() ?? ''; + } + if ($locale === '') { + $locale = $language->getTranslationCode()?->getCode() + ?? $language->getLocale()?->getCode() + ?? ''; + } + } + } + + if ($locale === '') { + $locale = 'en-GB'; + } + + return [ + 'id' => $languageId !== '' ? $languageId : null, + 'locale' => $locale, + 'name' => $name !== '' ? $name : null, + ]; + } + + /** + * @param array $context + */ + private function buildUserPrompt(array $context, string $name): string + { + $lines = [ + 'Generate a category description for:', + 'Name: ' . $name, + ]; + + $parentCategories = $this->stringList($context['parentCategories'] ?? null); + if ($parentCategories !== []) { + $lines[] = 'Parent categories: ' . implode(' > ', $parentCategories); + } + + $keywords = trim((string) ($context['keywords'] ?? '')); + if ($keywords !== '') { + $lines[] = 'Keywords: ' . $keywords; + } + + $metaTitle = trim((string) ($context['metaTitle'] ?? '')); + if ($metaTitle !== '') { + $lines[] = 'Meta title: ' . $metaTitle; + } + + $metaDescription = trim((string) ($context['metaDescription'] ?? '')); + if ($metaDescription !== '') { + $lines[] = 'Meta description: ' . $metaDescription; + } + + $existing = trim(strip_tags((string) ($context['existingDescription'] ?? ''))); + if ($existing !== '') { + $lines[] = 'Existing description (improve / rewrite, keep useful facts):'; + $lines[] = mb_substr($existing, 0, 2000); + } + + $extra = trim((string) ($context['extraInstructions'] ?? '')); + if ($extra !== '') { + $lines[] = 'Additional merchant instructions (follow when possible):'; + $lines[] = mb_substr($extra, 0, 1500); + } + + return implode("\n", $lines); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + $out = []; + foreach ($value as $item) { + if (!\is_string($item) && !is_numeric($item)) { + continue; + } + $trimmed = trim((string) $item); + if ($trimmed !== '') { + $out[] = $trimmed; + } + } + + return $out; + } + + private function sanitizeHtml(string $html): string + { + $html = trim($html); + // Drop accidental markdown fences. + $html = preg_replace('/^```(?:html)?\s*/i', '', $html) ?? $html; + $html = preg_replace('/\s*```$/', '', $html) ?? $html; + $html = trim($html); + + // Strip script/style and keep a safe subset. + $html = preg_replace('#<(script|style)\b[^>]*>.*?#is', '', $html) ?? $html; + $allowed = '


      1. '; + $html = strip_tags($html, $allowed); + + return trim($html); + } +} diff --git a/src/Feature/FeatureId.php b/src/Feature/FeatureId.php index 46be5cd..7bec248 100644 --- a/src/Feature/FeatureId.php +++ b/src/Feature/FeatureId.php @@ -11,6 +11,7 @@ enum FeatureId: string case ProductDescription = 'product_description'; case ProductTitle = 'product_title'; case ProductSeo = 'product_seo'; + case CategoryDescription = 'category_description'; case ImageGeneration = 'image_generation'; case ImageEdit = 'image_edit'; @@ -20,6 +21,7 @@ public function configKey(): string self::ProductDescription => 'FroshAI.config.featureProductDescription', self::ProductTitle => 'FroshAI.config.featureProductTitle', self::ProductSeo => 'FroshAI.config.featureProductSeo', + self::CategoryDescription => 'FroshAI.config.featureCategoryDescription', self::ImageGeneration => 'FroshAI.config.featureImageGeneration', self::ImageEdit => 'FroshAI.config.featureImageEdit', }; @@ -31,6 +33,7 @@ public function label(): string self::ProductDescription => 'Product description', self::ProductTitle => 'Product title', self::ProductSeo => 'Product SEO fields', + self::CategoryDescription => 'Category description', self::ImageGeneration => 'Image generation', self::ImageEdit => 'Image editing', }; diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml index f3e095f..d585578 100644 --- a/src/Resources/config/config.xml +++ b/src/Resources/config/config.xml @@ -42,6 +42,15 @@ true + + featureCategoryDescription + + + Category form: Generate with AI. Requires master switch on. + Kategorieformular: Mit KI generieren. Ben\u00f6tigt aktivierten Hauptschalter. + true + + featureImageGeneration diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index ded31f4..b925f94 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -155,6 +155,20 @@ + + + + + + + + + + + + + + diff --git a/tests/Unit/Feature/CategoryDescriptionGeneratorTest.php b/tests/Unit/Feature/CategoryDescriptionGeneratorTest.php new file mode 100644 index 0000000..a1e5bba --- /dev/null +++ b/tests/Unit/Feature/CategoryDescriptionGeneratorTest.php @@ -0,0 +1,278 @@ +createGenerator( + featuresEnabled: false, + available: true, + content: '

        x

        ', + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('disabled'); + + $generator->generate(['name' => 'Sneaker']); + } + + public function testThrowsWhenNameMissing(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + available: true, + content: '

        x

        ', + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('name'); + + $generator->generate(['name' => ' ']); + } + + public function testGenerateUsesToneInstructionAndLanguage(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + available: true, + content: "```html\n

        Great category

        \n```", + ); + + $result = $generator->generate([ + 'name' => 'Running Shoes', + 'parentCategories' => ['Catalog', 'Shoes'], + 'keywords' => 'running, shoes, sport', + 'metaTitle' => 'Running Shoes', + 'metaDescription' => 'Shop running shoes', + 'tone' => 'casual', + 'languageId' => 'lang-de', + 'language' => 'de-DE', + 'languageName' => 'Deutsch', + 'categoryId' => 'abc', + ]); + + static::assertSame('openai', $result['provider']); + static::assertSame('gpt-4o', $result['model']); + static::assertSame('de-DE', $result['language']); + static::assertSame('Deutsch', $result['languageName']); + static::assertSame('casual', $result['tone']); + static::assertSame('Casual', $result['toneLabel']); + static::assertStringContainsString('

        Great category

        ', $result['description']); + static::assertStringNotContainsString('script', $result['description']); + } + + public function testResolvesLocaleFromLanguageId(): void + { + $locale = new LocaleEntity(); + $locale->setUniqueIdentifier('loc-1'); + $locale->setCode('fr-FR'); + + $language = new LanguageEntity(); + $language->setUniqueIdentifier('lang-fr'); + $language->setId('lang-fr'); + $language->setName('Français'); + $language->setTranslationCode($locale); + + $result = new EntitySearchResult( + 'language', + 1, + new EntityCollection([$language]), + null, + new Criteria(['lang-fr']), + Context::createDefaultContext(), + ); + + $repo = $this->createMock(EntityRepository::class); + $repo->method('search')->willReturn($result); + + $generator = $this->createGenerator( + featuresEnabled: true, + available: true, + content: '

        Chaussure

        ', + languageRepository: $repo, + ); + + $out = $generator->generate([ + 'name' => 'Sneaker', + 'languageId' => 'lang-fr', + ]); + + static::assertSame('fr-FR', $out['language']); + static::assertSame('Français', $out['languageName']); + } + + public function testDefaultToneFromStore(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + available: true, + content: '

        x

        ', + defaultTone: 'luxury', + ); + + static::assertSame('luxury', $generator->defaultTone()); + static::assertTrue($generator->isEnabled()); + static::assertNotEmpty($generator->availableTones()); + } + + private function createGenerator( + bool $featuresEnabled, + bool $available, + string $content, + string $defaultTone = 'professional', + ?EntityRepository $languageRepository = null, + ): CategoryDescriptionGenerator { + $config = $this->createMock(SystemConfigService::class); + $config->method('getBool')->willReturnCallback( + static function (string $key) use ($featuresEnabled): bool { + return $key === 'FroshAI.config.featuresEnabled' ? $featuresEnabled : false; + }, + ); + $config->method('getString')->willReturnCallback( + static function (string $key) use ($defaultTone): string { + return match ($key) { + 'FroshAI.config.productDescriptionDefaultTone' => $defaultTone, + default => '', + }; + }, + ); + // null = per-feature defaults to on + $config->method('get')->willReturn(null); + + if ($languageRepository === null) { + $empty = new EntitySearchResult( + 'language', + 0, + new EntityCollection(), + null, + new Criteria(), + Context::createDefaultContext(), + ); + $languageRepository = $this->createMock(EntityRepository::class); + $languageRepository->method('search')->willReturn($empty); + } + + $toneStore = new ProductDescriptionToneStore($config); + $featureFlags = new FeatureFlags($config); + + return new CategoryDescriptionGenerator( + $this->createAiClient($available, $content), + $config, + $languageRepository, + $toneStore, + $featureFlags, + ); + } + + private function createAiClient(bool $available, string $content): AiClient + { + $llm = new class($content) implements LlmClientInterface { + public function __construct(private string $content) + { + } + + public function chat(ChatRequest $request): ChatResponse + { + return new ChatResponse($this->content, 'openai', 'gpt-4o'); + } + + public function generateImage(ImageRequest $request): ImageResponse + { + throw new \LogicException('not used'); + } + }; + + $provider = new class($llm, $available) implements AiProviderInterface { + public function __construct( + private LlmClientInterface $client, + private bool $configured, + ) { + } + + public function getId(): string + { + return 'openai'; + } + + public function getName(): string + { + return 'OpenAI'; + } + + public function isConfigured(?string $salesChannelId = null): bool + { + return $this->configured; + } + + 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' => $this->configured, 'summary' => 'test', 'defaultModel' => 'gpt-4o']; + } + }; + + $config = $this->createMock(SystemConfigService::class); + $config->method('getString')->willReturn(''); + $config->method('getBool')->willReturn(true); + $config->method('get')->willReturn(null); + $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), + ); + } +} diff --git a/tests/Unit/Feature/FeatureFlagsTest.php b/tests/Unit/Feature/FeatureFlagsTest.php index 4f56ed1..2f63aac 100644 --- a/tests/Unit/Feature/FeatureFlagsTest.php +++ b/tests/Unit/Feature/FeatureFlagsTest.php @@ -17,6 +17,7 @@ public function testMasterOffDisablesAll(): void static::assertFalse($flags->isEnabled(FeatureId::ProductDescription)); static::assertFalse($flags->isEnabled(FeatureId::ProductTitle)); static::assertFalse($flags->isEnabled(FeatureId::ProductSeo)); + static::assertFalse($flags->isEnabled(FeatureId::CategoryDescription)); static::assertFalse($flags->isEnabled(FeatureId::ImageGeneration)); static::assertFalse($flags->isEnabled(FeatureId::ImageEdit)); static::assertTrue($flags->isFeatureFlagOn(FeatureId::ProductDescription)); @@ -43,6 +44,7 @@ public function testMissingFeatureKeyDefaultsToOn(): void static::assertTrue($flags->isEnabled(FeatureId::ProductDescription)); static::assertTrue($flags->isEnabled(FeatureId::ProductTitle)); static::assertTrue($flags->isEnabled(FeatureId::ProductSeo)); + static::assertTrue($flags->isEnabled(FeatureId::CategoryDescription)); static::assertTrue($flags->isEnabled(FeatureId::ImageGeneration)); static::assertTrue($flags->isEnabled(FeatureId::ImageEdit)); } @@ -53,7 +55,7 @@ public function testPublicArray(): void $public = $flags->toPublicArray(); static::assertTrue($public['master']); - static::assertCount(5, $public['features']); + static::assertCount(6, $public['features']); $byId = []; foreach ($public['features'] as $row) { $byId[$row['id']] = $row; @@ -63,6 +65,7 @@ public function testPublicArray(): void static::assertTrue($byId['image_generation']['effective']); static::assertArrayHasKey('product_title', $byId); static::assertArrayHasKey('product_seo', $byId); + static::assertArrayHasKey('category_description', $byId); static::assertArrayHasKey('image_edit', $byId); }