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 @@
-

mitho® KI-Agent

+

RetrieX KI-Agent

+
powered by mitho®
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 @@ {# ============================= #}