harden retrieval logic
bugfixes
This commit is contained in:
@@ -51,6 +51,9 @@ final readonly class AgentRunner
|
|||||||
$shopResults = [];
|
$shopResults = [];
|
||||||
$sources = [];
|
$sources = [];
|
||||||
$optimizedShopQuery = '';
|
$optimizedShopQuery = '';
|
||||||
|
$shopSearchQuery = '';
|
||||||
|
$commerceIntent = CommerceIntentLite::NONE;
|
||||||
|
$commerceHistoryContext = '';
|
||||||
|
|
||||||
$this->agentLogger->info('Agent run started', [
|
$this->agentLogger->info('Agent run started', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
@@ -97,7 +100,7 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId);
|
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId);
|
||||||
|
|
||||||
if($commerceHistoryContext){
|
if ($commerceHistoryContext !== '') {
|
||||||
$this->addSource($sources, 'Chatverlauf');
|
$this->addSource($sources, 'Chatverlauf');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +112,16 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
|
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
|
||||||
|
|
||||||
|
$this->agentLogger->info('Commerce search prepared', [
|
||||||
|
'userId' => $userId,
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
||||||
|
'optimizedShopQuery' => $optimizedShopQuery,
|
||||||
|
'shopSearchQuery' => $shopSearchQuery,
|
||||||
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||||
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||||
|
]);
|
||||||
|
|
||||||
yield $this->systemMsg(
|
yield $this->systemMsg(
|
||||||
'Ich rufe Recherchedaten ab (type: ' . $commerceIntent . ')',
|
'Ich rufe Recherchedaten ab (type: ' . $commerceIntent . ')',
|
||||||
'think'
|
'think'
|
||||||
@@ -126,7 +139,9 @@ final readonly class AgentRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($shopResults !== []) {
|
||||||
$knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent);
|
$knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent);
|
||||||
|
}
|
||||||
|
|
||||||
yield $this->systemMsg('Ich analysiere alle Informationen...', 'think');
|
yield $this->systemMsg('Ich analysiere alle Informationen...', 'think');
|
||||||
|
|
||||||
@@ -148,6 +163,7 @@ final readonly class AgentRunner
|
|||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
'finalPrompt' => $finalPrompt,
|
'finalPrompt' => $finalPrompt,
|
||||||
'optimizedShopQuery' => $optimizedShopQuery,
|
'optimizedShopQuery' => $optimizedShopQuery,
|
||||||
|
'shopSearchQuery' => $shopSearchQuery,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +214,10 @@ final readonly class AgentRunner
|
|||||||
'knowledgeChunkCount' => count($knowledgeChunks),
|
'knowledgeChunkCount' => count($knowledgeChunks),
|
||||||
'hasUrlContent' => $urlContent !== '',
|
'hasUrlContent' => $urlContent !== '',
|
||||||
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
||||||
|
'optimizedShopQuery' => $optimizedShopQuery,
|
||||||
|
'shopSearchQuery' => $shopSearchQuery,
|
||||||
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||||
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->agentLogger->error('Agent run failed', [
|
$this->agentLogger->error('Agent run failed', [
|
||||||
@@ -282,6 +302,8 @@ final readonly class AgentRunner
|
|||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
'commerceIntent' => $commerceIntent,
|
'commerceIntent' => $commerceIntent,
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||||
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ final readonly class PromptBuilder
|
|||||||
*/
|
*/
|
||||||
private const MIN_PROMPT_BUDGET_TOKENS = 1024;
|
private const MIN_PROMPT_BUDGET_TOKENS = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit how many ranked shop results are passed into the final prompt.
|
||||||
|
* The shop search may return many candidates, but the LLM should only see
|
||||||
|
* the most relevant top subset after local reranking.
|
||||||
|
*/
|
||||||
|
private const MAX_SHOP_RESULTS_IN_PROMPT = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Technical product prompts should be answered like documentation,
|
* Technical product prompts should be answered like documentation,
|
||||||
* not like sales copy.
|
* not like sales copy.
|
||||||
@@ -84,8 +91,7 @@ final readonly class PromptBuilder
|
|||||||
private ContextService $contextService,
|
private ContextService $contextService,
|
||||||
private SystemPromptRepository $systemPromptRepository,
|
private SystemPromptRepository $systemPromptRepository,
|
||||||
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
|
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,18 +228,21 @@ final readonly class PromptBuilder
|
|||||||
"Source: Shop Search";
|
"Source: Shop Search";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($shopResults === []) {
|
$normalizedShopResults = array_values(array_filter(
|
||||||
|
$shopResults,
|
||||||
|
static fn(mixed $product): bool => $product instanceof ShopProductResult
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($normalizedShopResults === []) {
|
||||||
return $this->implodeBlocks($parts);
|
return $this->implodeBlocks($parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
$isDetailed = count($shopResults) <= 5;
|
$totalCount = count($normalizedShopResults);
|
||||||
|
$limitedShopResults = array_slice($normalizedShopResults, 0, self::MAX_SHOP_RESULTS_IN_PROMPT);
|
||||||
|
$isDetailed = count($limitedShopResults) <= 5;
|
||||||
$lines = [];
|
$lines = [];
|
||||||
|
|
||||||
foreach ($shopResults as $i => $product) {
|
foreach ($limitedShopResults as $i => $product) {
|
||||||
if (!$product instanceof ShopProductResult) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$n = $i + 1;
|
$n = $i + 1;
|
||||||
$entryParts = [
|
$entryParts = [
|
||||||
"[{$n}] " . $this->normalizeBlockText($product->name),
|
"[{$n}] " . $this->normalizeBlockText($product->name),
|
||||||
@@ -283,13 +292,19 @@ final readonly class PromptBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($lines !== []) {
|
if ($lines !== []) {
|
||||||
$parts[] =
|
$header =
|
||||||
"LIVE SHOP RESULTS (authoritative for current commercial details):\n" .
|
"LIVE SHOP RESULTS (authoritative for current commercial details):\n" .
|
||||||
"Use these results as the primary source for current price, availability, URL, and current shop-visible product naming.\n" .
|
"Use these results as the primary source for current price, availability, URL, and current shop-visible product naming.\n" .
|
||||||
"If retrieved documents conflict with shop data on price, availability, URL, or current naming, prefer the shop data.\n" .
|
"If retrieved documents conflict with shop data on price, availability, URL, or current naming, prefer the shop data.\n" .
|
||||||
"Output real URL values exactly as provided in the shop results. Do not replace them with placeholders, link labels, or product names.\n" .
|
"Output real URL values exactly as provided in the shop results. Do not replace them with placeholders, link labels, or product names.\n" .
|
||||||
"Do not infer undocumented technical specifications from shop data.\n\n" .
|
"Do not infer undocumented technical specifications from shop data.";
|
||||||
implode("\n\n", $lines);
|
|
||||||
|
if ($totalCount > count($limitedShopResults)) {
|
||||||
|
$header .= "\n" .
|
||||||
|
"Only the top " . count($limitedShopResults) . " ranked shop results are shown here out of {$totalCount} total results.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = $header . "\n\n" . implode("\n\n", $lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->implodeBlocks($parts);
|
return $this->implodeBlocks($parts);
|
||||||
|
|||||||
@@ -17,16 +17,14 @@ final readonly class CommerceQueryParser
|
|||||||
private QueryCleaner $queryCleaner,
|
private QueryCleaner $queryCleaner,
|
||||||
private CommerceQueryParserConfig $config,
|
private CommerceQueryParserConfig $config,
|
||||||
private CommerceIntentConfig $intentConfig,
|
private CommerceIntentConfig $intentConfig,
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parse(
|
public function parse(
|
||||||
string $originalPrompt,
|
string $originalPrompt,
|
||||||
string $intent,
|
string $intent,
|
||||||
string $historyContext = ''
|
string $historyContext = ''
|
||||||
): CommerceSearchQuery
|
): CommerceSearchQuery {
|
||||||
{
|
|
||||||
$normalizedPrompt = $this->normalize($originalPrompt);
|
$normalizedPrompt = $this->normalize($originalPrompt);
|
||||||
|
|
||||||
[$priceMin, $priceMax] = $this->extractPriceRange($normalizedPrompt);
|
[$priceMin, $priceMax] = $this->extractPriceRange($normalizedPrompt);
|
||||||
@@ -152,8 +150,10 @@ final readonly class CommerceQueryParser
|
|||||||
private function extractBrand(string $prompt): ?string
|
private function extractBrand(string $prompt): ?string
|
||||||
{
|
{
|
||||||
foreach ($this->config->getKnownBrands() as $brand) {
|
foreach ($this->config->getKnownBrands() as $brand) {
|
||||||
if (str_contains($prompt, $brand)) {
|
$normalizedBrand = $this->normalize((string) $brand);
|
||||||
return $brand;
|
|
||||||
|
if ($normalizedBrand !== '' && str_contains($prompt, $normalizedBrand)) {
|
||||||
|
return $normalizedBrand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,8 +166,7 @@ final readonly class CommerceQueryParser
|
|||||||
?string $brand,
|
?string $brand,
|
||||||
?float $priceMin,
|
?float $priceMin,
|
||||||
?float $priceMax
|
?float $priceMax
|
||||||
): string
|
): string {
|
||||||
{
|
|
||||||
$text = ' ' . $prompt . ' ';
|
$text = ' ' . $prompt . ' ';
|
||||||
|
|
||||||
foreach ($this->config->getPhrasesToRemove() as $phrase) {
|
foreach ($this->config->getPhrasesToRemove() as $phrase) {
|
||||||
@@ -179,7 +178,7 @@ final readonly class CommerceQueryParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($brand !== null && $brand !== '') {
|
if ($brand !== null && $brand !== '') {
|
||||||
$text = str_replace($brand, ' ', $text);
|
$text = preg_replace('/\b' . preg_quote($brand, '/') . '\b/u', ' ', $text) ?? $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($priceMin !== null || $priceMax !== null) {
|
if ($priceMin !== null || $priceMax !== null) {
|
||||||
@@ -211,7 +210,9 @@ final readonly class CommerceQueryParser
|
|||||||
|
|
||||||
private function extractLatestQuestionFromHistory(string $historyContext): string
|
private function extractLatestQuestionFromHistory(string $historyContext): string
|
||||||
{
|
{
|
||||||
if (preg_match_all('/^Question:\s*(.+)$/m', $historyContext, $matches) !== 1 && preg_match_all('/^Question:\s*(.+)$/m', $historyContext, $matches) === false) {
|
$result = preg_match_all('/^Question:\s*(.+)$/m', $historyContext, $matches);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Commerce;
|
namespace App\Commerce;
|
||||||
|
|
||||||
|
use App\Commerce\Dto\CommerceSearchQuery;
|
||||||
use App\Commerce\Dto\ShopProductResult;
|
use App\Commerce\Dto\ShopProductResult;
|
||||||
use App\Shopware\ShopwareCriteriaBuilder;
|
use App\Shopware\ShopwareCriteriaBuilder;
|
||||||
use App\Shopware\StoreApiClient;
|
use App\Shopware\StoreApiClient;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||||
@@ -18,11 +20,11 @@ final readonly class ShopSearchService
|
|||||||
private CommerceQueryParser $queryParser,
|
private CommerceQueryParser $queryParser,
|
||||||
private ShopwareCriteriaBuilder $criteriaBuilder,
|
private ShopwareCriteriaBuilder $criteriaBuilder,
|
||||||
private StoreApiClient $storeApiClient,
|
private StoreApiClient $storeApiClient,
|
||||||
|
private LoggerInterface $logger,
|
||||||
private bool $enabled = true,
|
private bool $enabled = true,
|
||||||
private int $maxResults = 25,
|
private int $maxResults = 25,
|
||||||
private string $baseUrl
|
private string $baseUrl
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,19 +36,87 @@ final readonly class ShopSearchService
|
|||||||
string $commerceHistoryContext = ''
|
string $commerceHistoryContext = ''
|
||||||
): array {
|
): array {
|
||||||
if (!$this->enabled) {
|
if (!$this->enabled) {
|
||||||
|
$this->logger->info('Shop search skipped because commerce search is disabled', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
]);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = [];
|
$primaryQuery = $this->queryParser->parse(
|
||||||
|
|
||||||
$query = $this->queryParser->parse(
|
|
||||||
$originalPrompt,
|
$originalPrompt,
|
||||||
$commerceIntent,
|
$commerceIntent,
|
||||||
$commerceHistoryContext
|
$commerceHistoryContext
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Shop search started', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'originalPrompt' => $originalPrompt,
|
||||||
|
'normalizedPrompt' => $primaryQuery->normalizedPrompt,
|
||||||
|
'searchText' => $primaryQuery->searchText,
|
||||||
|
'brand' => $primaryQuery->brand,
|
||||||
|
'sizes' => $primaryQuery->sizes,
|
||||||
|
'priceMin' => $primaryQuery->priceMin,
|
||||||
|
'priceMax' => $primaryQuery->priceMax,
|
||||||
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||||
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||||
|
'criteriaLimit' => $this->maxResults,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rankedProducts = $this->executeSearch($primaryQuery, $commerceIntent, $originalPrompt, true);
|
||||||
|
|
||||||
|
if ($rankedProducts === [] && $commerceHistoryContext !== '') {
|
||||||
|
$fallbackQuery = $this->queryParser->parse(
|
||||||
|
$originalPrompt,
|
||||||
|
$commerceIntent,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Shop search retry without commerce history context', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'originalPrompt' => $originalPrompt,
|
||||||
|
'normalizedPrompt' => $fallbackQuery->normalizedPrompt,
|
||||||
|
'searchText' => $fallbackQuery->searchText,
|
||||||
|
'brand' => $fallbackQuery->brand,
|
||||||
|
'sizes' => $fallbackQuery->sizes,
|
||||||
|
'priceMin' => $fallbackQuery->priceMin,
|
||||||
|
'priceMax' => $fallbackQuery->priceMax,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rankedProducts = $this->executeSearch($fallbackQuery, $commerceIntent, $originalPrompt, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Shop search finished', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'originalPrompt' => $originalPrompt,
|
||||||
|
'rankedProductsCount' => count($rankedProducts),
|
||||||
|
'topProducts' => array_map(
|
||||||
|
static fn(ShopProductResult $product): array => [
|
||||||
|
'name' => $product->name,
|
||||||
|
'productNumber' => $product->productNumber,
|
||||||
|
'manufacturer' => $product->manufacturer,
|
||||||
|
'available' => $product->available,
|
||||||
|
],
|
||||||
|
array_slice($rankedProducts, 0, 3)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $rankedProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ShopProductResult[]
|
||||||
|
*/
|
||||||
|
private function executeSearch(
|
||||||
|
CommerceSearchQuery $query,
|
||||||
|
string $commerceIntent,
|
||||||
|
string $originalPrompt,
|
||||||
|
bool $usesHistoryContext
|
||||||
|
): array {
|
||||||
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
||||||
|
|
||||||
|
$response = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->storeApiClient->searchProducts($criteria);
|
$response = $this->storeApiClient->searchProducts($criteria);
|
||||||
} catch (
|
} catch (
|
||||||
@@ -55,9 +125,52 @@ final readonly class ShopSearchService
|
|||||||
| ServerExceptionInterface
|
| ServerExceptionInterface
|
||||||
| TransportExceptionInterface $e
|
| TransportExceptionInterface $e
|
||||||
) {
|
) {
|
||||||
|
$this->logger->warning('Shop search request failed', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'originalPrompt' => $originalPrompt,
|
||||||
|
'normalizedPrompt' => $query->normalizedPrompt,
|
||||||
|
'searchText' => $query->searchText,
|
||||||
|
'brand' => $query->brand,
|
||||||
|
'sizes' => $query->sizes,
|
||||||
|
'priceMin' => $query->priceMin,
|
||||||
|
'priceMax' => $query->priceMax,
|
||||||
|
'usesHistoryContext' => $usesHistoryContext,
|
||||||
|
'criteria' => $criteria,
|
||||||
|
'exceptionClass' => $e::class,
|
||||||
|
'exceptionMessage' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->mapProducts($response);
|
$mappedProducts = $this->mapProducts($response);
|
||||||
|
$rankedProducts = $this->rerankProducts($mappedProducts, $query);
|
||||||
|
|
||||||
|
$this->logger->info('Shop search request finished', [
|
||||||
|
'commerceIntent' => $commerceIntent,
|
||||||
|
'originalPrompt' => $originalPrompt,
|
||||||
|
'normalizedPrompt' => $query->normalizedPrompt,
|
||||||
|
'searchText' => $query->searchText,
|
||||||
|
'brand' => $query->brand,
|
||||||
|
'sizes' => $query->sizes,
|
||||||
|
'priceMin' => $query->priceMin,
|
||||||
|
'priceMax' => $query->priceMax,
|
||||||
|
'usesHistoryContext' => $usesHistoryContext,
|
||||||
|
'rawElementsCount' => is_array($response['elements'] ?? null) ? count($response['elements']) : 0,
|
||||||
|
'mappedProductsCount' => count($mappedProducts),
|
||||||
|
'rankedProductsCount' => count($rankedProducts),
|
||||||
|
'topProducts' => array_map(
|
||||||
|
static fn(ShopProductResult $product): array => [
|
||||||
|
'name' => $product->name,
|
||||||
|
'productNumber' => $product->productNumber,
|
||||||
|
'manufacturer' => $product->manufacturer,
|
||||||
|
'available' => $product->available,
|
||||||
|
],
|
||||||
|
array_slice($rankedProducts, 0, 3)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $rankedProducts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +190,8 @@ final readonly class ShopSearchService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$relativeUrl = $this->extractUrl($row);
|
||||||
|
|
||||||
$results[] = new ShopProductResult(
|
$results[] = new ShopProductResult(
|
||||||
id: (string) ($row['id'] ?? ''),
|
id: (string) ($row['id'] ?? ''),
|
||||||
name: trim((string) ($row['translated']['name'] ?? '')),
|
name: trim((string) ($row['translated']['name'] ?? '')),
|
||||||
@@ -84,7 +199,7 @@ final readonly class ShopSearchService
|
|||||||
manufacturer: $this->extractManufacturer($row),
|
manufacturer: $this->extractManufacturer($row),
|
||||||
price: $this->extractPrice($row),
|
price: $this->extractPrice($row),
|
||||||
available: isset($row['available']) ? (bool) $row['available'] : null,
|
available: isset($row['available']) ? (bool) $row['available'] : null,
|
||||||
url: $this->baseUrl . $this->extractUrl($row),
|
url: $this->buildAbsoluteUrl($relativeUrl),
|
||||||
highlights: $this->extractHighlights($row),
|
highlights: $this->extractHighlights($row),
|
||||||
description: $this->cleanUpDescription($row),
|
description: $this->cleanUpDescription($row),
|
||||||
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
|
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
|
||||||
@@ -98,6 +213,157 @@ final readonly class ShopSearchService
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ShopProductResult[] $products
|
||||||
|
* @return ShopProductResult[]
|
||||||
|
*/
|
||||||
|
private function rerankProducts(array $products, CommerceSearchQuery $query): array
|
||||||
|
{
|
||||||
|
if (count($products) <= 1) {
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decorated = [];
|
||||||
|
|
||||||
|
foreach ($products as $index => $product) {
|
||||||
|
$decorated[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'score' => $this->scoreProduct($product, $query),
|
||||||
|
'product' => $product,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($decorated, static function (array $a, array $b): int {
|
||||||
|
if ($a['score'] === $b['score']) {
|
||||||
|
return $a['index'] <=> $b['index'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['score'] <=> $a['score'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn(array $entry): ShopProductResult => $entry['product'],
|
||||||
|
$decorated
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scoreProduct(ShopProductResult $product, CommerceSearchQuery $query): int
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
|
||||||
|
$normalizedPrompt = $this->normalizeForMatching($query->normalizedPrompt ?: $query->originalPrompt);
|
||||||
|
$normalizedSearchText = $this->normalizeForMatching($query->searchText);
|
||||||
|
$normalizedQuery = trim($normalizedPrompt . ' ' . $normalizedSearchText);
|
||||||
|
|
||||||
|
$queryTokens = $this->tokenize($normalizedQuery);
|
||||||
|
$queryNumberTokens = $this->extractNumberTokens($queryTokens);
|
||||||
|
|
||||||
|
$normalizedProductName = $this->normalizeForMatching($product->name);
|
||||||
|
$productNameTokens = $this->tokenize($normalizedProductName);
|
||||||
|
$productNameNumberTokens = $this->extractNumberTokens($productNameTokens);
|
||||||
|
|
||||||
|
$normalizedProductNumber = $this->normalizeForMatching((string) ($product->productNumber ?? ''));
|
||||||
|
$productNumberTokens = $this->tokenize($normalizedProductNumber);
|
||||||
|
$productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens);
|
||||||
|
|
||||||
|
$normalizedManufacturer = $this->normalizeForMatching((string) ($product->manufacturer ?? ''));
|
||||||
|
$normalizedBrand = $this->normalizeForMatching((string) ($query->brand ?? ''));
|
||||||
|
|
||||||
|
if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) {
|
||||||
|
$score += 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedBrand !== '') {
|
||||||
|
if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) {
|
||||||
|
$score += 40;
|
||||||
|
} elseif ($this->containsWholePhrase($normalizedProductName, $normalizedBrand)) {
|
||||||
|
$score += 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$score += $this->countOverlap($queryTokens, $productNameTokens) * 4;
|
||||||
|
$score += $this->countOverlap($queryTokens, $productNumberTokens) * 8;
|
||||||
|
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * 16;
|
||||||
|
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * 24;
|
||||||
|
|
||||||
|
foreach ($query->sizes as $size) {
|
||||||
|
$normalizedSize = $this->normalizeForMatching((string) $size);
|
||||||
|
|
||||||
|
if ($normalizedSize === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->containsWholePhrase($normalizedProductName, $normalizedSize)
|
||||||
|
|| $this->containsWholePhrase($normalizedProductNumber, $normalizedSize)) {
|
||||||
|
$score += 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($product->available === true) {
|
||||||
|
$score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $left
|
||||||
|
* @param string[] $right
|
||||||
|
*/
|
||||||
|
private function countOverlap(array $left, array $right): int
|
||||||
|
{
|
||||||
|
if ($left === [] || $right === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$leftSet = array_fill_keys($left, true);
|
||||||
|
$rightSet = array_fill_keys($right, true);
|
||||||
|
|
||||||
|
return count(array_intersect_key($leftSet, $rightSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $tokens
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function extractNumberTokens(array $tokens): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$tokens,
|
||||||
|
static fn(string $token): bool => preg_match('/\d/u', $token) === 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeForMatching(string $value): string
|
||||||
|
{
|
||||||
|
$value = mb_strtolower(trim($value));
|
||||||
|
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||||
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function tokenize(string $value): array
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function containsWholePhrase(string $normalizedText, string $normalizedPhrase): bool
|
||||||
|
{
|
||||||
|
if ($normalizedText === '' || $normalizedPhrase === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains(' ' . $normalizedText . ' ', ' ' . $normalizedPhrase . ' ');
|
||||||
|
}
|
||||||
|
|
||||||
private function getRelevantCustomFields(array $customField): string
|
private function getRelevantCustomFields(array $customField): string
|
||||||
{
|
{
|
||||||
$result = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? '');
|
$result = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? '');
|
||||||
@@ -179,6 +445,15 @@ final readonly class ShopSearchService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildAbsoluteUrl(?string $relativeUrl): ?string
|
||||||
|
{
|
||||||
|
if ($relativeUrl === null || trim($relativeUrl) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return string[]
|
* @return string[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class CommerceIntentConfig
|
|||||||
'produkt',
|
'produkt',
|
||||||
'sku',
|
'sku',
|
||||||
'Artikel',
|
'Artikel',
|
||||||
'kaufen'
|
'kaufen',
|
||||||
|
|
||||||
/* 'zeig',
|
/* 'zeig',
|
||||||
'welche',
|
'welche',
|
||||||
@@ -58,8 +58,9 @@ class CommerceIntentConfig
|
|||||||
'eur',
|
'eur',
|
||||||
'teuer',
|
'teuer',
|
||||||
'preis',
|
'preis',
|
||||||
'kosten'
|
'kosten',
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode('|', $pattern);
|
return implode('|', $pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +77,9 @@ class CommerceIntentConfig
|
|||||||
'pink',
|
'pink',
|
||||||
'gruen',
|
'gruen',
|
||||||
'orange',
|
'orange',
|
||||||
'braun'
|
'braun',
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode('|', $pattern);
|
return implode('|', $pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +91,10 @@ class CommerceIntentConfig
|
|||||||
'm',
|
'm',
|
||||||
'l',
|
'l',
|
||||||
'xl',
|
'xl',
|
||||||
'',
|
|
||||||
'xxl',
|
'xxl',
|
||||||
'xxxxl',
|
'xxxxl',
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode('|', $pattern);
|
return implode('|', $pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +103,9 @@ class CommerceIntentConfig
|
|||||||
$pattern = [
|
$pattern = [
|
||||||
'größe',
|
'größe',
|
||||||
'groesse',
|
'groesse',
|
||||||
'grösse'
|
'grösse',
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode('|', $pattern);
|
return implode('|', $pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,7 @@ final class SalesIntentLite
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SalesIntentConfig $config
|
private readonly SalesIntentConfig $config
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function detect(string $originalPrompt): array
|
public function detect(string $originalPrompt): array
|
||||||
@@ -62,7 +60,7 @@ final class SalesIntentLite
|
|||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// OBJECTION
|
// OBJECTION
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
foreach ($this->config->getComparisonSignals() as $word) {
|
foreach ($this->config->getObjectionSignals() as $word) {
|
||||||
if (preg_match('/\b' . preg_quote($word, '/') . '\b/u', $p)) {
|
if (preg_match('/\b' . preg_quote($word, '/') . '\b/u', $p)) {
|
||||||
$scores[self::OBJECTION] += 3;
|
$scores[self::OBJECTION] += 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ final readonly class QueryEnricher
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private QueryEnricherConfig $config
|
private QueryEnricherConfig $config
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,58 +18,46 @@ final readonly class QueryEnricher
|
|||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* - input: "water hardness device"
|
* - input: "water hardness device"
|
||||||
* - output: "water hardness device | Synonyms: residual hardness, model"
|
* - output: "water hardness device residual hardness model"
|
||||||
*/
|
*/
|
||||||
public function enrichPrompt(string $query): string
|
public function enrichPrompt(string $query): string
|
||||||
{
|
{
|
||||||
if (trim($query) === '') {
|
$originalQuery = trim($query);
|
||||||
|
|
||||||
|
if ($originalQuery === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the original query untouched for the final output.
|
|
||||||
$originalQuery = $query;
|
|
||||||
|
|
||||||
// Normalize the query for case-insensitive matching.
|
|
||||||
$normalizedQuery = $this->normalize($query);
|
|
||||||
|
|
||||||
// Expected format:
|
|
||||||
// [
|
|
||||||
// 'trousers' => 'jeans',
|
|
||||||
// 'jacket' => 'coat',
|
|
||||||
// ]
|
|
||||||
$mapping = $this->config->getEnrichQueryList();
|
$mapping = $this->config->getEnrichQueryList();
|
||||||
|
|
||||||
// Build a bidirectional lookup table:
|
|
||||||
// key -> value
|
|
||||||
// value -> key
|
|
||||||
$lookup = $this->buildBidirectionalLookup($mapping);
|
$lookup = $this->buildBidirectionalLookup($mapping);
|
||||||
|
$normalizedQuery = $this->normalizeForMatching($originalQuery);
|
||||||
// Split the query into searchable tokens.
|
|
||||||
$tokens = $this->tokenize($normalizedQuery);
|
|
||||||
|
|
||||||
$matches = [];
|
$matches = [];
|
||||||
|
|
||||||
foreach ($tokens as $token) {
|
foreach ($lookup as $needle => $mappedValue) {
|
||||||
// If the token exists in the lookup table, add the mapped counterpart.
|
if ($needle === '') {
|
||||||
if (isset($lookup[$token])) {
|
continue;
|
||||||
$matches[] = $lookup[$token];
|
}
|
||||||
|
|
||||||
|
if ($this->containsWholePhrase($normalizedQuery, $needle)) {
|
||||||
|
$matches[] = $mappedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates while preserving order.
|
$matches = array_values(array_unique(array_filter(
|
||||||
$matches = array_values(array_unique($matches));
|
$matches,
|
||||||
|
static fn(string $value): bool => trim($value) !== ''
|
||||||
|
)));
|
||||||
|
|
||||||
// If no matches were found, return the original query unchanged.
|
|
||||||
if ($matches === []) {
|
if ($matches === []) {
|
||||||
return $originalQuery;
|
return $originalQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the matched counterpart terms to the original query.
|
return trim($originalQuery . ' ' . implode(' ', $matches));
|
||||||
return $originalQuery . ' | Synonyms: ' . implode(', ', $matches);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a string for case-insensitive comparison.
|
* Normalizes a string for case-insensitive matching.
|
||||||
*/
|
*/
|
||||||
private function normalize(string $value): string
|
private function normalize(string $value): string
|
||||||
{
|
{
|
||||||
@@ -78,13 +65,29 @@ final readonly class QueryEnricher
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tokenizes the query into words.
|
* Normalizes a string for phrase-aware matching.
|
||||||
*
|
*
|
||||||
* Splits on every character that is not a letter or number.
|
* This keeps words searchable across spaces, punctuation and hyphens.
|
||||||
*/
|
*/
|
||||||
private function tokenize(string $value): array
|
private function normalizeForMatching(string $value): string
|
||||||
{
|
{
|
||||||
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
$value = $this->normalize($value);
|
||||||
|
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||||
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a normalized phrase exists as a full phrase in a normalized query.
|
||||||
|
*/
|
||||||
|
private function containsWholePhrase(string $normalizedQuery, string $normalizedPhrase): bool
|
||||||
|
{
|
||||||
|
if ($normalizedQuery === '' || $normalizedPhrase === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains(' ' . $normalizedQuery . ' ', ' ' . $normalizedPhrase . ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,20 +115,21 @@ final readonly class QueryEnricher
|
|||||||
$key = trim((string) $key);
|
$key = trim((string) $key);
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
|
|
||||||
// Skip incomplete pairs.
|
|
||||||
if ($key === '' || $value === '') {
|
if ($key === '' || $value === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalizedKey = $this->normalize($key);
|
$normalizedKey = $this->normalizeForMatching($key);
|
||||||
$normalizedValue = $this->normalize($value);
|
$normalizedValue = $this->normalizeForMatching($value);
|
||||||
|
|
||||||
// If the key is found in the query, return the value.
|
if ($normalizedKey !== '') {
|
||||||
$lookup[$normalizedKey] = $value;
|
$lookup[$normalizedKey] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
// If the value is found in the query, return the key.
|
if ($normalizedValue !== '') {
|
||||||
$lookup[$normalizedValue] = $key;
|
$lookup[$normalizedValue] = $key;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $lookup;
|
return $lookup;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user