From 55a61e2e717379c082b4156e5a94c2a957d2efbc Mon Sep 17 00:00:00 2001 From: team2 Date: Thu, 23 Apr 2026 22:07:07 +0200 Subject: [PATCH] move intent an config value into config files --- src/Commerce/ShopSearchService.php | 214 ++++++++----------- src/Config/ShopServiceConfig.php | 325 ++++++++++++++++++++++++++++- 2 files changed, 415 insertions(+), 124 deletions(-) diff --git a/src/Commerce/ShopSearchService.php b/src/Commerce/ShopSearchService.php index 671b836..35faa0e 100644 --- a/src/Commerce/ShopSearchService.php +++ b/src/Commerce/ShopSearchService.php @@ -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, diff --git a/src/Config/ShopServiceConfig.php b/src/Config/ShopServiceConfig.php index e0cb6b9..0210a3c 100644 --- a/src/Config/ShopServiceConfig.php +++ b/src/Config/ShopServiceConfig.php @@ -25,7 +25,6 @@ final class ShopServiceConfig 'monitor', 'monitore', 'controller', - 'controller', 'gerät für', 'geraet fuer', 'geräte für', @@ -131,4 +130,328 @@ final class ShopServiceConfig 'geräte', 'geraete', ]; + + public function getTopProductLogLimit(): int + { + return 3; + } + + /** + * @return string[] + */ + public function getDeviceFocusKeywords(): array + { + return [ + 'geräte', + 'geraete', + 'gerät', + 'geraet', + 'analysegerät', + 'analysegeraet', + 'messgerät', + 'messgeraet', + 'analysator', + 'controller', + 'monitor', + ]; + } + + /** + * @return string[] + */ + public function getAccessoryFocusKeywords(): array + { + return [ + 'indikator', + 'indikatoren', + 'reagenz', + 'reagenzien', + 'zubehör', + 'zubehor', + 'ersatzteil', + 'ersatzteile', + 'verbrauchsmaterial', + 'service set', + 'serviceset', + 'filter', + 'pumpenkopf', + 'motorblock', + ]; + } + + /** + * @return array + */ + public function getAccessoryFocusVariantMap(): array + { + return [ + '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'], + 'service-set' => ['service set', 'serviceset', 'service-set'], + ]; + } + + /** + * @return string[] + */ + public function getDeviceQueryKeywords(): array + { + return self::DEVICE_QUERY_KEYWORDS; + } + + /** + * @return string[] + */ + public function getAccessoryQueryKeywords(): array + { + return self::ACCESSORY_QUERY_KEYWORDS; + } + + /** + * @return string[] + */ + public function getAccessoryProductKeywords(): array + { + return self::ACCESSORY_PRODUCT_KEYWORDS; + } + + /** + * @return string[] + */ + public function getDeviceProductKeywords(): array + { + return self::DEVICE_PRODUCT_KEYWORDS; + } + + public function getExactProductNumberPhraseScore(): int + { + return 160; + } + + public function getExactProductNamePhraseScore(): int + { + return 90; + } + + public function getExactManufacturerMatchScore(): int + { + return 40; + } + + public function getBrandContainedInNameScore(): int + { + return 20; + } + + public function getNameTokenOverlapWeight(): int + { + return 6; + } + + public function getProductNumberTokenOverlapWeight(): int + { + return 10; + } + + public function getCorpusTokenOverlapWeight(): int + { + return 2; + } + + public function getNameNumberOverlapWeight(): int + { + return 18; + } + + public function getProductNumberNumberOverlapWeight(): int + { + return 28; + } + + public function getCorpusNumberOverlapWeight(): int + { + return 8; + } + + public function getSizeMatchScore(): int + { + return 12; + } + + public function getAvailabilityBonusScore(): int + { + return 1; + } + + public function getDeviceQueryDeviceProductBonus(): int + { + return 60; + } + + public function getDeviceQueryAccessoryPenalty(): int + { + return 120; + } + + public function getAccessoryQueryAccessoryProductBonus(): int + { + return 30; + } + + public function getAccessoryQueryDeviceProductBonus(): int + { + return 10; + } + + public function getContainsDigitPattern(): string + { + return '/\d/u'; + } + + public function getMatchingCleanupPattern(): string + { + return '/[^\p{L}\p{N}]+/u'; + } + + public function getWhitespaceCollapsePattern(): string + { + return '/\s+/u'; + } + + public function getTokenSplitPattern(): string + { + return '/[^\p{L}\p{N}]+/u'; + } + + public function wrapWithPaddingSpaces(string $value): string + { + return ' ' . trim($value) . ' '; + } + + /** + * @return string[] + */ + public function getPriceNormalizationSearch(): array + { + return ['€', ' ', '.']; + } + + /** + * @return string[] + */ + public function getPriceNormalizationReplace(): array + { + return ['', '', '']; + } + + public function getPrimaryCustomFieldKey(): string + { + return 'migration_Backup_product_attr1'; + } + + public function getSecondaryCustomFieldKey(): string + { + return 'migration_Backup_product_attr2'; + } + + public function getUseCasesCustomFieldKey(): string + { + return 'migration_Backup_product_attr4'; + } + + public function getLanguagesCustomFieldKey(): string + { + return 'migration_Backup_product_attr5'; + } + + public function getPrimarySecondarySeparator(): string + { + return ': '; + } + + public function getUseCasesLabel(): string + { + return 'Einsatzgebiete: '; + } + + public function getLanguagesLabel(): string + { + return 'Sprachen: '; + } + + public function getCustomFieldJoinSeparator(): string + { + return ' | '; + } + + public function getDescriptionEmptyLinePattern(): string + { + return '/^[ \t]*\R/m'; + } + + public function getDescriptionWhitespaceCleanupPattern(): string + { + return '/[ \t]{2,}/'; + } + + public function getDescriptionMaxLength(): int + { + return 1500; + } + + public function getPriceDecimals(): int + { + return 2; + } + + public function getPriceDecimalSeparator(): string + { + return ','; + } + + public function getPriceThousandsSeparator(): string + { + return '.'; + } + + public function getPriceSuffix(): string + { + return ' €'; + } + + public function buildRelativeSeoUrl(string $path): string + { + return '/' . ltrim($path, '/'); + } + + public function getAvailableHighlightLabel(): string + { + return 'Verfügbar'; + } + + public function getUnavailableHighlightLabel(): string + { + return 'Nicht verfügbar'; + } + + public function getProductNumberHighlightPrefix(): string + { + return 'Produktnummer: '; + } + + public function getMissingProductImagePlaceholder(): string + { + return 'no-image'; + } + + public function getDeduplicationSeparator(): string + { + return '|'; + } } \ No newline at end of file