move intent an config value into config files

This commit is contained in:
team2
2026-04-23 22:07:07 +02:00
parent fce44e971d
commit 55a61e2e71
2 changed files with 415 additions and 124 deletions

View File

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

View File

@@ -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<string, string[]>
*/
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 '|';
}
}