Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/Controller/Api/CategoryDescriptionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Controller\Api;

use Frosh\AI\Exception\AiException;
use Frosh\AI\Exception\NoProviderAvailableException;
use Frosh\AI\Feature\CategoryDescription\CategoryDescriptionGenerator;
use Frosh\AI\Feature\ProductDescription\ProductDescriptionToneStore;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\Routing\ApiRouteScope;
use Shopware\Core\PlatformRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [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<string, mixed> $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);
}
}
313 changes: 313 additions & 0 deletions src/Feature/CategoryDescription/CategoryDescriptionGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Feature\CategoryDescription;

use Frosh\AI\Client\AiClient;
use Frosh\AI\Enum\Capability;
use Frosh\AI\Exception\AiException;
use Frosh\AI\Exception\NoProviderAvailableException;
use Frosh\AI\Feature\FeatureFlags;
use Frosh\AI\Feature\FeatureId;
use Frosh\AI\Feature\ProductDescription\ProductDescriptionToneStore;
use Frosh\AI\Feature\ProductDescription\TonePreset;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\System\Language\LanguageEntity;
use Shopware\Core\System\SystemConfig\SystemConfigService;

/**
* Generates HTML category descriptions via the merchant's configured text model.
*
* Language is the **category content language** (translation being edited), not the admin UI locale.
*/
final class CategoryDescriptionGenerator
{
public const FEATURE = 'category-description';

/**
* @param EntityRepository<LanguageEntity> $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<array{id: string, label: string, instruction: string}>
*/
public function availableTones(?string $salesChannelId = null): array
{
return $this->toneStore->toPublicList($salesChannelId);
}

/**
* @param array{
* name?: string|null,
* parentCategories?: list<string>|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 <p>, <ul>, <li>, <strong> 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<string, mixed> $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<string, mixed> $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<string>
*/
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[^>]*>.*?</\1>#is', '', $html) ?? $html;
$allowed = '<p><br><ul><ol><li><strong><b><em><i><u><h2><h3><h4>';
$html = strip_tags($html, $allowed);

return trim($html);
}
}
Loading
Loading