diff --git a/src/Controller/Api/ReviewSummaryController.php b/src/Controller/Api/ReviewSummaryController.php new file mode 100644 index 0000000..a5593e4 --- /dev/null +++ b/src/Controller/Api/ReviewSummaryController.php @@ -0,0 +1,83 @@ + [ApiRouteScope::ID]])] +class ReviewSummaryController extends AbstractController +{ + public function __construct( + private readonly ReviewSummaryGenerator $generator, + ) { + } + + #[Route( + path: '/api/_action/frosh-ai/product/review-summary', + name: 'api.action.frosh_ai.product.review_summary', + methods: ['POST'], + )] + public function summarize(Request $request, Context $context): JsonResponse + { + /** @var array $payload */ + $payload = $request->toArray(); + $productId = trim((string) ($payload['productId'] ?? '')); + + if ($productId === '') { + return new JsonResponse(['success' => false, 'error' => 'productId is required.'], 400); + } + + try { + $result = $this->generator->summarize($productId, $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, + 'summary' => $result['summary'], + 'pros' => $result['pros'], + 'cons' => $result['cons'], + 'themes' => $result['themes'], + 'reviewCount' => $result['reviewCount'], + 'averageRating' => $result['averageRating'], + 'provider' => $result['provider'], + 'model' => $result['model'], + ]); + } + + #[Route( + path: '/api/_action/frosh-ai/product/review-summary-status', + name: 'api.action.frosh_ai.product.review_summary_status', + methods: ['GET'], + )] + public function status(): JsonResponse + { + return new JsonResponse([ + 'enabled' => $this->generator->isEnabled(), + ]); + } + + 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/FeatureId.php b/src/Feature/FeatureId.php index c763604..a1f14d0 100644 --- a/src/Feature/FeatureId.php +++ b/src/Feature/FeatureId.php @@ -13,6 +13,7 @@ enum FeatureId: string case ProductSeo = 'product_seo'; case CategoryDescription = 'category_description'; case ManufacturerDescription = 'manufacturer_description'; + case ReviewSummary = 'review_summary'; case ImageGeneration = 'image_generation'; case ImageEdit = 'image_edit'; @@ -24,6 +25,7 @@ public function configKey(): string self::ProductSeo => 'FroshAI.config.featureProductSeo', self::CategoryDescription => 'FroshAI.config.featureCategoryDescription', self::ManufacturerDescription => 'FroshAI.config.featureManufacturerDescription', + self::ReviewSummary => 'FroshAI.config.featureReviewSummary', self::ImageGeneration => 'FroshAI.config.featureImageGeneration', self::ImageEdit => 'FroshAI.config.featureImageEdit', }; @@ -37,6 +39,7 @@ public function label(): string self::ProductSeo => 'Product SEO fields', self::CategoryDescription => 'Category description', self::ManufacturerDescription => 'Manufacturer description', + self::ReviewSummary => 'Review summary', self::ImageGeneration => 'Image generation', self::ImageEdit => 'Image editing', }; diff --git a/src/Feature/ReviewSummary/ReviewSummaryGenerator.php b/src/Feature/ReviewSummary/ReviewSummaryGenerator.php new file mode 100644 index 0000000..fcf1296 --- /dev/null +++ b/src/Feature/ReviewSummary/ReviewSummaryGenerator.php @@ -0,0 +1,210 @@ + 'review_summary', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'summary' => ['type' => 'string'], + 'pros' => ['type' => 'array', 'items' => ['type' => 'string']], + 'cons' => ['type' => 'array', 'items' => ['type' => 'string']], + 'themes' => ['type' => 'array', 'items' => ['type' => 'string']], + 'reviewCount' => ['type' => 'integer'], + 'averageRating' => ['type' => 'number'], + ], + 'required' => ['summary', 'pros', 'cons', 'themes', 'reviewCount', 'averageRating'], + ], + ]; + + /** + * @param EntityRepository $reviewRepository + */ + public function __construct( + private readonly AiClient $ai, + private readonly SystemConfigService $systemConfigService, + private readonly EntityRepository $reviewRepository, + private readonly FeatureFlags $featureFlags, + ) { + } + + public function isEnabled(?string $salesChannelId = null): bool + { + return $this->featureFlags->isEnabled(FeatureId::ReviewSummary, $salesChannelId) + && $this->ai->isAvailable($salesChannelId); + } + + /** + * @return array{ + * summary: string, + * pros: list, + * cons: list, + * themes: list, + * reviewCount: int, + * averageRating: float, + * provider: string, + * model: string, + * } + */ + public function summarize(string $productId, ?Context $context = 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::ReviewSummary, $salesChannelId)) { + throw new AiException('Review summary generation is disabled. Enable it under Settings → Frosh AI → Features.'); + } + + if (!$this->ai->isAvailable($salesChannelId)) { + throw new NoProviderAvailableException(); + } + + $reviews = $this->loadApprovedReviews($productId, $context ?? Context::createDefaultContext()); + if ($reviews === []) { + throw new AiException('This product has no approved reviews to summarize.'); + } + + $system = implode("\n", [ + 'You are an e-commerce assistant summarizing customer reviews for a Shopware store.', + 'Return only JSON.', + 'Do not invent pros/cons not present in the reviews.', + 'Set reviewCount to the number of reviews provided and averageRating to the mean of the star ratings (1-5).', + ]); + + $user = $this->buildUserPrompt($reviews); + + $response = $this->ai->chatStructured( + [ChatMessage::system($system), ChatMessage::user($user)], + self::SCHEMA, + [ + 'temperature' => 0.3, + 'maxTokens' => 2048, + 'metadata' => [ + 'extension' => 'FroshAI', + 'feature' => self::FEATURE, + 'entity' => 'product', + 'entityId' => $productId, + ], + ], + ); + + if (!$response->isValid()) { + throw new AiException('The AI returned an invalid review summary: ' . ($response->error ?? 'unknown error')); + } + + $data = $response->data; + + return [ + 'summary' => (string) ($data['summary'] ?? ''), + 'pros' => $this->stringList($data['pros'] ?? []), + 'cons' => $this->stringList($data['cons'] ?? []), + 'themes' => $this->stringList($data['themes'] ?? []), + 'reviewCount' => (int) ($data['reviewCount'] ?? \count($reviews)), + 'averageRating' => (float) ($data['averageRating'] ?? 0.0), + 'provider' => $response->raw->providerId, + 'model' => $response->raw->model, + ]; + } + + /** + * @return list + */ + private function loadApprovedReviews(string $productId, Context $context): array + { + $criteria = new Criteria(); + $criteria->addFilter(new EqualsFilter('productId', $productId)); + $criteria->addFilter(new EqualsFilter('status', true)); + $criteria->addSorting(new FieldSorting('createdAt', FieldSorting::DESCENDING)); + $criteria->setLimit(self::MAX_REVIEWS); + $criteria->addFields(['title', 'content', 'points']); + + $reviews = []; + foreach ($this->reviewRepository->search($criteria, $context)->getEntities() as $review) { + $reviews[] = [ + 'points' => $review->getPoints(), + 'title' => trim((string) ($review->getTitle() ?? '')), + 'content' => trim((string) ($review->getContent() ?? '')), + ]; + } + + return $reviews; + } + + /** + * @param list $reviews + */ + private function buildUserPrompt(array $reviews): string + { + $lines = [ + \sprintf('Summarize the following %d customer reviews for one product:', \count($reviews)), + '', + ]; + + foreach ($reviews as $index => $review) { + $rating = $review['points'] !== null ? \sprintf('%g/5', $review['points']) : 'unrated'; + $lines[] = \sprintf('Review %d — Rating: %s', $index + 1, $rating); + if ($review['title'] !== '') { + $lines[] = 'Title: ' . $review['title']; + } + if ($review['content'] !== '') { + $lines[] = 'Content: ' . mb_substr($review['content'], 0, self::MAX_CONTENT_CHARS); + } + $lines[] = ''; + } + + 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; + } +} diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml index b4d2339..59e9a7a 100644 --- a/src/Resources/config/config.xml +++ b/src/Resources/config/config.xml @@ -60,6 +60,15 @@ true + + featureReviewSummary + + + Product reviews: summarize approved customer reviews (pros/cons/themes) with AI. Requires master switch on. + Produktbewertungen: freigegebene Kundenbewertungen (Pro/Contra/Themen) mit KI zusammenfassen. Benötigt aktivierten Hauptschalter. + true + + featureImageGeneration diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 11f9220..04d8cb7 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -183,6 +183,18 @@ + + + + + + + + + + + + diff --git a/tests/Unit/Feature/FeatureFlagsTest.php b/tests/Unit/Feature/FeatureFlagsTest.php index 3629b34..e570dd4 100644 --- a/tests/Unit/Feature/FeatureFlagsTest.php +++ b/tests/Unit/Feature/FeatureFlagsTest.php @@ -19,6 +19,7 @@ public function testMasterOffDisablesAll(): void static::assertFalse($flags->isEnabled(FeatureId::ProductSeo)); static::assertFalse($flags->isEnabled(FeatureId::CategoryDescription)); static::assertFalse($flags->isEnabled(FeatureId::ManufacturerDescription)); + static::assertFalse($flags->isEnabled(FeatureId::ReviewSummary)); static::assertFalse($flags->isEnabled(FeatureId::ImageGeneration)); static::assertFalse($flags->isEnabled(FeatureId::ImageEdit)); static::assertTrue($flags->isFeatureFlagOn(FeatureId::ProductDescription)); @@ -47,6 +48,7 @@ public function testMissingFeatureKeyDefaultsToOn(): void static::assertTrue($flags->isEnabled(FeatureId::ProductSeo)); static::assertTrue($flags->isEnabled(FeatureId::CategoryDescription)); static::assertTrue($flags->isEnabled(FeatureId::ManufacturerDescription)); + static::assertTrue($flags->isEnabled(FeatureId::ReviewSummary)); static::assertTrue($flags->isEnabled(FeatureId::ImageGeneration)); static::assertTrue($flags->isEnabled(FeatureId::ImageEdit)); } @@ -57,7 +59,7 @@ public function testPublicArray(): void $public = $flags->toPublicArray(); static::assertTrue($public['master']); - static::assertCount(7, $public['features']); + static::assertCount(8, $public['features']); $byId = []; foreach ($public['features'] as $row) { $byId[$row['id']] = $row; @@ -69,6 +71,7 @@ public function testPublicArray(): void static::assertArrayHasKey('product_seo', $byId); static::assertArrayHasKey('category_description', $byId); static::assertArrayHasKey('manufacturer_description', $byId); + static::assertArrayHasKey('review_summary', $byId); static::assertArrayHasKey('image_edit', $byId); } diff --git a/tests/Unit/Feature/ReviewSummaryGeneratorTest.php b/tests/Unit/Feature/ReviewSummaryGeneratorTest.php new file mode 100644 index 0000000..7e4cfa0 --- /dev/null +++ b/tests/Unit/Feature/ReviewSummaryGeneratorTest.php @@ -0,0 +1,298 @@ +createGenerator( + featuresEnabled: false, + featureOn: true, + available: true, + content: '{}', + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('disabled'); + + $generator->summarize('product-1'); + } + + public function testThrowsWhenFeatureFlagOff(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + featureOn: false, + available: true, + content: '{}', + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('Review summary generation is disabled'); + + $generator->summarize('product-1'); + } + + public function testThrowsWhenNoProviderAvailable(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: false, + content: '{}', + ); + + $this->expectException(AiException::class); + + $generator->summarize('product-1'); + } + + public function testThrowsWhenNoApprovedReviews(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: true, + content: '{}', + reviews: [], + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('no approved reviews'); + + $generator->summarize('product-1'); + } + + public function testThrowsOnInvalidStructuredOutput(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: true, + content: 'not json at all', + ); + + $this->expectException(AiException::class); + $this->expectExceptionMessage('invalid review summary'); + + $generator->summarize('product-1'); + } + + public function testReturnsStructuredSummary(): void + { + $generator = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: true, + content: json_encode([ + 'summary' => 'Customers love the fit, some complain about laces.', + 'pros' => ['Great fit', 'Lightweight'], + 'cons' => ['Laces wear out fast'], + 'themes' => ['fit', 'durability'], + 'reviewCount' => 2, + 'averageRating' => 4.25, + ], \JSON_THROW_ON_ERROR), + ); + + $result = $generator->summarize('product-1'); + + static::assertSame('Customers love the fit, some complain about laces.', $result['summary']); + static::assertSame(['Great fit', 'Lightweight'], $result['pros']); + static::assertSame(['Laces wear out fast'], $result['cons']); + static::assertSame(['fit', 'durability'], $result['themes']); + static::assertSame(2, $result['reviewCount']); + static::assertSame(4.25, $result['averageRating']); + static::assertSame('openai', $result['provider']); + static::assertSame('gpt-4o', $result['model']); + } + + public function testIsEnabledReflectsFlagsAndAvailability(): void + { + $enabled = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: true, + content: '{}', + ); + static::assertTrue($enabled->isEnabled()); + + $noProvider = $this->createGenerator( + featuresEnabled: true, + featureOn: true, + available: false, + content: '{}', + ); + static::assertFalse($noProvider->isEnabled()); + } + + /** + * @param list|null $reviews + */ + private function createGenerator( + bool $featuresEnabled, + bool $featureOn, + bool $available, + string $content, + ?array $reviews = null, + ): ReviewSummaryGenerator { + $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')->willReturn(''); + $config->method('get')->willReturnCallback( + static function (string $key) use ($featureOn): mixed { + // null = per-feature defaults to on; explicit bool for our feature + return $key === FeatureId::ReviewSummary->configKey() ? $featureOn : null; + }, + ); + + if ($reviews === null) { + $reviews = [ + ['points' => 5.0, 'title' => 'Perfect fit', 'content' => 'These shoes fit like a glove and are super light.'], + ['points' => 3.5, 'title' => 'Good but laces', 'content' => 'Comfortable, but the laces frayed after two weeks.'], + ]; + } + + $entities = []; + foreach ($reviews as $index => $row) { + $review = new ProductReviewEntity(); + $review->setUniqueIdentifier('review-' . $index); + $review->setPoints($row['points']); + $review->setTitle($row['title']); + $review->setContent($row['content']); + $entities[] = $review; + } + + $searchResult = new EntitySearchResult( + 'product_review', + \count($entities), + new EntityCollection($entities), + null, + new Criteria(), + Context::createDefaultContext(), + ); + + $reviewRepository = $this->createMock(EntityRepository::class); + $reviewRepository->method('search')->willReturn($searchResult); + + return new ReviewSummaryGenerator( + $this->createAiClient($available, $content), + $config, + $reviewRepository, + new FeatureFlags($config), + ); + } + + 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, Capability::StructuredOutput]; + } + + 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), + ); + } +}