move intent an config value into config files
This commit is contained in:
@@ -26,6 +26,7 @@ final readonly class ShopSearchService
|
||||
private CommerceQueryParser $queryParser,
|
||||
private ShopwareCriteriaBuilder $criteriaBuilder,
|
||||
private StoreApiClient $storeApiClient,
|
||||
private ShopServiceConfig $shopConfig,
|
||||
private LoggerInterface $logger,
|
||||
private bool $enabled = true,
|
||||
private int $maxResults = 25,
|
||||
@@ -53,8 +54,7 @@ final readonly class ShopSearchService
|
||||
$primaryQuery = $this->queryParser->parse(
|
||||
$originalPrompt,
|
||||
$commerceIntent,
|
||||
$commerceHistoryContext,
|
||||
$referenceContext
|
||||
$commerceHistoryContext
|
||||
);
|
||||
|
||||
$focusMode = $this->determineFocusMode(
|
||||
@@ -97,8 +97,7 @@ final readonly class ShopSearchService
|
||||
$fallbackQuery = $this->queryParser->parse(
|
||||
$originalPrompt,
|
||||
$commerceIntent,
|
||||
'',
|
||||
$referenceContext
|
||||
''
|
||||
);
|
||||
|
||||
$this->logger->info('Shop search retry without commerce history context', [
|
||||
@@ -156,7 +155,7 @@ final readonly class ShopSearchService
|
||||
'available' => $product->available,
|
||||
'price' => $product->price,
|
||||
],
|
||||
array_slice($finalProducts, 0, 3)
|
||||
array_slice($finalProducts, 0, $this->shopConfig->getTopProductLogLimit())
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -222,10 +221,12 @@ final readonly class ShopSearchService
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseSearchText = $referenceContext->buildReferenceSearchText();
|
||||
|
||||
$baseQuery = new CommerceSearchQuery(
|
||||
originalPrompt: $originalPrompt,
|
||||
normalizedPrompt: mb_strtolower($referenceContext->buildReferenceSearchText(), 'UTF-8'),
|
||||
searchText: $referenceContext->buildReferenceSearchText(),
|
||||
normalizedPrompt: mb_strtolower($baseSearchText, 'UTF-8'),
|
||||
searchText: $baseSearchText,
|
||||
brand: $referenceContext->manufacturer,
|
||||
sizes: [],
|
||||
properties: [],
|
||||
@@ -308,22 +309,11 @@ final readonly class ShopSearchService
|
||||
private function expandFocusTermVariants(string $focusTerm): array
|
||||
{
|
||||
$normalized = $this->normalizeForMatching($focusTerm);
|
||||
|
||||
$variants = [$normalized];
|
||||
$variantMap = $this->shopConfig->getAccessoryFocusVariantMap();
|
||||
|
||||
$map = [
|
||||
'indikator' => ['indikator', 'indikatoren'],
|
||||
'indikatoren' => ['indikator', 'indikatoren'],
|
||||
'reagenz' => ['reagenz', 'reagenzien'],
|
||||
'reagenzien' => ['reagenz', 'reagenzien'],
|
||||
'ersatzteil' => ['ersatzteil', 'ersatzteile'],
|
||||
'ersatzteile' => ['ersatzteil', 'ersatzteile'],
|
||||
'service set' => ['service set', 'serviceset', 'service-set'],
|
||||
'serviceset' => ['service set', 'serviceset', 'service-set'],
|
||||
];
|
||||
|
||||
if (isset($map[$normalized])) {
|
||||
$variants = array_merge($variants, $map[$normalized]);
|
||||
if (isset($variantMap[$normalized]) && is_array($variantMap[$normalized])) {
|
||||
$variants = array_merge($variants, $variantMap[$normalized]);
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
@@ -415,38 +405,11 @@ final readonly class ShopSearchService
|
||||
): string {
|
||||
$normalizedPrompt = $this->normalizeForMatching($originalPrompt);
|
||||
|
||||
if ($this->containsAnyKeyword($normalizedPrompt, [
|
||||
'geräte',
|
||||
'geraete',
|
||||
'gerät',
|
||||
'geraet',
|
||||
'analysegerät',
|
||||
'analysegeraet',
|
||||
'messgerät',
|
||||
'messgeraet',
|
||||
'analysator',
|
||||
'controller',
|
||||
'monitor',
|
||||
])) {
|
||||
if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getDeviceFocusKeywords())) {
|
||||
return self::FOCUS_DEVICE;
|
||||
}
|
||||
|
||||
if ($this->containsAnyKeyword($normalizedPrompt, [
|
||||
'indikator',
|
||||
'indikatoren',
|
||||
'reagenz',
|
||||
'reagenzien',
|
||||
'zubehör',
|
||||
'zubehor',
|
||||
'ersatzteil',
|
||||
'ersatzteile',
|
||||
'verbrauchsmaterial',
|
||||
'service set',
|
||||
'serviceset',
|
||||
'filter',
|
||||
'pumpenkopf',
|
||||
'motorblock',
|
||||
])) {
|
||||
if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getAccessoryFocusKeywords())) {
|
||||
return self::FOCUS_ACCESSORY;
|
||||
}
|
||||
|
||||
@@ -673,22 +636,7 @@ final readonly class ShopSearchService
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'indikator',
|
||||
'indikatoren',
|
||||
'reagenz',
|
||||
'reagenzien',
|
||||
'zubehor',
|
||||
'zubehör',
|
||||
'ersatzteil',
|
||||
'ersatzteile',
|
||||
'verbrauchsmaterial',
|
||||
'service set',
|
||||
'serviceset',
|
||||
'filter',
|
||||
'pumpenkopf',
|
||||
'motorblock',
|
||||
] as $candidate) {
|
||||
foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) {
|
||||
$normalizedCandidate = $this->normalizeForMatching($candidate);
|
||||
|
||||
if ($normalizedCandidate !== '' && str_contains($normalizedPrompt, $normalizedCandidate)) {
|
||||
@@ -701,22 +649,7 @@ final readonly class ShopSearchService
|
||||
|
||||
private function isAccessoryFocusToken(string $token): bool
|
||||
{
|
||||
foreach ([
|
||||
'indikator',
|
||||
'indikatoren',
|
||||
'reagenz',
|
||||
'reagenzien',
|
||||
'zubehor',
|
||||
'zubehör',
|
||||
'ersatzteil',
|
||||
'ersatzteile',
|
||||
'verbrauchsmaterial',
|
||||
'service set',
|
||||
'serviceset',
|
||||
'filter',
|
||||
'pumpenkopf',
|
||||
'motorblock',
|
||||
] as $candidate) {
|
||||
foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) {
|
||||
if ($token === $this->normalizeForMatching($candidate)) {
|
||||
return true;
|
||||
}
|
||||
@@ -725,6 +658,9 @@ final readonly class ShopSearchService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $focusTerms
|
||||
*/
|
||||
private function productMatchesAnyFocusTerm(ShopProductResult $product, array $focusTerms): bool
|
||||
{
|
||||
if ($focusTerms === []) {
|
||||
@@ -768,9 +704,11 @@ final readonly class ShopSearchService
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace(['€', ' '], '', $price);
|
||||
$normalized = str_replace('.', '', $normalized);
|
||||
$normalized = str_replace(',', '.', $normalized);
|
||||
$normalized = str_replace(
|
||||
$this->shopConfig->getPriceNormalizationSearch(),
|
||||
$this->shopConfig->getPriceNormalizationReplace(),
|
||||
$price
|
||||
);
|
||||
|
||||
return is_numeric($normalized) ? (float) $normalized : null;
|
||||
}
|
||||
@@ -806,7 +744,7 @@ final readonly class ShopSearchService
|
||||
url: $this->buildAbsoluteUrl($relativeUrl),
|
||||
highlights: $this->extractHighlights($row),
|
||||
description: $this->cleanUpDescription($row),
|
||||
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
|
||||
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? $this->shopConfig->getMissingProductImagePlaceholder(),
|
||||
customFields: $this->getRelevantCustomFields($row['customFields'] ?? [])
|
||||
);
|
||||
}
|
||||
@@ -890,28 +828,28 @@ final readonly class ShopSearchService
|
||||
$productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens);
|
||||
|
||||
if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) {
|
||||
$score += 160;
|
||||
$score += $this->shopConfig->getExactProductNumberPhraseScore();
|
||||
}
|
||||
|
||||
if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) {
|
||||
$score += 90;
|
||||
$score += $this->shopConfig->getExactProductNamePhraseScore();
|
||||
}
|
||||
|
||||
if ($normalizedBrand !== '') {
|
||||
if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) {
|
||||
$score += 40;
|
||||
$score += $this->shopConfig->getExactManufacturerMatchScore();
|
||||
} elseif ($this->containsWholePhrase($normalizedProductName, $normalizedBrand)) {
|
||||
$score += 20;
|
||||
$score += $this->shopConfig->getBrandContainedInNameScore();
|
||||
}
|
||||
}
|
||||
|
||||
$score += $this->countOverlap($queryTokens, $productNameTokens) * 6;
|
||||
$score += $this->countOverlap($queryTokens, $productNumberTokens) * 10;
|
||||
$score += $this->countOverlap($queryTokens, $productCorpusTokens) * 2;
|
||||
$score += $this->countOverlap($queryTokens, $productNameTokens) * $this->shopConfig->getNameTokenOverlapWeight();
|
||||
$score += $this->countOverlap($queryTokens, $productNumberTokens) * $this->shopConfig->getProductNumberTokenOverlapWeight();
|
||||
$score += $this->countOverlap($queryTokens, $productCorpusTokens) * $this->shopConfig->getCorpusTokenOverlapWeight();
|
||||
|
||||
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * 18;
|
||||
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * 28;
|
||||
$score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * 8;
|
||||
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * $this->shopConfig->getNameNumberOverlapWeight();
|
||||
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * $this->shopConfig->getProductNumberNumberOverlapWeight();
|
||||
$score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * $this->shopConfig->getCorpusNumberOverlapWeight();
|
||||
|
||||
foreach ($normalizedSizes as $normalizedSize) {
|
||||
if ($normalizedSize === '') {
|
||||
@@ -923,14 +861,14 @@ final readonly class ShopSearchService
|
||||
|| $this->containsWholePhrase($normalizedProductNumber, $normalizedSize)
|
||||
|| $this->containsWholePhrase($normalizedProductCorpus, $normalizedSize)
|
||||
) {
|
||||
$score += 12;
|
||||
$score += $this->shopConfig->getSizeMatchScore();
|
||||
}
|
||||
}
|
||||
|
||||
$score += $this->scoreProductTypeMatch($product, $normalizedQuery);
|
||||
|
||||
if ($product->available === true) {
|
||||
$score += 1;
|
||||
$score += $this->shopConfig->getAvailabilityBonusScore();
|
||||
}
|
||||
|
||||
return $score;
|
||||
@@ -952,21 +890,21 @@ final readonly class ShopSearchService
|
||||
|
||||
if ($isDeviceQuery && !$isAccessoryQuery) {
|
||||
if ($isDeviceLikeProduct) {
|
||||
$score += 60;
|
||||
$score += $this->shopConfig->getDeviceQueryDeviceProductBonus();
|
||||
}
|
||||
|
||||
if ($isAccessoryLikeProduct) {
|
||||
$score -= 120;
|
||||
$score -= $this->shopConfig->getDeviceQueryAccessoryPenalty();
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAccessoryQuery) {
|
||||
if ($isAccessoryLikeProduct) {
|
||||
$score += 30;
|
||||
$score += $this->shopConfig->getAccessoryQueryAccessoryProductBonus();
|
||||
}
|
||||
|
||||
if ($isDeviceLikeProduct) {
|
||||
$score += 10;
|
||||
$score += $this->shopConfig->getAccessoryQueryDeviceProductBonus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,7 +913,7 @@ final readonly class ShopSearchService
|
||||
|
||||
private function isDeviceQuery(string $normalizedQuery): bool
|
||||
{
|
||||
foreach (ShopServiceConfig::DEVICE_QUERY_KEYWORDS as $keyword) {
|
||||
foreach ($this->shopConfig->getDeviceQueryKeywords() as $keyword) {
|
||||
if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) {
|
||||
return true;
|
||||
}
|
||||
@@ -986,7 +924,7 @@ final readonly class ShopSearchService
|
||||
|
||||
private function isAccessoryQuery(string $normalizedQuery): bool
|
||||
{
|
||||
foreach (ShopServiceConfig::ACCESSORY_QUERY_KEYWORDS as $keyword) {
|
||||
foreach ($this->shopConfig->getAccessoryQueryKeywords() as $keyword) {
|
||||
if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) {
|
||||
return true;
|
||||
}
|
||||
@@ -999,7 +937,7 @@ final readonly class ShopSearchService
|
||||
{
|
||||
$corpus = $this->buildNormalizedProductCorpus($product);
|
||||
|
||||
foreach (ShopServiceConfig::ACCESSORY_PRODUCT_KEYWORDS as $keyword) {
|
||||
foreach ($this->shopConfig->getAccessoryProductKeywords() as $keyword) {
|
||||
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
||||
return true;
|
||||
}
|
||||
@@ -1012,7 +950,7 @@ final readonly class ShopSearchService
|
||||
{
|
||||
$corpus = $this->buildNormalizedProductCorpus($product);
|
||||
|
||||
foreach (ShopServiceConfig::DEVICE_PRODUCT_KEYWORDS as $keyword) {
|
||||
foreach ($this->shopConfig->getDeviceProductKeywords() as $keyword) {
|
||||
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
||||
return true;
|
||||
}
|
||||
@@ -1058,15 +996,15 @@ final readonly class ShopSearchService
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$tokens,
|
||||
static fn(string $token): bool => preg_match('/\d/u', $token) === 1
|
||||
fn(string $token): bool => preg_match($this->shopConfig->getContainsDigitPattern(), $token) === 1
|
||||
));
|
||||
}
|
||||
|
||||
private function normalizeForMatching(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace($this->shopConfig->getMatchingCleanupPattern(), ' ', $value) ?? $value;
|
||||
$value = preg_replace($this->shopConfig->getWhitespaceCollapsePattern(), ' ', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
@@ -1080,7 +1018,12 @@ final readonly class ShopSearchService
|
||||
return [];
|
||||
}
|
||||
|
||||
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
return preg_split(
|
||||
$this->shopConfig->getTokenSplitPattern(),
|
||||
$value,
|
||||
-1,
|
||||
PREG_SPLIT_NO_EMPTY
|
||||
) ?: [];
|
||||
}
|
||||
|
||||
private function containsWholePhrase(string $normalizedText, string $normalizedPhrase): bool
|
||||
@@ -1089,7 +1032,10 @@ final readonly class ShopSearchService
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(' ' . $normalizedText . ' ', ' ' . $normalizedPhrase . ' ');
|
||||
return str_contains(
|
||||
$this->shopConfig->wrapWithPaddingSpaces($normalizedText),
|
||||
$this->shopConfig->wrapWithPaddingSpaces($normalizedPhrase)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1097,11 +1043,26 @@ final readonly class ShopSearchService
|
||||
*/
|
||||
private function getRelevantCustomFields(array $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'] ?? '');
|
||||
$primary = (string) ($customField[$this->shopConfig->getPrimaryCustomFieldKey()] ?? '');
|
||||
$secondary = (string) ($customField[$this->shopConfig->getSecondaryCustomFieldKey()] ?? '');
|
||||
$useCases = (string) ($customField[$this->shopConfig->getUseCasesCustomFieldKey()] ?? '');
|
||||
$languages = (string) ($customField[$this->shopConfig->getLanguagesCustomFieldKey()] ?? '');
|
||||
|
||||
return trim($result);
|
||||
$parts = [];
|
||||
|
||||
if ($primary !== '' || $secondary !== '') {
|
||||
$parts[] = trim($primary . $this->shopConfig->getPrimarySecondarySeparator() . $secondary);
|
||||
}
|
||||
|
||||
if ($useCases !== '') {
|
||||
$parts[] = $this->shopConfig->getUseCasesLabel() . $useCases;
|
||||
}
|
||||
|
||||
if ($languages !== '') {
|
||||
$parts[] = $this->shopConfig->getLanguagesLabel() . $languages;
|
||||
}
|
||||
|
||||
return trim(implode($this->shopConfig->getCustomFieldJoinSeparator(), array_filter($parts)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1115,11 +1076,11 @@ final readonly class ShopSearchService
|
||||
|
||||
$newDesc = strip_tags((string) ($description['translated']['description']));
|
||||
$newDesc = html_entity_decode($newDesc);
|
||||
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc) ?? $newDesc;
|
||||
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc) ?? $newDesc;
|
||||
$newDesc = preg_replace($this->shopConfig->getDescriptionEmptyLinePattern(), '', $newDesc) ?? $newDesc;
|
||||
$newDesc = preg_replace($this->shopConfig->getDescriptionWhitespaceCleanupPattern(), ' ', $newDesc) ?? $newDesc;
|
||||
$result = trim((string) $newDesc);
|
||||
|
||||
return mb_substr($result, 0, 1500);
|
||||
return mb_substr($result, 0, $this->shopConfig->getDescriptionMaxLength());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1165,7 +1126,12 @@ final readonly class ShopSearchService
|
||||
$value = (float) $candidate;
|
||||
|
||||
if ($value > 0.0) {
|
||||
return number_format($value, 2, ',', '.') . ' €';
|
||||
return number_format(
|
||||
$value,
|
||||
$this->shopConfig->getPriceDecimals(),
|
||||
$this->shopConfig->getPriceDecimalSeparator(),
|
||||
$this->shopConfig->getPriceThousandsSeparator()
|
||||
) . $this->shopConfig->getPriceSuffix();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,7 +1157,7 @@ final readonly class ShopSearchService
|
||||
$path = $seoUrl['seoPathInfo'] ?? null;
|
||||
|
||||
if (is_string($path) && trim($path) !== '') {
|
||||
return '/' . ltrim($path, '/');
|
||||
return $this->shopConfig->buildRelativeSeoUrl($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,11 +1182,13 @@ final readonly class ShopSearchService
|
||||
$highlights = [];
|
||||
|
||||
if (isset($row['available'])) {
|
||||
$highlights[] = (bool) $row['available'] ? 'Verfügbar' : 'Nicht verfügbar';
|
||||
$highlights[] = (bool) $row['available']
|
||||
? $this->shopConfig->getAvailableHighlightLabel()
|
||||
: $this->shopConfig->getUnavailableHighlightLabel();
|
||||
}
|
||||
|
||||
if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
|
||||
$highlights[] = 'Produktnummer: ' . trim($row['productNumber']);
|
||||
$highlights[] = $this->shopConfig->getProductNumberHighlightPrefix() . trim($row['productNumber']);
|
||||
}
|
||||
|
||||
return array_values(array_unique($highlights));
|
||||
@@ -1236,7 +1204,7 @@ final readonly class ShopSearchService
|
||||
$seen = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$key = mb_strtolower(trim(implode('|', [
|
||||
$key = mb_strtolower(trim(implode($this->shopConfig->getDeduplicationSeparator(), [
|
||||
$product->id,
|
||||
$product->productNumber ?? '',
|
||||
$product->name,
|
||||
|
||||
Reference in New Issue
Block a user