diff --git a/config/services.yaml b/config/services.yaml
index 45addee..f5ba347 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -41,6 +41,12 @@ parameters:
mto.vector.timeout: 600
mto.vector.service_url: 'http://127.0.0.1:8090'
+ mto.commerce.enabled: true
+ mto.commerce.max_shop_results: '%env(SHOPWARE_STORE_API_MAX_RESULT)%'
+ mto.commerce.shop_timeout: 5
+ mto.commerce.store_api_base_url: '%env(SHOPWARE_STORE_API_BASE_URL)%'
+ mto.commerce.sales_channel_access_key: '%env(SHOPWARE_SALES_CHANNEL_ACCESS_KEY)%'
+
# ------------------------------------------------------------
# Services
@@ -99,10 +105,31 @@ services:
App\Knowledge\Retrieval\NdjsonHybridRetriever: ~
- # CachedRetriever entfernt: war Interface-inkompatibel und erzeugt Drift/Chaos
App\Knowledge\Retrieval\RetrieverInterface:
alias: App\Knowledge\Retrieval\NdjsonHybridRetriever
+ # ------------------------------------------------------------
+ # Commerce / Shopware Store API
+ # ------------------------------------------------------------
+
+ App\Intent\CommerceIntentLite: ~
+
+ App\Commerce\CommerceQueryParser: ~
+
+ App\Shopware\ShopwareCriteriaBuilder: ~
+
+ App\Shopware\StoreApiClient:
+ arguments:
+ $baseUrl: '%mto.commerce.store_api_base_url%'
+ $salesChannelAccessKey: '%mto.commerce.sales_channel_access_key%'
+ $timeoutSeconds: '%mto.commerce.shop_timeout%'
+
+ App\Commerce\ShopSearchService:
+ arguments:
+ $enabled: '%mto.commerce.enabled%'
+ $maxResults: '%mto.commerce.max_shop_results%'
+ $baseUrl: '%mto.commerce.store_api_base_url%'
+
# ------------------------------------------------------------
# Index Configuration Provider
# ------------------------------------------------------------
diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php
index 957165f..0be01ec 100644
--- a/src/Agent/AgentRunner.php
+++ b/src/Agent/AgentRunner.php
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Agent;
+use App\Commerce\ShopSearchService;
use App\Context\ContextService;
use App\Context\UrlAnalyzer;
use App\Infrastructure\OllamaClient;
+use App\Intent\CommerceIntentLite;
use App\Knowledge\Retrieval\RetrieverInterface;
use Generator;
use Psr\Log\LoggerInterface;
@@ -21,6 +23,8 @@ final readonly class AgentRunner
private ContextService $contextService,
private UrlAnalyzer $urlAnalyzer,
private RetrieverInterface $retriever,
+ private ShopSearchService $shopSearchService,
+ private CommerceIntentLite $commerceIntentLite,
private OllamaClient $ollamaClient,
private LoggerInterface $agentLogger,
private bool $debug,
@@ -33,6 +37,7 @@ final readonly class AgentRunner
public function run(string $prompt, string $userId, ?bool $includeFullContext = false): Generator
{
$prompt = trim($prompt);
+ $shopResults = [];
if ($prompt === '') {
yield '❌ Empty prompt.';
@@ -57,16 +62,46 @@ final readonly class AgentRunner
// ---------------------------------------------------------
// 3) Retrieve RAG knowledge
// ---------------------------------------------------------
+ yield "Hole Daten aus dem RAG Wissen... \n";
+
$knowledgeChunks = $this->retriever->retrieve($prompt);
// ---------------------------------------------------------
- // 4) Build final prompt
+ // 4) Optional commerce/shop search
+ // ---------------------------------------------------------
+
+ $commerceMeta = $this->commerceIntentLite->detect($prompt);
+ $commerceIntent = (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE);
+
+ if($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH){
+ yield "Rufe Shop auf (type: ".$commerceIntent.")... \n";
+ $shopResults = $this->shopSearchService->search($prompt,$commerceIntent);
+ }
+
+ if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH) {
+ $knowledgeChunks = array_slice($knowledgeChunks, 0, 2);
+ } elseif ($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH) {
+ $knowledgeChunks = array_slice($knowledgeChunks, 0, 3);
+ }
+
+ if($shopResults){
+ yield "Verarbeite Shopdaten... \n
\n";
+ }else{
+ yield "Keine releveanten Shopdaten gefunden... \n
\n";
+ }
+
+ yield "Denke nach...\n
\n";
+
+
+ // ---------------------------------------------------------
+ // 5) Build final prompt
// ---------------------------------------------------------
$finalPrompt = $this->promptBuilder->build(
prompt: $prompt,
userId: $userId,
urlContent: $urlContent,
knowledgeChunks: $knowledgeChunks,
+ shopResults: $shopResults,
fullContext: $includeFullContext
);
@@ -84,7 +119,7 @@ final readonly class AgentRunner
}
// ---------------------------------------------------------
- // 5) Stream tokens from the LLM backend (chunked streaming)
+ // 6) Stream tokens from the LLM backend (chunked streaming)
// ---------------------------------------------------------
$fullOutput = '';
$chunker = new StreamChunker();
@@ -120,7 +155,7 @@ final readonly class AgentRunner
}
// ---------------------------------------------------------
- // 6) Persist conversation history
+ // 7) Persist conversation history
// ---------------------------------------------------------
$this->contextService->appendHistory(
$userId,
@@ -132,6 +167,8 @@ final readonly class AgentRunner
'userId' => $userId,
'outputLength' => mb_strlen($fullOutput),
'contextMode' => 'recent',
+ 'commerceIntent' => $commerceIntent,
+ 'shopResultsCount' => count($shopResults),
]);
} catch (Throwable $e) {
$this->agentLogger->error('Agent run failed', [
@@ -142,4 +179,4 @@ final readonly class AgentRunner
yield "\n❌ An internal error occurred while processing the request. \nError: " . $e->getMessage();
}
}
-}
+}
\ No newline at end of file
diff --git a/src/Agent/PromptBuilder.php b/src/Agent/PromptBuilder.php
index 8029020..21e73cb 100644
--- a/src/Agent/PromptBuilder.php
+++ b/src/Agent/PromptBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Agent;
+use App\Commerce\Dto\ShopProductResult;
use App\Context\ContextService;
use App\Repository\SystemPromptRepository;
use DateTimeImmutable;
@@ -24,6 +25,7 @@ final readonly class PromptBuilder
* @param string $userId
* @param string $urlContent
* @param string[] $knowledgeChunks
+ * @param ShopProductResult[] $shopResults
* @param bool $fullContext
* @return string
*/
@@ -32,6 +34,7 @@ final readonly class PromptBuilder
string $userId,
string $urlContent,
array $knowledgeChunks,
+ array $shopResults = [],
?bool $fullContext = false,
): string
{
@@ -69,7 +72,59 @@ final readonly class PromptBuilder
}
// ------------------------------------------------------------
- // 3) EXTERNAL KNOWLEDGE (SUPPORTING)
+ // 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS)
+ // ------------------------------------------------------------
+ $shopBlock = '';
+
+ if ($shopResults !== []) {
+ $lines = [];
+
+ foreach ($shopResults as $i => $product) {
+ if (!$product instanceof ShopProductResult) {
+ continue;
+ }
+
+ $n = $i + 1;
+ $parts = [
+ "[{$n}] " . $product->name,
+ ];
+
+ if ($product->productNumber) {
+ $parts[] = "Product number: " . $product->productNumber;
+ }
+
+ if ($product->manufacturer) {
+ $parts[] = "Manufacturer: " . $product->manufacturer;
+ }
+
+ if ($product->price) {
+ $parts[] = "Price: " . $product->price;
+ }
+
+ if ($product->available !== null) {
+ $parts[] = "Available: " . ($product->available ? 'yes' : 'no');
+ }
+
+ foreach ($product->highlights as $highlight) {
+ $parts[] = "- " . $highlight;
+ }
+
+ if ($product->url) {
+ $parts[] = "URL: " . $product->url;
+ }
+
+ $lines[] = implode("\n", $parts);
+ }
+
+ if ($lines !== []) {
+ $shopBlock =
+ "LIVE SHOP RESULTS (authoritative for products):\n" .
+ implode("\n\n", $lines);
+ }
+ }
+
+ // ------------------------------------------------------------
+ // 4) EXTERNAL KNOWLEDGE (SUPPORTING)
// ------------------------------------------------------------
$knowledgeParts = [];
@@ -98,22 +153,23 @@ final readonly class PromptBuilder
}
// ------------------------------------------------------------
- // 4) USER QUESTION
+ // 5) USER QUESTION
// ------------------------------------------------------------
$userBlock =
"USER QUESTION:\n" .
$prompt;
// ------------------------------------------------------------
- // 5) FINAL PROMPT ASSEMBLY
+ // 6) FINAL PROMPT ASSEMBLY
// ------------------------------------------------------------
$blocks = array_filter([
$systemBlock,
$contextBlock,
+ $shopBlock,
$knowledgeBlock,
$userBlock,
]);
return implode("\n\n", $blocks);
}
-}
+}
\ No newline at end of file
diff --git a/src/Command/TestShopSearchCommand.php b/src/Command/TestShopSearchCommand.php
new file mode 100644
index 0000000..1481197
--- /dev/null
+++ b/src/Command/TestShopSearchCommand.php
@@ -0,0 +1,72 @@
+addArgument(
+ 'query',
+ InputArgument::OPTIONAL,
+ 'Die zu testende Suchanfrage',
+ 'zeige mir testomat modelle wasserhärte unter 5000 euro'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $query = (string) $input->getArgument('query');
+
+ $output->writeln('Test query: ' . $query);
+ $output->writeln('');
+
+ $results = $this->shopSearchService->search($query);
+
+ if ($results === []) {
+ $output->writeln('Keine Shop-Ergebnisse gefunden.');
+
+ return Command::SUCCESS;
+ }
+
+ foreach ($results as $index => $result) {
+ $n = $index + 1;
+
+ $output->writeln(sprintf('[%d] %s', $n, $result->name));
+ $output->writeln(' ID: ' . $result->id);
+ $output->writeln(' Produktnummer: ' . ($result->productNumber ?? '-'));
+ $output->writeln(' Hersteller: ' . ($result->manufacturer ?? '-'));
+ $output->writeln(' Preis: ' . ($result->price ?? '-'));
+ $output->writeln(' Verfügbar: ' . ($result->available === null ? '-' : ($result->available ? 'ja' : 'nein')));
+ $output->writeln(' URL: ' . ($result->url ?? '-'));
+ $output->writeln(' Description: ' . ($result->description ?? '-'));
+
+ if ($result->highlights !== []) {
+ $output->writeln(' Highlights:');
+ foreach ($result->highlights as $highlight) {
+ $output->writeln(' - ' . $highlight);
+ }
+ }
+
+ $output->writeln('');
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Commerce/CommerceQueryParser.php b/src/Commerce/CommerceQueryParser.php
new file mode 100644
index 0000000..1a7e950
--- /dev/null
+++ b/src/Commerce/CommerceQueryParser.php
@@ -0,0 +1,267 @@
+normalize($originalPrompt);
+
+ [$priceMin, $priceMax] = $this->extractPriceRange($normalized);
+ $sizes = $this->extractSizes($normalized);
+ $colors = $this->extractColors($normalized);
+ $brand = $this->extractBrand($normalized);
+ $category = $this->extractCategory($normalized);
+ $properties = [];
+
+ $searchText = $this->buildSearchText(
+ $normalized,
+ $colors,
+ $sizes,
+ $brand,
+ $priceMin,
+ $priceMax
+ );
+
+ return new CommerceSearchQuery(
+ originalPrompt: $originalPrompt,
+ normalizedPrompt: $normalized,
+ searchText: $searchText !== '' ? $searchText : $normalized,
+ category: $category,
+ brand: $brand,
+ colors: $colors,
+ sizes: $sizes,
+ properties: $properties,
+ priceMin: $priceMin,
+ priceMax: $priceMax,
+ intent: $intent,
+ needsLlmFallback: false,
+ );
+ }
+
+ private function normalize(string $prompt): string
+ {
+ $value = mb_strtolower(trim($prompt));
+ $value = str_replace(['€'], ' euro ', $value);
+ $value = preg_replace('/[^\p{L}\p{N}\s.,\-]/u', ' ', $value) ?? $value;
+ $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
+
+ return trim($value);
+ }
+
+ /**
+ * @return array{0:?float,1:?float}
+ */
+ private function extractPriceRange(string $prompt): array
+ {
+ $priceMin = 10;
+ $priceMax = null;
+
+ if (preg_match('/\bzwischen\s+(\d+(?:[.,]\d+)?)\s+und\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
+ $a = $this->toFloat($m[1]);
+ $b = $this->toFloat($m[2]);
+
+ if ($a !== null && $b !== null) {
+ return [min($a, $b), max($a, $b)];
+ }
+ }
+
+ if (preg_match('/\b(?:unter|bis|max(?:imal)?)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
+ $priceMax = $this->toFloat($m[1]);
+ }
+
+ if (preg_match('/\b(?:ab|mindestens|min)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
+ $priceMin = $this->toFloat($m[1]);
+ }
+
+ return [$priceMin, $priceMax];
+ }
+
+ /**
+ * @return string[]
+ */
+ private function extractSizes(string $prompt): array
+ {
+ $sizes = [];
+
+ if (preg_match_all('/\b(?:größe|groesse|grösse)\s*([a-z0-9.-]+)\b/u', $prompt, $matches) === false) {
+ return [];
+ }
+
+ foreach ($matches[1] as $size) {
+ $sizes[] = trim($size);
+ }
+
+ if (preg_match_all('/\b(xs|s|m|l|xl|xxl|xxxl)\b/u', $prompt, $tokenMatches) !== false) {
+ foreach ($tokenMatches[1] as $sizeToken) {
+ $sizes[] = trim($sizeToken);
+ }
+ }
+
+ return array_values(array_unique(array_filter($sizes, static fn ($v) => $v !== '')));
+ }
+
+ /**
+ * @return string[]
+ */
+ private function extractColors(string $prompt): array
+ {
+ $colors = [];
+
+ foreach ($this->knownColors as $color) {
+ if (preg_match('/\b' . preg_quote($color, '/') . '\b/u', $prompt) === 1) {
+ $colors[] = $color;
+ }
+ }
+
+ return array_values(array_unique($colors));
+ }
+
+ private function extractBrand(string $prompt): ?string
+ {
+ foreach ($this->knownBrands as $brand) {
+ if (str_contains($prompt, $brand)) {
+ return $brand;
+ }
+ }
+
+ if (preg_match('/\bmarke\s+([a-z0-9][a-z0-9\s\-]+)/u', $prompt, $m) === 1) {
+ return trim($m[1]);
+ }
+
+ return null;
+ }
+
+ private function extractCategory(string $prompt): ?string
+ {
+ foreach ($this->knownCategories as $category) {
+ if (preg_match('/\b' . preg_quote($category, '/') . '\b/u', $prompt) === 1) {
+ return $category;
+ }
+ }
+
+ return null;
+ }
+
+ private function buildSearchText(
+ string $prompt,
+ array $colors,
+ array $sizes,
+ ?string $brand,
+ ?float $priceMin,
+ ?float $priceMax
+ ): string {
+ $text = ' ' . $prompt . ' ';
+
+ $phrasesToRemove = [
+ 'ich suche',
+ 'suche',
+ 'habt ihr',
+ 'gibt es',
+ 'zeige mir',
+ 'welches gerät',
+ 'welche gerät',
+ 'welches modell',
+ 'welches ist besser',
+ 'welches ist am besten',
+ 'alternative',
+ 'alternativen',
+ ];
+
+ foreach ($phrasesToRemove as $phrase) {
+ $text = str_replace($phrase, ' ', $text);
+ }
+
+ foreach ($colors as $color) {
+ $text = preg_replace('/\b' . preg_quote($color, '/') . '\b/u', ' ', $text) ?? $text;
+ }
+
+ foreach ($sizes as $size) {
+ $text = preg_replace('/\b' . preg_quote($size, '/') . '\b/u', ' ', $text) ?? $text;
+ }
+
+ if ($brand !== null && $brand !== '') {
+ $text = str_replace($brand, ' ', $text);
+ }
+
+ if ($priceMin !== null || $priceMax !== null) {
+ if ($priceMin !== null || $priceMax !== null) {
+ $text = preg_replace('/\bzwischen\s+\d+(?:[.,]\d+)?\s+und\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
+ $text = preg_replace('/\b(?:unter|bis|max(?:imal)?|ab|mindestens|min)\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
+ $text = preg_replace('/\beuro\b/u', ' ', $text) ?? $text;
+ }
+ }
+
+ $text = preg_replace('/\s+/u', ' ', $text) ?? $text;
+ $text = trim($text, " \t\n\r\0\x0B-.,");
+ $tokens = array_filter(explode(' ', $text), static fn (string $token): bool => mb_strlen($token) > 1);
+
+ return trim(implode(' ', $tokens));
+ }
+
+ private function toFloat(string $value): ?float
+ {
+ $value = str_replace(',', '.', trim($value));
+
+ return is_numeric($value) ? (float) $value : null;
+ }
+}
\ No newline at end of file
diff --git a/src/Commerce/Dto/CommerceSearchQuery.php b/src/Commerce/Dto/CommerceSearchQuery.php
new file mode 100644
index 0000000..7cb12ea
--- /dev/null
+++ b/src/Commerce/Dto/CommerceSearchQuery.php
@@ -0,0 +1,29 @@
+enabled) {
+ return [];
+ }
+
+ $query = $this->queryParser->parse($originalPrompt, $commerceIntent);
+ $criteria = $this->criteriaBuilder->build($query, $this->maxResults);
+ $response = $this->storeApiClient->searchProducts($criteria);
+
+ return $this->mapProducts($response);
+ }
+
+ /**
+ * @return ShopProductResult[]
+ */
+ private function mapProducts(array $response): array
+ {
+ $elements = $response['elements'] ?? [];
+ if (!is_array($elements)) {
+ return [];
+ }
+
+ $results = [];
+
+ foreach ($elements as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $results[] = new ShopProductResult(
+ id: (string)($row['id'] ?? ''),
+ name: trim((string)($row['translated']['name'] ?? '')),
+ productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null,
+ price: $this->extractPrice($row),
+ available: isset($row['available']) ? (bool)$row['available'] : null,
+ url: $this->baseUrl . $this->extractUrl($row),
+ highlights: $this->extractHighlights($row),
+ description: $this->cleanUpDescription($row),
+ );
+ }
+
+ return array_values(array_filter(
+ $results,
+ static fn(ShopProductResult $product): bool => $product->name !== ''
+ ));
+ }
+
+ private function cleanUpDescription($description): string
+ {
+ if (isset($description['translated']['description'])) {
+ $newDesc = strip_tags((string)$description['translated']['description']);
+ $newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc); // leere Zeilen weg
+ $newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc); // mehrere Spaces zu einem
+ $result = trim($newDesc);
+ return substr($result, 0, 500);
+ }
+
+ return '';
+ }
+
+ private function extractManufacturer(array $row): ?string
+ {
+ $manufacturer = $row['manufacturer'] ?? null;
+
+ if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
+ return trim($manufacturer['name']) !== '' ? trim($manufacturer['name']) : null;
+ }
+
+ return null;
+ }
+
+ private function extractPrice(array $row): ?string
+ {
+ $calculatedPrice = $row['calculatedPrice'] ?? null;
+
+ if (!is_array($calculatedPrice)) {
+ return null;
+ }
+
+ $unitPrice = $calculatedPrice['unitPrice'] ?? $calculatedPrice['totalPrice'] ?? $calculatedPrice['referencePrice'] ?? $calculatedPrice['listPrice'] ?? $calculatedPrice['regulationPrice'] ?? 0;
+ if (!is_numeric($unitPrice)) {
+ return null;
+ }
+
+ return number_format((float)$unitPrice, 2, ',', '.') . ' €';
+ }
+
+ private function extractUrl(array $row): ?string
+ {
+ $seoUrls = $row['seoUrls'] ?? null;
+
+ if (!is_array($seoUrls) || $seoUrls === []) {
+ return null;
+ }
+
+ foreach ($seoUrls as $seoUrl) {
+ if (!is_array($seoUrl)) {
+ continue;
+ }
+
+ $path = $seoUrl['seoPathInfo'] ?? null;
+ if (is_string($path) && trim($path) !== '') {
+ return '/' . ltrim($path, '/');
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return string[]
+ */
+ private function extractHighlights(array $row): array
+ {
+ $highlights = [];
+
+ if (isset($row['available'])) {
+ $highlights[] = ((bool)$row['available']) ? 'Verfügbar' : 'Nicht verfügbar';
+ }
+
+ if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
+ $highlights[] = 'Produktnummer: ' . trim($row['productNumber']);
+ }
+
+ return array_values(array_unique($highlights));
+ }
+}
\ No newline at end of file
diff --git a/src/Intent/CommerceIntentLite.php b/src/Intent/CommerceIntentLite.php
new file mode 100644
index 0000000..700036f
--- /dev/null
+++ b/src/Intent/CommerceIntentLite.php
@@ -0,0 +1,120 @@
+ self::NONE,
+ 'score' => 0,
+ 'signals' => [],
+ ];
+ }
+
+ $score = 0;
+ $signals = [];
+
+ $strongSignals = [
+ 'suche',
+ 'habt',
+ 'gibt',
+ 'zeig',
+ 'welche',
+ 'vergleich',
+ 'alternativ',
+ 'find',
+ 'shop',
+ 'sku',
+ 'Artikel',
+ 'Gerät'
+ ];
+
+ foreach ($strongSignals as $signal) {
+ if (str_contains($p, $signal)) {
+ $score += 2;
+ $signals[] = $signal;
+ }
+ }
+
+ if(preg_match('#\d{3,10}#', $p)){
+ $score += 2;
+ $signals[] = 'sku';
+ }
+
+ if (preg_match('/\b\d+(?:[.,]\d+)?\s*(euro|€|eur|teuer|preis|kosten)\b/u', $p) === 1) {
+ $score += 2;
+ $signals[] = 'price';
+ }
+
+ if (preg_match('/\b(größe|groesse|grösse)\s*[a-z0-9.-]+\b/u', $p) === 1) {
+ $score += 2;
+ $signals[] = 'size';
+ }
+
+ if (preg_match('/\b(xs|s|m|l|xl|xxl|xxxl)\b/u', $p) === 1) {
+ $score += 1;
+ $signals[] = 'size_token';
+ }
+
+ if (preg_match('/\b(schwarz|weiß|weiss|rot|blau|grün|gruen|gelb|grau|beige|rosa|pink|orange|braun)\b/u', $p) === 1) {
+ $score += 1;
+ $signals[] = 'color';
+ }
+
+ $advisorySignals = [
+ 'passt',
+ 'eignet',
+ 'besser',
+ 'besten',
+ 'geeignet',
+ 'empfiehl',
+ 'empfehl',
+ ];
+
+ foreach ($advisorySignals as $signal) {
+ if (str_contains($p, $signal)) {
+ $score += 1;
+ $signals[] = 'advisory:' . $signal;
+ }
+ }
+
+ $signals = array_values(array_unique($signals));
+
+ if ($score >= 3) {
+ return [
+ 'intent' => self::PRODUCT_SEARCH,
+ 'score' => $score,
+ 'signals' => $signals,
+ ];
+ }
+
+ if ($score >= 2) {
+ return [
+ 'intent' => self::ADVISORY_PRODUCT_SEARCH,
+ 'score' => $score,
+ 'signals' => $signals,
+ ];
+ }
+
+ return [
+ 'intent' => self::NONE,
+ 'score' => $score,
+ 'signals' => $signals,
+ ];
+ }
+
+}
\ No newline at end of file
diff --git a/src/Knowledge/Retrieval/CachedRetriever.php b/src/Knowledge/Retrieval/CachedRetriever.php
new file mode 100644
index 0000000..d2ae610
--- /dev/null
+++ b/src/Knowledge/Retrieval/CachedRetriever.php
@@ -0,0 +1,50 @@
+buildCacheKey($prompt);
+
+ $item = $this->cache->getItem($key);
+ if ($item->isHit()) {
+ $cached = $item->get();
+
+ return is_array($cached) ? $cached : [];
+ }
+
+ $result = $this->inner->retrieve($prompt);
+
+ $item->set($result);
+ $item->expiresAfter($this->ttlSeconds);
+ $this->cache->save($item);
+
+ return $result;
+ }
+
+ private function buildCacheKey(string $prompt): string
+ {
+ $normalized = mb_strtolower(trim($prompt));
+ $normalized = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized;
+
+ return 'rag_retrieval_' . sha1($normalized);
+ }
+}
\ No newline at end of file
diff --git a/src/Shopware/ShopwareCriteriaBuilder.php b/src/Shopware/ShopwareCriteriaBuilder.php
new file mode 100644
index 0000000..d672ee4
--- /dev/null
+++ b/src/Shopware/ShopwareCriteriaBuilder.php
@@ -0,0 +1,93 @@
+ 1,
+ 'limit' => max(1, $limit),
+ "grouping" => ["parentId"],
+ 'total-count-mode' => 0,
+ 'includes' => [
+ 'product' => [
+ 'id',
+ 'name',
+ 'description',
+ 'productNumber',
+ 'available',
+ 'calculatedPrice',
+ 'seoUrls',
+ 'manufacturer',
+ 'translated.name'
+ ],
+ 'product_manufacturer' => [
+ 'name',
+ ],
+ 'calculated_price' => [
+ 'unitPrice',
+ 'totalPrice',
+ 'referencePrice',
+ 'listPrice',
+ 'regulationPrice'
+ ],
+ 'seo_url' => [
+ 'seoPathInfo',
+ ],
+ ],
+ 'associations' => [
+ 'manufacturer' => new \stdClass(),
+ 'seoUrls' => new \stdClass(),
+ ],
+ 'sort' => [
+ [
+ 'field' => 'name',
+ 'order' => 'ASC',
+ 'naturalSorting' => true,
+ ],
+ ],
+ ];
+
+ if ($query->searchText !== '') {
+ $criteria['term'] = $query->searchText;
+ }
+
+ $filters = [
+ [
+ 'type' => 'equals',
+ 'field' => 'active',
+ 'value' => true,
+ ],
+ [
+ 'type' => 'equals',
+ 'field' => 'available',
+ 'value' => true,
+ ],
+ [
+ 'type' => 'range',
+ 'field' => 'price.gross',
+ 'parameters' => [
+ 'gt' => 0,
+ ],
+ ]
+ ];
+
+ if ($query->priceMin !== null) {
+ $criteria['min-price'] = $query->priceMin;
+ }
+
+ if ($query->priceMax !== null) {
+ $criteria['max-price'] = $query->priceMax;
+ }
+
+ $criteria['filter'] = $filters;
+
+ return $criteria;
+ }
+}
\ No newline at end of file
diff --git a/src/Shopware/StoreApiClient.php b/src/Shopware/StoreApiClient.php
new file mode 100644
index 0000000..ccaa4eb
--- /dev/null
+++ b/src/Shopware/StoreApiClient.php
@@ -0,0 +1,53 @@
+baseUrl, '/') . '/store-api/product';
+
+ $response = $this->httpClient->request('POST', $url, [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ 'sw-access-key' => $this->salesChannelAccessKey,
+ ],
+ 'json' => $criteria,
+ 'timeout' => $this->timeoutSeconds,
+ ]);
+
+ $statusCode = $response->getStatusCode();
+ if ($statusCode < 200 || $statusCode >= 300) {
+ return [];
+ }
+
+ return $response->toArray(false);
+ }
+}
\ No newline at end of file
diff --git a/templates/admin/security/login.html.twig b/templates/admin/security/login.html.twig
index c12bd05..e7704fe 100644
--- a/templates/admin/security/login.html.twig
+++ b/templates/admin/security/login.html.twig
@@ -11,7 +11,7 @@
- mitho® KI RAG Login
+ Heyl-Neomeris KI/RAG Login
{% if error %}