diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css
index f42a772..9c9d752 100644
--- a/public/assets/styles/base.css
+++ b/public/assets/styles/base.css
@@ -409,4 +409,9 @@ span.think {
.think {
animation: none;
}
+}
+
+.bubble img {
+ max-width: 50px;
+ max-height: 50px;
}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index eb43fb6..e71f682 100644
--- a/public/index.html
+++ b/public/index.html
@@ -18,7 +18,8 @@
diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php
index ed13410..b5e962d 100644
--- a/src/Agent/AgentRunner.php
+++ b/src/Agent/AgentRunner.php
@@ -58,7 +58,7 @@ final readonly class AgentRunner
//$includeFullContext = false;
if ($includeFullContext) {
- yield $this->systemMsg("Ich analyse deine Anfrage...", "think");
+ yield $this->systemMsg("Ich analysiere deine Anfrage...", "think");
$promptSwagSearch = '
Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche.
@@ -80,6 +80,7 @@ final readonly class AgentRunner
';
$this->thinkSuppressor->reset();
+
foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) {
if (!is_string($swagToken)) {
@@ -138,7 +139,6 @@ final readonly class AgentRunner
yield $this->systemMsg("Denke nach...", "think");
-
// ---------------------------------------------------------
// 5) Build final prompt
// ---------------------------------------------------------
diff --git a/src/Agent/PromptBuilder.php b/src/Agent/PromptBuilder.php
index 21e73cb..8bf6442 100644
--- a/src/Agent/PromptBuilder.php
+++ b/src/Agent/PromptBuilder.php
@@ -75,6 +75,7 @@ final readonly class PromptBuilder
// 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS)
// ------------------------------------------------------------
$shopBlock = '';
+ $isDetailed = !(count($shopResults) > 5);
if ($shopResults !== []) {
$lines = [];
@@ -113,6 +114,18 @@ final readonly class PromptBuilder
$parts[] = "URL: " . $product->url;
}
+ if ($product->productImage) {
+ $parts[] = "productImage: " . $product->productImage;
+ }
+
+ if ($isDetailed && $product->description) {
+ $parts[] = "description: " . $product->description;
+ }
+
+ if ($product->customFields) {
+ $parts[] = "Meta-Informationen: " . $product->customFields;
+ }
+
$lines[] = implode("\n", $parts);
}
diff --git a/src/Commerce/Dto/ShopProductResult.php b/src/Commerce/Dto/ShopProductResult.php
index bd73997..b5e8310 100644
--- a/src/Commerce/Dto/ShopProductResult.php
+++ b/src/Commerce/Dto/ShopProductResult.php
@@ -10,15 +10,18 @@ final readonly class ShopProductResult
* @param string[] $highlights
*/
public function __construct(
- public string $id,
- public string $name,
+ public string $id,
+ public string $name,
public ?string $productNumber = null,
public ?string $manufacturer = null,
public ?string $price = null,
- public ?bool $available = null,
+ public ?bool $available = null,
public ?string $url = null,
- public array $highlights = [],
- public ?string $description = null
- ) {
+ public array $highlights = [],
+ public ?string $description = null,
+ public ?string $productImage = null,
+ public ?string $customFields = null,
+ )
+ {
}
}
\ No newline at end of file
diff --git a/src/Commerce/ShopSearchService.php b/src/Commerce/ShopSearchService.php
index ba102bf..7612ce5 100644
--- a/src/Commerce/ShopSearchService.php
+++ b/src/Commerce/ShopSearchService.php
@@ -7,6 +7,11 @@ namespace App\Commerce;
use App\Commerce\Dto\ShopProductResult;
use App\Shopware\ShopwareCriteriaBuilder;
use App\Shopware\StoreApiClient;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
final readonly class ShopSearchService
{
@@ -29,12 +34,18 @@ final readonly class ShopSearchService
if (!$this->enabled) {
return [];
}
-
+ $response = [];
$query = $this->queryParser->parse($originalPrompt, $commerceIntent);
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
- $response = $this->storeApiClient->searchProducts($criteria);
+ try {
+ $response = $this->storeApiClient->searchProducts($criteria);
+ } catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
- return $this->mapProducts($response);
+ }
+
+ $result = $this->mapProducts($response);;
+
+ return $result;
}
/**
@@ -63,6 +74,8 @@ final readonly class ShopSearchService
url: $this->baseUrl . $this->extractUrl($row),
highlights: $this->extractHighlights($row),
description: $this->cleanUpDescription($row),
+ productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
+ customFields: $this->getRelevantCustomFields($row['customFields'])
);
}
@@ -72,6 +85,15 @@ final readonly class ShopSearchService
));
}
+ private function getRelevantCustomFields($customField): string
+ {
+ $result = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? '');
+ $result .= ' | Einsatzgebiete: ' . ($customField['migration_Backup_product_attr4'] ?? '');
+ $result .= ' | Sprachen: ' . ($customField['migration_Backup_product_attr5'] ?? '');
+
+ return $result;
+ }
+
private function cleanUpDescription($description): string
{
if (isset($description['translated']['description'])) {
diff --git a/src/Knowledge/Retrieval/NdjsonHybridRetriever.php b/src/Knowledge/Retrieval/NdjsonHybridRetriever.php
index 0da9c62..6977ece 100644
--- a/src/Knowledge/Retrieval/NdjsonHybridRetriever.php
+++ b/src/Knowledge/Retrieval/NdjsonHybridRetriever.php
@@ -41,8 +41,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
private readonly SalesIntentLite $salesIntentLite,
private readonly CatalogIntentLite $catalogIntent,
private readonly IntentRouteResolver $routeResolver,
- private readonly EntityCatalogService $entityCatalogService
- ) {}
+ private readonly EntityCatalogService $entityCatalogService,
+ private readonly QueryEnricher $queryEnricher,
+ )
+ {
+ }
// =========================================================
// PUBLIC API
@@ -126,10 +129,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
// =========================================================
private function execute(
- string $prompt,
+ string $prompt,
ModelGenerationConfig $config,
- bool $withScores
- ): array {
+ bool $withScores
+ ): array
+ {
$entityLabel = $this->catalogIntent->detect($prompt);
$salesIntent = $this->detectSalesIntent($prompt);
@@ -195,11 +199,12 @@ final class NdjsonHybridRetriever implements RetrieverInterface
// =========================================================
private function runCore(
- string $prompt,
+ string $prompt,
ModelGenerationConfig $config,
- bool $withScores,
- string $salesIntent
- ): array {
+ bool $withScores,
+ string $salesIntent
+ ): array
+ {
$limit = max(1, min($config->getRetrievalMaxChunks(), self::HARD_MAX_CHUNKS));
$vectorTopKBase = max(1, min($config->getRetrievalVectorTopK(), self::HARD_MAX_VECTORK));
@@ -207,6 +212,7 @@ final class NdjsonHybridRetriever implements RetrieverInterface
$isListQuery = $this->intentLite->isListQuery($prompt);
$cleanQuery = $this->queryCleaner->clean($prompt);
+ $cleanQuery = $this->queryEnricher->enrichPrompt($cleanQuery);
if ($cleanQuery === '') {
return [
@@ -316,9 +322,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
private function computeThresholdAndTopK(
string $salesIntent,
- bool $isListQuery,
- int $vectorTopKBase
- ): array {
+ bool $isListQuery,
+ int $vectorTopKBase
+ ): array
+ {
$threshold = self::VECTOR_SCORE_THRESHOLD;
$topK = $vectorTopKBase;
@@ -344,9 +351,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
array $globalHits,
array $scopedHits,
float $threshold,
- bool $boostScoped,
- bool $captureRaw
- ): array {
+ bool $boostScoped,
+ bool $captureRaw
+ ): array
+ {
$rrfScores = [];
$rawScores = [];
diff --git a/src/Knowledge/Retrieval/QueryEnricher.php b/src/Knowledge/Retrieval/QueryEnricher.php
new file mode 100644
index 0000000..674da37
--- /dev/null
+++ b/src/Knowledge/Retrieval/QueryEnricher.php
@@ -0,0 +1,128 @@
+normalize($query);
+
+ // Expect an associative array like:
+ // [
+ // 'hose' => 'jeans',
+ // 'jacke' => 'mantel',
+ // ]
+ $mapping = $this->enrichQueryList();
+
+ // Build a bidirectional lookup table:
+ // key -> value
+ // value -> key
+ $lookup = $this->buildBidirectionalLookup($mapping);
+
+ // Split the query into searchable words/tokens.
+ $tokens = $this->tokenize($normalizedQuery);
+
+ $matches = [];
+
+ foreach ($tokens as $token) {
+ // If the token exists in the lookup table, add the mapped counterpart.
+ if (isset($lookup[$token])) {
+ $matches[] = $lookup[$token];
+ }
+ }
+
+ // Remove duplicates while preserving order.
+ $matches = array_values(array_unique($matches));
+
+ // If nothing was found, return the original query unchanged.
+ if ($matches === []) {
+ return $originalQuery;
+ }
+
+ // Append the matched counterpart terms to the original prompt.
+ return $originalQuery . " | Pseudonyme: " . implode(', ', $matches);
+ }
+
+ /**
+ * Normalize a string for case-insensitive comparison.
+ */
+ private function normalize(string $value): string
+ {
+ return mb_strtolower(trim($value), 'UTF-8');
+ }
+
+ /**
+ * Tokenize the query into words.
+ * Splits on everything that is not a letter or number.
+ */
+ private function tokenize(string $value): array
+ {
+ return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+ }
+
+ /**
+ * Build a lookup table that works in both directions.
+ *
+ * Example:
+ * [
+ * 'hose' => 'jeans',
+ * 'jacke' => 'mantel',
+ * ]
+ *
+ * becomes:
+ * [
+ * 'hose' => 'jeans',
+ * 'jeans' => 'hose',
+ * 'jacke' => 'mantel',
+ * 'mantel' => 'jacke',
+ * ]
+ */
+ private function buildBidirectionalLookup(array $mapping): array
+ {
+ $lookup = [];
+
+ foreach ($mapping as $key => $value) {
+ $key = trim((string)$key);
+ $value = trim((string)$value);
+
+ // Skip incomplete pairs.
+ if ($key === '' || $value === '') {
+ continue;
+ }
+
+ $normalizedKey = $this->normalize($key);
+ $normalizedValue = $this->normalize($value);
+
+ // If the key is found in the query, return the value.
+ $lookup[$normalizedKey] = $value;
+
+ // If the value is found in the query, return the key.
+ $lookup[$normalizedValue] = $key;
+ }
+
+ return $lookup;
+ }
+
+ public function enrichQueryList(): array
+ {
+ return [
+ 'Wasserhärte' => "Resthärte",
+ 'Gerät' => 'Modell',
+ 'Indikator' => 'Chemie',
+ 'Wasserhärte-Grenzwert'=>'Resthärte',
+ 'Resthärte-Grenzwert'=>'Wasserhärte'
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Shopware/ShopwareCriteriaBuilder.php b/src/Shopware/ShopwareCriteriaBuilder.php
index cfd7eea..d4d22a4 100644
--- a/src/Shopware/ShopwareCriteriaBuilder.php
+++ b/src/Shopware/ShopwareCriteriaBuilder.php
@@ -28,7 +28,9 @@ final class ShopwareCriteriaBuilder
'calculatedPrice',
'seoUrls',
'manufacturer',
- 'translated.name'
+ 'translated.name',
+ 'cover',
+ 'customFields'
],
'product_manufacturer' => [
'name',
@@ -43,10 +45,30 @@ final class ShopwareCriteriaBuilder
'seo_url' => [
'seoPathInfo',
],
+ 'product_media' => [
+ 'id',
+ 'media'
+ ],
+ 'media' => [
+ 'id',
+ 'url',
+ 'thumbnails',
+ 'alt',
+ 'title'
+ ]
],
'associations' => [
'manufacturer' => new \stdClass(),
'seoUrls' => new \stdClass(),
+ 'cover' => [
+ 'associations' => [
+ 'media' => [
+ 'associations' => [
+ "thumbnails" => new \stdClass()
+ ]
+ ]
+ ]
+ ]
],
'sort' => [
[
diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig
index 8ca9392..dd482eb 100644
--- a/templates/admin/base.html.twig
+++ b/templates/admin/base.html.twig
@@ -20,10 +20,10 @@
{# ============================= #}